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/README.md b/README.md index 6267cc3a7a..169a276db4 100644 --- a/README.md +++ b/README.md @@ -8,15 +8,13 @@
-💡 [Get started](https://thingsboard.io/docs/getting-started-guides/helloworld/) • 🌐 [Website](https://thingsboard.io/) • 📚 [Documentation](https://thingsboard.io/docs/) • 📔 [Blog](https://thingsboard.io/blog/) • ▶️ [Live demo](https://demo.thingsboard.io/signup) • 🔗 [LinkedIn](https://www.linkedin.com/company/thingsboard/posts/?feedView=all) +💡 [Get started](https://thingsboard.io/docs/getting-started-guides/helloworld/) • 🌐 [Website](https://thingsboard.io/) • 📚 [Documentation](https://thingsboard.io/docs/) • 📔 [Blog](https://thingsboard.io/blog/) • 🔗 [LinkedIn](https://www.linkedin.com/company/thingsboard/posts/?feedView=all)
## 🚀 Installation options -* Install ThingsBoard [On-premise](https://thingsboard.io/docs/user-guide/install/installation-options/?ceInstallType=onPremise) -* Try [ThingsBoard Cloud](https://thingsboard.io/installations/) -* or [Use our Live demo](https://demo.thingsboard.io/signup) +Install ThingsBoard [on-premises](https://thingsboard.io/docs/user-guide/install/installation-options/?ceInstallType=onPremise) or use [ThingsBoard Cloud](https://thingsboard.io/installations/). ## 💡 Getting started with ThingsBoard @@ -123,9 +121,9 @@ ThingsBoard is a scalable, user-friendly, and device-agnostic IoT platform that [![SCADA Swimming pool](https://github.com/user-attachments/assets/68fd9e29-99f1-4c16-8c4c-476f4ccb20c0 "SCADA Swimming pool")](https://thingsboard.io/use-cases/scada/) -[**Fleet tracking**](https://thingsboard.io/use-cases/fleet-tracking/) +[**Site fleet tracking**](https://thingsboard.io/use-cases/site-fleet-tracking/) -[![Fleet tracking](https://github.com/user-attachments/assets/9e8938ba-ee0c-4599-9494-d74b7de8a63d "Fleet tracking")](https://thingsboard.io/use-cases/fleet-tracking/) +[![Site fleet tracking](https://github.com/user-attachments/assets/d6ce0766-b138-4a42-86aa-7112a543026c "Site fleet tracking")](https://thingsboard.io/use-cases/site-fleet-tracking/) [**Smart farming**](https://thingsboard.io/use-cases/smart-farming/) diff --git a/application/pom.xml b/application/pom.xml index f6ceba95a1..60186c3d3b 100644 --- a/application/pom.xml +++ b/application/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard - 4.3.1.3-SNAPSHOT + 4.4.0-SNAPSHOT thingsboard application 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/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/unread_notifications.json b/application/src/main/data/json/system/widget_types/unread_notifications.json index 7d699e7018..c4d2f899fb 100644 --- a/application/src/main/data/json/system/widget_types/unread_notifications.json +++ b/application/src/main/data/json/system/widget_types/unread_notifications.json @@ -3,6 +3,7 @@ "name": "Unread notifications", "deprecated": false, "image": "tb-image;/api/images/system/unread_notification_system_widget_image.png", + "description": "Displays the latest unread notifications and alarms for the current user.", "descriptor": { "type": "static", "sizeX": 5.5, diff --git a/application/src/main/data/upgrade/basic/schema_update.sql b/application/src/main/data/upgrade/basic/schema_update.sql index 66243461e2..2f9c0cd302 100644 --- a/application/src/main/data/upgrade/basic/schema_update.sql +++ b/application/src/main/data/upgrade/basic/schema_update.sql @@ -14,69 +14,14 @@ -- limitations under the License. -- --- UPDATE TENANT PROFILE CONFIGURATION START +-- CALCULATED FIELD ADDITIONAL INFO ADDITION START -UPDATE tenant_profile -SET profile_data = jsonb_set( - profile_data, - '{configuration}', - jsonb_build_object( - 'minAllowedScheduledUpdateIntervalInSecForCF', 10, - 'maxRelationLevelPerCfArgument', 2, - 'maxRelatedEntitiesToReturnPerCfArgument', 100, - 'minAllowedDeduplicationIntervalInSecForCF', 10, - 'minAllowedAggregationIntervalInSecForCF', 60, - 'intermediateAggregationIntervalInSecForCF', 300, - 'cfReevaluationCheckInterval', 60, - 'alarmsReevaluationInterval', 60 - ) - || - jsonb_strip_nulls(profile_data -> 'configuration') -) -WHERE NOT ( - jsonb_strip_nulls(profile_data -> 'configuration') ?& ARRAY[ - 'minAllowedScheduledUpdateIntervalInSecForCF', - 'maxRelationLevelPerCfArgument', - 'maxRelatedEntitiesToReturnPerCfArgument', - 'minAllowedDeduplicationIntervalInSecForCF', - 'minAllowedAggregationIntervalInSecForCF', - 'intermediateAggregationIntervalInSecForCF', - 'cfReevaluationCheckInterval', - 'alarmsReevaluationInterval' - ] -); +ALTER TABLE calculated_field ADD COLUMN IF NOT EXISTS additional_info varchar; --- UPDATE TENANT PROFILE CONFIGURATION END +-- CALCULATED FIELD ADDITIONAL INFO ADDITION END --- CALCULATED FIELD UNIQUE CONSTRAINT UPDATE START +-- RULE CHAIN NOTES MIGRATION START -ALTER TABLE calculated_field DROP CONSTRAINT IF EXISTS calculated_field_unq_key; -ALTER TABLE calculated_field ADD CONSTRAINT calculated_field_unq_key UNIQUE (entity_id, type, name); +ALTER TABLE rule_chain ADD COLUMN IF NOT EXISTS notes varchar(1000000); --- CALCULATED FIELD UNIQUE CONSTRAINT UPDATE END - --- CALCULATED FIELD OUTPUT STRATEGY UPDATE START - -UPDATE calculated_field -SET configuration = jsonb_set( - configuration::jsonb, - '{output}', - (configuration::jsonb -> 'output') - || jsonb_build_object( - 'strategy', - jsonb_build_object( - 'type', 'RULE_CHAIN' - ) - ), - false - ) -WHERE (configuration::jsonb -> 'output' -> 'strategy') IS NULL; - --- CALCULATED FIELD OUTPUT STRATEGY UPDATE END - --- REMOVAL OF CALCULATED FIELD LINKS PERSISTENCE START - -DROP TABLE IF EXISTS calculated_field_link; -ANALYZE calculated_field; - --- REMOVAL OF CALCULATED FIELD LINKS PERSISTENCE END +-- RULE CHAIN NOTES MIGRATION END diff --git a/application/src/main/java/org/thingsboard/server/config/WebSocketConfiguration.java b/application/src/main/java/org/thingsboard/server/config/WebSocketConfiguration.java index 38f37cf35d..4ce7268232 100644 --- a/application/src/main/java/org/thingsboard/server/config/WebSocketConfiguration.java +++ b/application/src/main/java/org/thingsboard/server/config/WebSocketConfiguration.java @@ -60,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/SystemInfoController.java b/application/src/main/java/org/thingsboard/server/controller/SystemInfoController.java index 41e8949a71..e9c10261ad 100644 --- a/application/src/main/java/org/thingsboard/server/controller/SystemInfoController.java +++ b/application/src/main/java/org/thingsboard/server/controller/SystemInfoController.java @@ -78,6 +78,9 @@ public class SystemInfoController extends BaseController { @Value("${debug.settings.default_duration:15}") private int defaultDebugDurationMinutes; + @Value("${sql.query.key-filters-or-conditions.enabled:true}") + private boolean keyFiltersOrConditionsEnabled; + @Value("${sql.entity_data_query_nulls_order_strategy:default}") private String nullsOrderStrategy; @@ -189,6 +192,7 @@ public class SystemInfoController extends BaseController { systemParams.setMobileQrEnabled(Optional.ofNullable(qrCodeSettingService.findQrCodeSettings(TenantId.SYS_TENANT_ID)) .map(QrCodeSettings::getQrCodeConfig).map(QRCodeConfig::isShowOnHomePage) .orElse(false)); + systemParams.setAllowKeyFiltersOrConditions(keyFiltersOrConditionsEnabled); return systemParams; } diff --git a/application/src/main/java/org/thingsboard/server/controller/plugin/TbWebSocketHandler.java b/application/src/main/java/org/thingsboard/server/controller/plugin/TbWebSocketHandler.java index 133584201c..cf56d051ca 100644 --- a/application/src/main/java/org/thingsboard/server/controller/plugin/TbWebSocketHandler.java +++ b/application/src/main/java/org/thingsboard/server/controller/plugin/TbWebSocketHandler.java @@ -52,6 +52,7 @@ import org.thingsboard.server.config.WebSocketConfiguration; import org.thingsboard.server.dao.tenant.TbTenantProfileCache; import org.thingsboard.server.queue.util.TbCoreComponent; import org.thingsboard.server.service.security.auth.jwt.JwtAuthenticationProvider; +import org.thingsboard.server.service.security.auth.pat.ApiKeyAuthenticationProvider; import org.thingsboard.server.service.security.exception.JwtExpiredTokenException; import org.thingsboard.server.service.security.model.SecurityUser; import org.thingsboard.server.service.security.model.UserPrincipal; @@ -100,6 +101,8 @@ public class TbWebSocketHandler extends TextWebSocketHandler implements WebSocke private RateLimitService rateLimitService; @Autowired private JwtAuthenticationProvider authenticationProvider; + @Autowired + private ApiKeyAuthenticationProvider apiKeyAuthenticationProvider; @Value("${server.ws.send_timeout:5000}") private long sendTimeout; @@ -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/install/ThingsboardInstallService.java b/application/src/main/java/org/thingsboard/server/install/ThingsboardInstallService.java index 8ea989fc01..66695c396f 100644 --- a/application/src/main/java/org/thingsboard/server/install/ThingsboardInstallService.java +++ b/application/src/main/java/org/thingsboard/server/install/ThingsboardInstallService.java @@ -116,7 +116,6 @@ public class ThingsboardInstallService { entityDatabaseSchemaService.createDatabaseIndexes(); // TODO: cleanup update code after each release - systemDataLoaderService.updateDefaultNotificationConfigs(false); // Runs upgrade scripts that are not possible in plain SQL. dataUpdateService.updateData(); diff --git a/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/alarm/AlarmCalculatedFieldState.java b/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/alarm/AlarmCalculatedFieldState.java index 8aa2c6d1c9..f9c06dbda7 100644 --- a/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/alarm/AlarmCalculatedFieldState.java +++ b/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/alarm/AlarmCalculatedFieldState.java @@ -40,7 +40,7 @@ import org.thingsboard.server.common.data.alarm.rule.condition.AlarmConditionTyp import org.thingsboard.server.common.data.alarm.rule.condition.AlarmConditionValue; import org.thingsboard.server.common.data.alarm.rule.condition.expression.AlarmConditionExpression; import org.thingsboard.server.common.data.alarm.rule.condition.expression.AlarmConditionFilter; -import org.thingsboard.server.common.data.alarm.rule.condition.expression.ComplexOperation; +import org.thingsboard.server.common.data.query.ComplexOperation; import org.thingsboard.server.common.data.alarm.rule.condition.expression.SimpleAlarmConditionExpression; import org.thingsboard.server.common.data.alarm.rule.condition.expression.TbelAlarmConditionExpression; import org.thingsboard.server.common.data.alarm.rule.condition.expression.predicate.BooleanFilterPredicate; 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 c99261b249..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,11 +15,14 @@ */ 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; @@ -86,6 +89,8 @@ 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 @@ -96,14 +101,25 @@ 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 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 072a4878d5..0000000000 --- a/application/src/main/java/org/thingsboard/server/service/edge/rpc/EdgeGrpcService.java +++ /dev/null @@ -1,712 +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.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 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.StringUtils; -import org.thingsboard.server.common.transport.config.ssl.PemSslCredentials; -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.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.rpc.ssl.key_password:}") - private String keyPassword; - @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 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 { - setupSsl(builder); - } 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!"); - } - - /** - * 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) throws Exception { - 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); - } - - @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) { - ctx.getTsSubService().saveTimeseries(TimeseriesSaveRequest.builder() - .tenantId(tenantId) - .entityId(edgeId) - .entry(new LongDataEntry(key, value)) - .callback(new AttributeSaveCallback(tenantId, edgeId, key, value)) - .build()); - } else { - ctx.getTsSubService().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) { - ctx.getTsSubService().saveTimeseries(TimeseriesSaveRequest.builder() - .tenantId(tenantId) - .entityId(edgeId) - .entry(new BooleanDataEntry(key, value)) - .callback(new AttributeSaveCallback(tenantId, edgeId, key, value)) - .build()); - } else { - ctx.getTsSubService().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 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 de4a92ae8c..0000000000 --- a/application/src/main/java/org/thingsboard/server/service/edge/rpc/EdgeGrpcSession.java +++ /dev/null @@ -1,1029 +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.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.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.BooleanDataEntry; -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.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; -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 { - updateSyncInProgress(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; - if (!this.edge.getCustomerId().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(); - sendDownlinkMsg(edgeConfigMsg); - } - - public void startSyncProcess(boolean fullSync) { - if (!syncInProgress) { - log.info("[{}][{}][{}] Staring edge sync process", tenantId, edge.getId(), sessionId); - updateSyncInProgress(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 updateSyncInProgress(Boolean value) { - this.syncInProgress = value; - - ctx.getTsSubService().saveAttributes(AttributesSaveRequest.builder() - .tenantId(tenantId) - .entityId(edge.getId()) - .scope(AttributeScope.SERVER_SCOPE) - .entry(new BooleanDataEntry(DataConstants.EDGE_SYNC_IN_PROGRESS_ATTR_KEY, value)) - .callback(new AttributeSaveCallback(tenantId, edge.getId(), DataConstants.EDGE_SYNC_IN_PROGRESS_ATTR_KEY, value)) - .build()); - } - - 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() { - updateSyncInProgress(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(); - } - } - } - if (uplinkMsg.getApiKeyUpdateMsgCount() > 0) { - for (ApiKeyUpdateMsg apiKeyUpdateMsg : uplinkMsg.getApiKeyUpdateMsgList()) { - sequenceDependencyLock.lock(); - try { - result.add(ctx.getApiKeyProcessor().processApiKeyMsgFromEdge(edge.getTenantId(), edge, apiKeyUpdateMsg)); - } 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/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 67c0a19e66..0000000000 --- a/application/src/main/java/org/thingsboard/server/service/edge/rpc/KafkaEdgeGrpcSession.java +++ /dev/null @@ -1,196 +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()); - // Defensive backstop: the loop already gates polling on readiness; this only fires on the narrow race - // where readiness flips during poll(), and that already-polled batch is dropped here (can't rewind). - if (!isReadyToProcessGeneralEvents()) { - 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(); - } - } - - private boolean isReadyToProcessGeneralEvents() { - return isConnected() && !isSyncInProgress() && !isHighPriorityProcessing; - } - - @Override - public ListenableFuture migrateEdgeEvents() throws Exception { - return super.processEdgeEvents(); - } - - @Override - public ListenableFuture processEdgeEvents() { - if (!isReadyToProcessGeneralEvents()) { - 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()) - .readinessCheck(this::isReadyToProcessGeneralEvents) - .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; - try { - super.processHighPriorityEvents(); - } finally { - 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/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/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/AttributeSaveCallback.java b/application/src/main/java/org/thingsboard/server/service/edge/rpc/session/EdgeAttributeSaveCallback.java similarity index 79% rename from application/src/main/java/org/thingsboard/server/service/edge/rpc/AttributeSaveCallback.java rename to application/src/main/java/org/thingsboard/server/service/edge/rpc/session/EdgeAttributeSaveCallback.java index 7d0f14c24e..a5e063de2c 100644 --- a/application/src/main/java/org/thingsboard/server/service/edge/rpc/AttributeSaveCallback.java +++ b/application/src/main/java/org/thingsboard/server/service/edge/rpc/session/EdgeAttributeSaveCallback.java @@ -13,24 +13,29 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package org.thingsboard.server.service.edge.rpc; +package org.thingsboard.server.service.edge.rpc.session; import com.google.common.util.concurrent.FutureCallback; import jakarta.annotation.Nullable; -import lombok.AllArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.thingsboard.server.common.data.id.EdgeId; import org.thingsboard.server.common.data.id.TenantId; @Slf4j -@AllArgsConstructor -public class AttributeSaveCallback implements FutureCallback { +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); @@ -40,4 +45,5 @@ public class AttributeSaveCallback implements FutureCallback { 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..f42f48421a --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/edge/rpc/session/manager/KafkaBasedEdgeGrpcSessionManager.java @@ -0,0 +1,313 @@ +/** + * 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 (!isSessionReady(state)) { + 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) + .readinessCheck(this::isReadyToProcessGeneralEvents) + .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); + // Defensive backstop: the loop already gates polling on readiness; this only fires on the narrow race + // where readiness flips during poll(), and that already-polled batch is dropped here (can't rewind). + if (!isSessionReady(state)) { + 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 boolean isReadyToProcessGeneralEvents() { + return isSessionReady(getState()); + } + + /** Single source of truth for "the session may process general edge events" - see {@link #isReadyToProcessGeneralEvents}. */ + private boolean isSessionReady(EdgeSessionState state) { + return state != null && state.isConnected() && !state.isSyncInProgress() && !isHighPriorityProcessing; + } + + 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/install/DefaultDatabaseSchemaSettingsService.java b/application/src/main/java/org/thingsboard/server/service/install/DefaultDatabaseSchemaSettingsService.java index ce86cbef07..c03dc22b2d 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 @@ -32,8 +32,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.2.1", "4.2.1.x", - "4.2.2", "4.2.2.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/install/DefaultSystemDataLoaderService.java b/application/src/main/java/org/thingsboard/server/service/install/DefaultSystemDataLoaderService.java index 965d7105a9..d528f10899 100644 --- a/application/src/main/java/org/thingsboard/server/service/install/DefaultSystemDataLoaderService.java +++ b/application/src/main/java/org/thingsboard/server/service/install/DefaultSystemDataLoaderService.java @@ -53,7 +53,7 @@ import org.thingsboard.server.common.data.alarm.rule.AlarmRule; import org.thingsboard.server.common.data.alarm.rule.condition.AlarmConditionValue; import org.thingsboard.server.common.data.alarm.rule.condition.SimpleAlarmCondition; import org.thingsboard.server.common.data.alarm.rule.condition.expression.AlarmConditionFilter; -import org.thingsboard.server.common.data.alarm.rule.condition.expression.ComplexOperation; +import org.thingsboard.server.common.data.query.ComplexOperation; import org.thingsboard.server.common.data.alarm.rule.condition.expression.SimpleAlarmConditionExpression; import org.thingsboard.server.common.data.alarm.rule.condition.expression.predicate.BooleanFilterPredicate; import org.thingsboard.server.common.data.alarm.rule.condition.expression.predicate.NumericFilterPredicate; diff --git a/application/src/main/java/org/thingsboard/server/service/install/update/DefaultDataUpdateService.java b/application/src/main/java/org/thingsboard/server/service/install/update/DefaultDataUpdateService.java index 464714fde7..086d76a25c 100644 --- a/application/src/main/java/org/thingsboard/server/service/install/update/DefaultDataUpdateService.java +++ b/application/src/main/java/org/thingsboard/server/service/install/update/DefaultDataUpdateService.java @@ -31,6 +31,7 @@ import org.thingsboard.server.service.component.RuleNodeClassInfo; import org.thingsboard.server.service.install.DatabaseSchemaSettingsService; import org.thingsboard.server.service.install.DbUpgradeExecutorService; import org.thingsboard.server.service.install.lts.LtsMigrationService; +import org.thingsboard.server.service.install.lts.V4_3_1_3Migration; import org.thingsboard.server.utils.TbNodeUpgradeUtils; import java.util.ArrayList; @@ -51,11 +52,18 @@ public class DefaultDataUpdateService implements DataUpdateService { private final DbUpgradeExecutorService executorService; private final DatabaseSchemaSettingsService schemaSettingsService; private final LtsMigrationService ltsMigrationService; + private final V4_3_1_3Migration v4313Migration; @Override public void updateData() throws Exception { log.info("Updating data ..."); ltsMigrationService.runDataMigrations(schemaSettingsService.getDbSchemaVersion(), schemaSettingsService.getPackageSchemaVersion()); + // 4.4-release data step: the 4.3.1.3 data migration (obsolete widget-bundle cleanup) is dormant on 4.4 because + // LtsMigrationService.select() filters out non-target-family migrations and there are no 4.4-family beans, so the + // 4.3-family V4_3_1_3Migration is never selected. The 4.4 SUPPORTED_VERSIONS_FOR_UPGRADE allows upgrading from + // sources below 4.3.1.3, so we run it explicitly for every supported 4.3.x -> 4.4 upgrade. It is idempotent + // (bundle deletion checks existence first), so re-running it for a source already at 4.3.1.3 is a safe no-op. + v4313Migration.apply(); log.info("Data updated."); } diff --git a/application/src/main/java/org/thingsboard/server/service/iot_hub/DefaultIotHubService.java b/application/src/main/java/org/thingsboard/server/service/iot_hub/DefaultIotHubService.java index 5da54eb8b5..eb082c92c8 100644 --- a/application/src/main/java/org/thingsboard/server/service/iot_hub/DefaultIotHubService.java +++ b/application/src/main/java/org/thingsboard/server/service/iot_hub/DefaultIotHubService.java @@ -68,6 +68,7 @@ import org.thingsboard.server.service.entitiy.dashboard.TbDashboardService; import org.thingsboard.server.service.entitiy.device.TbDeviceService; import org.thingsboard.server.service.entitiy.device.profile.TbDeviceProfileService; import org.thingsboard.server.service.entitiy.widgets.type.TbWidgetTypeService; +import org.thingsboard.server.service.install.ProjectInfo; import org.thingsboard.server.service.rule.TbRuleChainService; import org.thingsboard.server.service.security.model.SecurityUser; @@ -117,6 +118,7 @@ public class DefaultIotHubService implements IotHubService { private final DeviceService deviceService; private final TbDeviceService tbDeviceService; private final SolutionService solutionService; + private final ProjectInfo projectInfo; // Field names of the marketplace version JSON payload. Both the install path and the // install-plan resolver parse the same shape, so the contract lives here in one place. @@ -203,10 +205,11 @@ public class DefaultIotHubService implements IotHubService { } try { - iotHubRestClient.reportVersionInstalled(versionId); + InstallReport report = buildInstallReport(tenantId.getId(), user.getId().getId(), + projectInfo.getProjectVersion(), projectInfo.getProductType()); + iotHubRestClient.reportVersionInstalled(versionId, report); } catch (Exception e) { - // Counter ping is best-effort — do not fail the install if it errors. - log.warn("[{}] Failed to report install counter for version {}: {}", tenantId, versionId, e.getMessage()); + log.warn("[{}] Failed to report install for version {}: {}", tenantId, versionId, e.getMessage()); } log.info("[{}] Successfully installed IoT Hub item version: {} (type: {})", tenantId, itemName, itemType); return installedItem; @@ -665,6 +668,13 @@ public class DefaultIotHubService implements IotHubService { } } + static InstallReport buildInstallReport(UUID tenantId, UUID userId, String tbVersion, String edition) { + String salt = tenantId.toString(); + String tenantHash = sha256(salt + tenantId); + String userHash = sha256(salt + userId); + return new InstallReport(tenantHash, userHash, tbVersion, edition); + } + @Override public InstallItemVersionResult registerDeviceInstall(SecurityUser user, String versionId, DeviceInstalledItemDescriptor descriptor) { TenantId tenantId = user.getTenantId(); @@ -704,10 +714,11 @@ public class DefaultIotHubService implements IotHubService { } try { - iotHubRestClient.reportVersionInstalled(versionId); + InstallReport report = buildInstallReport(tenantId.getId(), user.getId().getId(), + projectInfo.getProjectVersion(), projectInfo.getProductType()); + iotHubRestClient.reportVersionInstalled(versionId, report); } catch (Exception e) { - // Counter ping is best-effort — do not fail the install if it errors. - log.warn("[{}] Failed to report install counter for version {}: {}", tenantId, versionId, e.getMessage()); + log.warn("[{}] Failed to report install for version {}: {}", tenantId, versionId, e.getMessage()); } log.info("[{}] Registered device package install: {} (version {})", tenantId, itemName, version); diff --git a/application/src/main/java/org/thingsboard/server/service/iot_hub/InstallReport.java b/application/src/main/java/org/thingsboard/server/service/iot_hub/InstallReport.java new file mode 100644 index 0000000000..3b253e426c --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/iot_hub/InstallReport.java @@ -0,0 +1,19 @@ +/** + * 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.iot_hub; + +public record InstallReport(String tenantHash, String userHash, String tbVersion, String edition) { +} diff --git a/application/src/main/java/org/thingsboard/server/service/iot_hub/IotHubRestClient.java b/application/src/main/java/org/thingsboard/server/service/iot_hub/IotHubRestClient.java index c67fef55a8..ae3cf5b07e 100644 --- a/application/src/main/java/org/thingsboard/server/service/iot_hub/IotHubRestClient.java +++ b/application/src/main/java/org/thingsboard/server/service/iot_hub/IotHubRestClient.java @@ -120,9 +120,9 @@ public class IotHubRestClient { }); } - public void reportVersionInstalled(String versionId) { + public void reportVersionInstalled(String versionId, InstallReport report) { String url = baseUrl + "/api/versions/" + versionId + "/install"; log.debug("Reporting IoT Hub version installed: {}", url); - restTemplate.postForObject(url, null, Void.class); + restTemplate.postForObject(url, report, Void.class); } } 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 50f0f0e75a..92d76693f9 100644 --- a/application/src/main/java/org/thingsboard/server/service/query/DefaultEntityQueryService.java +++ b/application/src/main/java/org/thingsboard/server/service/query/DefaultEntityQueryService.java @@ -221,7 +221,7 @@ public class DefaultEntityQueryService implements EntityQueryService { private EntityDataQuery buildEntityDataQuery(AlarmCountQuery query) { EntityDataPageLink edpl = new EntityDataPageLink(maxEntitiesPerAlarmSubscription, 0, null, new EntityDataSortOrder(new EntityKey(EntityKeyType.ENTITY_FIELD, EntityKeyMapping.CREATED_TIME))); - return new EntityDataQuery(query.getEntityFilter(), edpl, null, null, query.getKeyFilters()); + return new EntityDataQuery(query.getEntityFilter(), edpl, null, null, query.getKeyFilters(), query.getKeyFiltersOperationOrDefault()); } private EntityDataQuery buildEntityDataQuery(AlarmDataQuery query) { @@ -233,7 +233,7 @@ public class DefaultEntityQueryService implements EntityQueryService { entitiesSortOrder = sortOrder; } EntityDataPageLink edpl = new EntityDataPageLink(maxEntitiesPerAlarmSubscription, 0, null, entitiesSortOrder); - return new EntityDataQuery(query.getEntityFilter(), edpl, query.getEntityFields(), query.getLatestValues(), query.getKeyFilters()); + return new EntityDataQuery(query.getEntityFilter(), edpl, query.getEntityFields(), query.getLatestValues(), query.getKeyFilters(), query.getKeyFiltersOperationOrDefault()); } @Override diff --git a/application/src/main/java/org/thingsboard/server/service/queue/DefaultTbCalculatedFieldConsumerService.java b/application/src/main/java/org/thingsboard/server/service/queue/DefaultTbCalculatedFieldConsumerService.java index df5683711a..8a3f05f0b1 100644 --- a/application/src/main/java/org/thingsboard/server/service/queue/DefaultTbCalculatedFieldConsumerService.java +++ b/application/src/main/java/org/thingsboard/server/service/queue/DefaultTbCalculatedFieldConsumerService.java @@ -145,15 +145,15 @@ public class DefaultTbCalculatedFieldConsumerService extends AbstractPartitionBa private void processMsgs(List> msgs, TbQueueConsumer> consumer, Object consumerKey, QueueConfig config) throws Exception { List> orderedMsgList = msgs.stream().map(msg -> new IdMsgPair<>(UUID.randomUUID(), msg)).toList(); ConcurrentMap> pendingMap = orderedMsgList.stream().collect( - Collectors.toConcurrentMap(IdMsgPair::getUuid, IdMsgPair::getMsg)); + Collectors.toConcurrentMap(IdMsgPair::uuid, IdMsgPair::msg)); CountDownLatch processingTimeoutLatch = new CountDownLatch(1); TbPackProcessingContext> ctx = new TbPackProcessingContext<>( processingTimeoutLatch, pendingMap, new ConcurrentHashMap<>()); PendingMsgHolder pendingMsgHolder = new PendingMsgHolder<>(); Future packSubmitFuture = consumersExecutor.submit(() -> { orderedMsgList.forEach((element) -> { - UUID id = element.getUuid(); - TbProtoQueueMsg msg = element.getMsg(); + UUID id = element.uuid(); + TbProtoQueueMsg msg = element.msg(); log.trace("[{}] Creating main callback for message: {}", id, msg.getValue()); TbCallback callback = new TbPackCallback<>(id, ctx); try { diff --git a/application/src/main/java/org/thingsboard/server/service/queue/DefaultTbCoreConsumerService.java b/application/src/main/java/org/thingsboard/server/service/queue/DefaultTbCoreConsumerService.java index ffb6af8319..2489c3a091 100644 --- a/application/src/main/java/org/thingsboard/server/service/queue/DefaultTbCoreConsumerService.java +++ b/application/src/main/java/org/thingsboard/server/service/queue/DefaultTbCoreConsumerService.java @@ -258,15 +258,15 @@ public class DefaultTbCoreConsumerService extends AbstractConsumerService> msgs, TbQueueConsumer> consumer, Object consumerKey, QueueConfig config) throws Exception { List> orderedMsgList = msgs.stream().map(msg -> new IdMsgPair<>(UUID.randomUUID(), msg)).toList(); ConcurrentMap> pendingMap = orderedMsgList.stream().collect( - Collectors.toConcurrentMap(IdMsgPair::getUuid, IdMsgPair::getMsg)); + Collectors.toConcurrentMap(IdMsgPair::uuid, IdMsgPair::msg)); CountDownLatch processingTimeoutLatch = new CountDownLatch(1); TbPackProcessingContext> ctx = new TbPackProcessingContext<>( processingTimeoutLatch, pendingMap, new ConcurrentHashMap<>()); PendingMsgHolder pendingMsgHolder = new PendingMsgHolder<>(); Future packSubmitFuture = consumersExecutor.submit(() -> { orderedMsgList.forEach((element) -> { - UUID id = element.getUuid(); - TbProtoQueueMsg msg = element.getMsg(); + UUID id = element.uuid(); + TbProtoQueueMsg msg = element.msg(); log.trace("[{}] Creating main callback for message: {}", id, msg.getValue()); TbCallback callback = new TbPackCallback<>(id, ctx); try { diff --git a/application/src/main/java/org/thingsboard/server/service/queue/DefaultTbEdgeConsumerService.java b/application/src/main/java/org/thingsboard/server/service/queue/DefaultTbEdgeConsumerService.java index 21361a1678..8498c3e205 100644 --- a/application/src/main/java/org/thingsboard/server/service/queue/DefaultTbEdgeConsumerService.java +++ b/application/src/main/java/org/thingsboard/server/service/queue/DefaultTbEdgeConsumerService.java @@ -128,15 +128,15 @@ public class DefaultTbEdgeConsumerService extends AbstractConsumerService> msgs, TbQueueConsumer> consumer, Object consumerKey, QueueConfig edgeQueueConfig) throws InterruptedException { List> orderedMsgList = msgs.stream().map(msg -> new IdMsgPair<>(UUID.randomUUID(), msg)).toList(); ConcurrentMap> pendingMap = orderedMsgList.stream().collect( - Collectors.toConcurrentMap(IdMsgPair::getUuid, IdMsgPair::getMsg)); + Collectors.toConcurrentMap(IdMsgPair::uuid, IdMsgPair::msg)); CountDownLatch processingTimeoutLatch = new CountDownLatch(1); TbPackProcessingContext> ctx = new TbPackProcessingContext<>( processingTimeoutLatch, pendingMap, new ConcurrentHashMap<>()); PendingMsgHolder pendingMsgHolder = new PendingMsgHolder<>(); Future submitFuture = consumersExecutor.submit(() -> { orderedMsgList.forEach((element) -> { - UUID id = element.getUuid(); - TbProtoQueueMsg msg = element.getMsg(); + UUID id = element.uuid(); + TbProtoQueueMsg msg = element.msg(); TbCallback callback = new TbPackCallback<>(id, ctx); try { ToEdgeMsg toEdgeMsg = msg.getValue(); diff --git a/application/src/main/java/org/thingsboard/server/service/queue/DefaultTbRuleEngineConsumerService.java b/application/src/main/java/org/thingsboard/server/service/queue/DefaultTbRuleEngineConsumerService.java index bb70a5f136..444cb904f5 100644 --- a/application/src/main/java/org/thingsboard/server/service/queue/DefaultTbRuleEngineConsumerService.java +++ b/application/src/main/java/org/thingsboard/server/service/queue/DefaultTbRuleEngineConsumerService.java @@ -69,6 +69,7 @@ public class DefaultTbRuleEngineConsumerService extends AbstractPartitionBasedCo private final TbRuleEngineConsumerContext ctx; private final QueueService queueService; private final TbRuleEngineDeviceRpcService tbDeviceRpcService; + private final TbMsgPackProcessingContextFactory packProcessingContextFactory; private final ConcurrentMap consumers = new ConcurrentHashMap<>(); @@ -83,11 +84,13 @@ public class DefaultTbRuleEngineConsumerService extends AbstractPartitionBasedCo TbApiUsageStateService apiUsageStateService, PartitionService partitionService, ApplicationEventPublisher eventPublisher, - JwtSettingsService jwtSettingsService) { + JwtSettingsService jwtSettingsService, + TbMsgPackProcessingContextFactory packProcessingContextFactory) { super(actorContext, tenantProfileCache, deviceProfileCache, assetProfileCache, tbResourceDataCache, apiUsageStateService, partitionService, eventPublisher, jwtSettingsService); this.ctx = ctx; this.tbDeviceRpcService = tbDeviceRpcService; this.queueService = queueService; + this.packProcessingContextFactory = packProcessingContextFactory; } @Override @@ -253,6 +256,7 @@ public class DefaultTbRuleEngineConsumerService extends AbstractPartitionBasedCo .consumerExecutor(consumersExecutor) .scheduler(scheduler) .taskExecutor(mgmtExecutor) + .packProcessingContextFactory(packProcessingContextFactory) .build(); consumers.put(queueKey, consumer); consumer.init(queue); diff --git a/application/src/main/java/org/thingsboard/server/service/queue/TbMsgPackProcessingContextFactory.java b/application/src/main/java/org/thingsboard/server/service/queue/TbMsgPackProcessingContextFactory.java new file mode 100644 index 0000000000..08307a68ca --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/queue/TbMsgPackProcessingContextFactory.java @@ -0,0 +1,35 @@ +/** + * Copyright © 2016-2026 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.service.queue; + +import org.springframework.stereotype.Component; +import org.thingsboard.server.service.queue.processing.TbRuleEngineSubmitStrategy; + +public interface TbMsgPackProcessingContextFactory { + + TbMsgPackProcessingContext create(String queueName, TbRuleEngineSubmitStrategy submitStrategy, boolean skipTimeouts); + + @Component + class DefaultTbMsgPackProcessingContextFactory implements TbMsgPackProcessingContextFactory { + + @Override + public TbMsgPackProcessingContext create(String queueName, TbRuleEngineSubmitStrategy submitStrategy, boolean skipTimeouts) { + return new TbMsgPackProcessingContext(queueName, submitStrategy, skipTimeouts); + } + + } + +} diff --git a/application/src/main/java/org/thingsboard/server/service/queue/processing/AbstractConsumerService.java b/application/src/main/java/org/thingsboard/server/service/queue/processing/AbstractConsumerService.java index 9f25ca209c..4243d71ce5 100644 --- a/application/src/main/java/org/thingsboard/server/service/queue/processing/AbstractConsumerService.java +++ b/application/src/main/java/org/thingsboard/server/service/queue/processing/AbstractConsumerService.java @@ -131,13 +131,13 @@ public abstract class AbstractConsumerService> msgs, TbQueueConsumer> consumer) throws Exception { List> orderedMsgList = msgs.stream().map(msg -> new IdMsgPair<>(UUID.randomUUID(), msg)).toList(); ConcurrentMap> pendingMap = orderedMsgList.stream().collect( - Collectors.toConcurrentMap(IdMsgPair::getUuid, IdMsgPair::getMsg)); + Collectors.toConcurrentMap(IdMsgPair::uuid, IdMsgPair::msg)); CountDownLatch processingTimeoutLatch = new CountDownLatch(1); TbPackProcessingContext> ctx = new TbPackProcessingContext<>( processingTimeoutLatch, pendingMap, new ConcurrentHashMap<>()); orderedMsgList.forEach(element -> { - UUID id = element.getUuid(); - TbProtoQueueMsg msg = element.getMsg(); + UUID id = element.uuid(); + TbProtoQueueMsg msg = element.msg(); log.trace("[{}] Creating notification callback for message: {}", id, msg.getValue()); TbCallback callback = new TbPackCallback<>(id, ctx); try { diff --git a/application/src/main/java/org/thingsboard/server/service/queue/processing/AbstractTbRuleEngineSubmitStrategy.java b/application/src/main/java/org/thingsboard/server/service/queue/processing/AbstractTbRuleEngineSubmitStrategy.java index 9f3344ca3e..291d9fc9b5 100644 --- a/application/src/main/java/org/thingsboard/server/service/queue/processing/AbstractTbRuleEngineSubmitStrategy.java +++ b/application/src/main/java/org/thingsboard/server/service/queue/processing/AbstractTbRuleEngineSubmitStrategy.java @@ -44,21 +44,21 @@ public abstract class AbstractTbRuleEngineSubmitStrategy implements TbRuleEngine @Override public ConcurrentMap> getPendingMap() { - return orderedMsgList.stream().collect(Collectors.toConcurrentMap(pair -> pair.uuid, pair -> pair.msg)); + return orderedMsgList.stream().collect(Collectors.toConcurrentMap(pair -> pair.uuid(), pair -> pair.msg())); } @Override public void update(ConcurrentMap> reprocessMap) { List> newOrderedMsgList = new ArrayList<>(reprocessMap.size()); for (IdMsgPair pair : orderedMsgList) { - if (reprocessMap.containsKey(pair.uuid)) { - if (StringUtils.isNotEmpty(pair.getMsg().getValue().getFailureMessage())) { - var toRuleEngineMsg = TransportProtos.ToRuleEngineMsg.newBuilder(pair.getMsg().getValue()) + if (reprocessMap.containsKey(pair.uuid())) { + if (StringUtils.isNotEmpty(pair.msg().getValue().getFailureMessage())) { + var toRuleEngineMsg = TransportProtos.ToRuleEngineMsg.newBuilder(pair.msg().getValue()) .clearFailureMessage() .clearRelationTypes() .build(); - var newMsg = new TbProtoQueueMsg<>(pair.getMsg().getKey(), toRuleEngineMsg, pair.getMsg().getHeaders()); - newOrderedMsgList.add(new IdMsgPair<>(pair.getUuid(), newMsg)); + var newMsg = new TbProtoQueueMsg<>(pair.msg().getKey(), toRuleEngineMsg, pair.msg().getHeaders()); + newOrderedMsgList.add(new IdMsgPair<>(pair.uuid(), newMsg)); } else { newOrderedMsgList.add(pair); } diff --git a/application/src/main/java/org/thingsboard/server/service/queue/processing/BatchTbRuleEngineSubmitStrategy.java b/application/src/main/java/org/thingsboard/server/service/queue/processing/BatchTbRuleEngineSubmitStrategy.java index 32fcd65ca4..605c5fe977 100644 --- a/application/src/main/java/org/thingsboard/server/service/queue/processing/BatchTbRuleEngineSubmitStrategy.java +++ b/application/src/main/java/org/thingsboard/server/service/queue/processing/BatchTbRuleEngineSubmitStrategy.java @@ -73,7 +73,7 @@ public class BatchTbRuleEngineSubmitStrategy extends AbstractTbRuleEngineSubmitS pendingPack.clear(); for (int i = startIdx; i < endIdx; i++) { IdMsgPair pair = orderedMsgList.get(i); - pendingPack.put(pair.uuid, pair.msg); + pendingPack.put(pair.uuid(), pair.msg()); } tmpPack = new LinkedHashMap<>(pendingPack); } diff --git a/application/src/main/java/org/thingsboard/server/service/queue/processing/BurstTbRuleEngineSubmitStrategy.java b/application/src/main/java/org/thingsboard/server/service/queue/processing/BurstTbRuleEngineSubmitStrategy.java index 9f48c7bb70..5da8b85dc4 100644 --- a/application/src/main/java/org/thingsboard/server/service/queue/processing/BurstTbRuleEngineSubmitStrategy.java +++ b/application/src/main/java/org/thingsboard/server/service/queue/processing/BurstTbRuleEngineSubmitStrategy.java @@ -34,7 +34,7 @@ public class BurstTbRuleEngineSubmitStrategy extends AbstractTbRuleEngineSubmitS if (log.isDebugEnabled()) { log.debug("[{}] submitting [{}] messages to rule engine", queueName, orderedMsgList.size()); } - orderedMsgList.forEach(pair -> msgConsumer.accept(pair.uuid, pair.msg)); + orderedMsgList.forEach(pair -> msgConsumer.accept(pair.uuid(), pair.msg())); } @Override diff --git a/application/src/main/java/org/thingsboard/server/service/queue/processing/IdMsgPair.java b/application/src/main/java/org/thingsboard/server/service/queue/processing/IdMsgPair.java index 81d2854dac..a0e440f7cd 100644 --- a/application/src/main/java/org/thingsboard/server/service/queue/processing/IdMsgPair.java +++ b/application/src/main/java/org/thingsboard/server/service/queue/processing/IdMsgPair.java @@ -15,19 +15,9 @@ */ package org.thingsboard.server.service.queue.processing; -import lombok.Getter; +import com.google.protobuf.GeneratedMessageV3; import org.thingsboard.server.queue.common.TbProtoQueueMsg; import java.util.UUID; -public class IdMsgPair { - @Getter - final UUID uuid; - @Getter - final TbProtoQueueMsg msg; - - public IdMsgPair(UUID uuid, TbProtoQueueMsg msg) { - this.uuid = uuid; - this.msg = msg; - } -} +public record IdMsgPair(UUID uuid, TbProtoQueueMsg msg) {} diff --git a/application/src/main/java/org/thingsboard/server/service/queue/processing/SequentialByEntityIdTbRuleEngineSubmitStrategy.java b/application/src/main/java/org/thingsboard/server/service/queue/processing/SequentialByEntityIdTbRuleEngineSubmitStrategy.java index cfc3bb4a38..5acb629824 100644 --- a/application/src/main/java/org/thingsboard/server/service/queue/processing/SequentialByEntityIdTbRuleEngineSubmitStrategy.java +++ b/application/src/main/java/org/thingsboard/server/service/queue/processing/SequentialByEntityIdTbRuleEngineSubmitStrategy.java @@ -51,7 +51,7 @@ public abstract class SequentialByEntityIdTbRuleEngineSubmitStrategy extends Abs entityIdToListMap.forEach((entityId, queue) -> { IdMsgPair msg = queue.peek(); if (msg != null) { - msgConsumer.accept(msg.uuid, msg.msg); + msgConsumer.accept(msg.uuid(), msg.msg()); } }); } @@ -71,13 +71,13 @@ public abstract class SequentialByEntityIdTbRuleEngineSubmitStrategy extends Abs IdMsgPair next = null; synchronized (queue) { IdMsgPair expected = queue.peek(); - if (expected != null && expected.uuid.equals(id)) { + if (expected != null && expected.uuid().equals(id)) { queue.poll(); next = queue.peek(); } } if (next != null) { - msgConsumer.accept(next.uuid, next.msg); + msgConsumer.accept(next.uuid(), next.msg()); } } } @@ -87,9 +87,9 @@ public abstract class SequentialByEntityIdTbRuleEngineSubmitStrategy extends Abs msgToEntityIdMap.clear(); entityIdToListMap.clear(); for (IdMsgPair pair : orderedMsgList) { - EntityId entityId = getEntityId(pair.msg.getValue()); + EntityId entityId = getEntityId(pair.msg().getValue()); if (entityId != null) { - msgToEntityIdMap.put(pair.uuid, entityId); + msgToEntityIdMap.put(pair.uuid(), entityId); entityIdToListMap.computeIfAbsent(entityId, id -> new LinkedList<>()).add(pair); } } diff --git a/application/src/main/java/org/thingsboard/server/service/queue/processing/SequentialTbRuleEngineSubmitStrategy.java b/application/src/main/java/org/thingsboard/server/service/queue/processing/SequentialTbRuleEngineSubmitStrategy.java index 64be9bd378..be32f155dd 100644 --- a/application/src/main/java/org/thingsboard/server/service/queue/processing/SequentialTbRuleEngineSubmitStrategy.java +++ b/application/src/main/java/org/thingsboard/server/service/queue/processing/SequentialTbRuleEngineSubmitStrategy.java @@ -60,11 +60,11 @@ public class SequentialTbRuleEngineSubmitStrategy extends AbstractTbRuleEngineSu int idx = msgIdx.get(); if (idx < listSize) { IdMsgPair pair = orderedMsgList.get(idx); - expectedMsgId = pair.uuid; + expectedMsgId = pair.uuid(); if (log.isDebugEnabled()) { - log.debug("[{}] submitting [{}] message to rule engine", queueName, pair.msg); + log.debug("[{}] submitting [{}] message to rule engine", queueName, pair.msg()); } - msgConsumer.accept(pair.uuid, pair.msg); + msgConsumer.accept(pair.uuid(), pair.msg()); } } diff --git a/application/src/main/java/org/thingsboard/server/service/queue/ruleengine/TbRuleEngineQueueConsumerManager.java b/application/src/main/java/org/thingsboard/server/service/queue/ruleengine/TbRuleEngineQueueConsumerManager.java index 8ba98b817a..3733b49f1f 100644 --- a/application/src/main/java/org/thingsboard/server/service/queue/ruleengine/TbRuleEngineQueueConsumerManager.java +++ b/application/src/main/java/org/thingsboard/server/service/queue/ruleengine/TbRuleEngineQueueConsumerManager.java @@ -42,6 +42,7 @@ import org.thingsboard.server.queue.common.consumer.TbQueueConsumerTask.Consumer import org.thingsboard.server.queue.discovery.QueueKey; import org.thingsboard.server.service.queue.TbMsgPackCallback; import org.thingsboard.server.service.queue.TbMsgPackProcessingContext; +import org.thingsboard.server.service.queue.TbMsgPackProcessingContextFactory; import org.thingsboard.server.service.queue.TbRuleEngineConsumerStats; import org.thingsboard.server.service.queue.processing.TbRuleEngineProcessingDecision; import org.thingsboard.server.service.queue.processing.TbRuleEngineProcessingResult; @@ -67,13 +68,15 @@ public class TbRuleEngineQueueConsumerManager extends MainQueueConsumerManager { Integer partitionId = tpi != null ? tpi.getPartition().orElse(-1) : null; @@ -82,6 +85,7 @@ public class TbRuleEngineQueueConsumerManager extends MainQueueConsumerManager submitMessage(packCtx, id, msg)); final boolean timeout = !packCtx.await(queue.getPackProcessingTimeout(), TimeUnit.MILLISECONDS); diff --git a/application/src/main/java/org/thingsboard/server/service/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/TbAlarmCountSubCtx.java b/application/src/main/java/org/thingsboard/server/service/subscription/TbAlarmCountSubCtx.java index c2fe743bb4..5b4287acd1 100644 --- a/application/src/main/java/org/thingsboard/server/service/subscription/TbAlarmCountSubCtx.java +++ b/application/src/main/java/org/thingsboard/server/service/subscription/TbAlarmCountSubCtx.java @@ -130,7 +130,7 @@ public class TbAlarmCountSubCtx extends TbAbstractEntityQuerySubCtx { entitiesSortOrder = sortOrder; } EntityDataPageLink edpl = new EntityDataPageLink(maxEntitiesPerAlarmSubscription, 0, null, entitiesSortOrder); - return new EntityDataQuery(query.getEntityFilter(), edpl, query.getEntityFields(), query.getLatestValues(), query.getKeyFilters()); + return new EntityDataQuery(query.getEntityFilter(), edpl, query.getEntityFields(), query.getLatestValues(), query.getKeyFilters(), query.getKeyFiltersOperationOrDefault()); } } diff --git a/application/src/main/java/org/thingsboard/server/service/transport/DefaultTransportApiService.java b/application/src/main/java/org/thingsboard/server/service/transport/DefaultTransportApiService.java index 48bd497c6e..5c21096862 100644 --- a/application/src/main/java/org/thingsboard/server/service/transport/DefaultTransportApiService.java +++ b/application/src/main/java/org/thingsboard/server/service/transport/DefaultTransportApiService.java @@ -22,6 +22,7 @@ import com.google.common.util.concurrent.ListenableFuture; import com.google.common.util.concurrent.ListeningExecutorService; import com.google.common.util.concurrent.MoreExecutors; import com.google.protobuf.ByteString; +import org.thingsboard.server.common.data.relation.RelationTypeGroup; import org.thingsboard.server.exception.EntitiesLimitExceededException; import jakarta.annotation.PostConstruct; import jakarta.annotation.PreDestroy; @@ -114,6 +115,7 @@ import java.util.stream.Collectors; import static org.thingsboard.server.service.transport.BasicCredentialsValidationResult.PASSWORD_MISMATCH; import static org.thingsboard.server.service.transport.BasicCredentialsValidationResult.VALID; +import static org.thingsboard.server.transport.mqtt.util.sparkplug.SparkplugTopicService.DEVICE_NAME_SPLIT_SEPARATOR; /** * Created by ashvayka on 05.10.18. @@ -139,6 +141,7 @@ public class DefaultTransportApiService implements TransportApiService { private final OtaPackageService otaPackageService; private final OtaPackageDataCache otaPackageDataCache; private final QueueService queueService; + public static final String GATEWAY_CREATED_RELATION = "Created"; private final ConcurrentMap deviceCreationLocks = new ConcurrentReferenceHashMap<>(16, ConcurrentReferenceHashMap.ReferenceType.WEAK); @@ -330,84 +333,188 @@ public class DefaultTransportApiService implements TransportApiService { } private TransportApiResponseMsg handle(GetOrCreateDeviceFromGatewayRequestMsg requestMsg) { - DeviceId gatewayId = new DeviceId(new UUID(requestMsg.getGatewayIdMSB(), requestMsg.getGatewayIdLSB())); + DeviceId gatewayId = toDeviceId(requestMsg); Device gateway = deviceService.findDeviceById(TenantId.SYS_TENANT_ID, gatewayId); - Lock deviceCreationLock = deviceCreationLocks.computeIfAbsent(requestMsg.getDeviceName(), id -> new ReentrantLock()); - deviceCreationLock.lock(); + String deviceName = requestMsg.getDeviceName(); + Lock lock = deviceCreationLocks.computeIfAbsent(deviceName, k -> new ReentrantLock()); + lock.lock(); try { - Device device = deviceService.findDeviceByTenantIdAndName(gateway.getTenantId(), requestMsg.getDeviceName()); - if (device == null) { - TenantId tenantId = gateway.getTenantId(); - device = new Device(); - device.setTenantId(tenantId); - device.setName(requestMsg.getDeviceName()); - device.setType(requestMsg.getDeviceType()); - device.setCustomerId(gateway.getCustomerId()); - DeviceProfile deviceProfile = deviceProfileCache.findOrCreateDeviceProfile(gateway.getTenantId(), requestMsg.getDeviceType()); - - device.setDeviceProfileId(deviceProfile.getId()); - ObjectNode additionalInfo = JacksonUtil.newObjectNode(); - additionalInfo.put(DataConstants.LAST_CONNECTED_GATEWAY, gatewayId.toString()); - device.setAdditionalInfo(additionalInfo); - device = deviceService.saveDevice(device); - - relationService.saveRelation(tenantId, new EntityRelation(gateway.getId(), device.getId(), "Created")); - - TbMsgMetaData metaData = new TbMsgMetaData(); - CustomerId customerId = gateway.getCustomerId(); - if (customerId != null && !customerId.isNullUid()) { - metaData.putValue("customerId", customerId.toString()); - } - metaData.putValue("gatewayId", gatewayId.toString()); - - DeviceId deviceId = device.getId(); - JsonNode entityNode = JacksonUtil.valueToTree(device); - TbMsg tbMsg = TbMsg.newMsg() - .type(TbMsgType.ENTITY_CREATED) - .originator(deviceId) - .customerId(customerId) - .copyMetaData(metaData) - .dataType(TbMsgDataType.JSON) - .data(JacksonUtil.toString(entityNode)) - .build(); - tbClusterService.pushMsgToRuleEngine(tenantId, deviceId, tbMsg, null); - } else { - JsonNode deviceAdditionalInfo = device.getAdditionalInfo(); - if (deviceAdditionalInfo == null) { - deviceAdditionalInfo = JacksonUtil.newObjectNode(); - } - if (deviceAdditionalInfo.isObject() && - (!deviceAdditionalInfo.has(DataConstants.LAST_CONNECTED_GATEWAY) - || !gatewayId.toString().equals(deviceAdditionalInfo.get(DataConstants.LAST_CONNECTED_GATEWAY).asText()))) { - ObjectNode newDeviceAdditionalInfo = (ObjectNode) deviceAdditionalInfo; - newDeviceAdditionalInfo.put(DataConstants.LAST_CONNECTED_GATEWAY, gatewayId.toString()); - deviceService.saveDevice(device); - } - } - GetOrCreateDeviceFromGatewayResponseMsg.Builder builder = GetOrCreateDeviceFromGatewayResponseMsg.newBuilder() - .setDeviceInfo(ProtoUtils.toDeviceInfoProto(device)); - DeviceProfile deviceProfile = deviceProfileCache.get(device.getTenantId(), device.getDeviceProfileId()); - if (deviceProfile != null) { - builder.setDeviceProfile(ProtoUtils.toProto(deviceProfile)); - } else { - log.warn("[{}] Failed to find device profile [{}] for device. ", device.getId(), device.getDeviceProfileId()); - } - return TransportApiResponseMsg.newBuilder() - .setGetOrCreateDeviceResponseMsg(builder.build()) - .build(); + Device device = findOrCreateDevice(requestMsg, gateway, gatewayId); + updateLastConnectedGateway(device, gatewayId); + return buildResponse(device); } catch (JsonProcessingException e) { - log.warn("[{}] Failed to lookup device by gateway id and name: [{}]", gatewayId, requestMsg.getDeviceName(), e); + log.warn("[{}] Failed to process device [{}]", gatewayId, deviceName, e); throw new RuntimeException(e); } catch (EntitiesLimitExceededException e) { - log.warn("[{}][{}] API limit exception: [{}]", e.getTenantId(), gatewayId, e.getMessage()); - return TransportApiResponseMsg.newBuilder() - .setGetOrCreateDeviceResponseMsg( - GetOrCreateDeviceFromGatewayResponseMsg.newBuilder() - .setError(TransportProtos.TransportApiRequestErrorCode.ENTITY_LIMIT)) - .build(); + return buildLimitErrorResponse(e, gatewayId); } finally { - deviceCreationLock.unlock(); + lock.unlock(); + } + } + + private DeviceId toDeviceId(GetOrCreateDeviceFromGatewayRequestMsg requestMsg) { + return new DeviceId(new UUID( + requestMsg.getGatewayIdMSB(), + requestMsg.getGatewayIdLSB() + )); + } + + private Device findOrCreateDevice(GetOrCreateDeviceFromGatewayRequestMsg requestMsg, + Device gateway, + DeviceId gatewayId) throws JsonProcessingException { + TenantId tenantId = gateway.getTenantId(); + String deviceName = requestMsg.getDeviceName(); + Device device = deviceService.findDeviceByTenantIdAndName(tenantId, deviceName); + if (device != null) { + return device; + } + String[] topicPath = requestMsg.getDeviceName().split(DEVICE_NAME_SPLIT_SEPARATOR); + device = tryRenameSparkplugDevice(requestMsg, gateway, topicPath); + if (device != null) { + return device; + } + device = createNewDevice(requestMsg, gateway, gatewayId, topicPath); + pushCreatedEvent(device, gateway); + return device; + } + + private Device tryRenameSparkplugDevice(GetOrCreateDeviceFromGatewayRequestMsg requestMsg, Device gateway, String[] topicPath) { + if (!requestMsg.getIsSparkplug()) { + return null; + } + + if (topicPath.length != 3) { + return null; + } + + String deviceId = topicPath[2]; + Device existingDevice = deviceService.findDeviceByTenantIdAndName(gateway.getTenantId(), deviceId); + + if (existingDevice == null) { + return null; + } + + // Security check: verify that the device was created by this gateway + boolean isRelated = false; + isRelated = relationService.checkRelation( + gateway.getTenantId(), + gateway.getId(), + existingDevice.getId(), + GATEWAY_CREATED_RELATION, + RelationTypeGroup.COMMON + ); + + // If the device is found but not related to this gateway + if (!isRelated) { + log.debug("[{}] Device [{}] exists but is not related to gateway. " + + "Skipping rename and allowing creation of Sparkplug device [{}].", + gateway.getId(), + existingDevice.getId(), + requestMsg.getDeviceName()); + + return null; + } + + // Logic for renaming the device if it's related and no naming conflicts exist + boolean changed = false; + String newName = requestMsg.getDeviceName(); + if (!newName.equals(existingDevice.getName())) { + existingDevice.setName(newName); + + if (StringUtils.isEmpty(existingDevice.getLabel())) { + existingDevice.setLabel(deviceId); + } + + changed = true; + } + + return changed ? deviceService.saveDevice(existingDevice) : existingDevice; + } + + private Device createNewDevice(GetOrCreateDeviceFromGatewayRequestMsg requestMsg, + Device gateway, + DeviceId gatewayId, String[] topicPath) { + TenantId tenantId = gateway.getTenantId(); + Device device = new Device(); + device.setTenantId(tenantId); + device.setName(requestMsg.getDeviceName()); + if (requestMsg.getIsSparkplug() && topicPath.length == 3) { + device.setLabel(topicPath[2]); } + device.setType(requestMsg.getDeviceType()); + device.setCustomerId(gateway.getCustomerId()); + DeviceProfile profile = + deviceProfileCache.findOrCreateDeviceProfile(tenantId, requestMsg.getDeviceType()); + device.setDeviceProfileId(profile.getId()); + ObjectNode additionalInfo = JacksonUtil.newObjectNode(); + additionalInfo.put(DataConstants.LAST_CONNECTED_GATEWAY, gatewayId.toString()); + device.setAdditionalInfo(additionalInfo); + device = deviceService.saveDevice(device); + relationService.saveRelation( + tenantId, + new EntityRelation(gateway.getId(), device.getId(), GATEWAY_CREATED_RELATION) + ); + return device; + } + + private void updateLastConnectedGateway(Device device, DeviceId gatewayId) { + String gatewayIdStr = gatewayId.toString(); + JsonNode info = device.getAdditionalInfo(); + ObjectNode objectNode = (info instanceof ObjectNode) + ? (ObjectNode) info + : JacksonUtil.newObjectNode(); + if (!objectNode.has(DataConstants.LAST_CONNECTED_GATEWAY) + || !gatewayIdStr.equals(objectNode.get(DataConstants.LAST_CONNECTED_GATEWAY).asText())) { + objectNode.put(DataConstants.LAST_CONNECTED_GATEWAY, gatewayIdStr); + device.setAdditionalInfo(objectNode); + deviceService.saveDevice(device); + } + } + + private void pushCreatedEvent(Device device, Device gateway) { + TenantId tenantId = gateway.getTenantId(); + CustomerId customerId = gateway.getCustomerId(); + TbMsgMetaData metaData = new TbMsgMetaData(); + metaData.putValue("gatewayId", gateway.getId().toString()); + if (customerId != null && !customerId.isNullUid()) { + metaData.putValue("customerId", customerId.toString()); + } + JsonNode entityNode = JacksonUtil.valueToTree(device); + TbMsg msg = TbMsg.newMsg() + .type(TbMsgType.ENTITY_CREATED) + .originator(device.getId()) + .customerId(customerId) + .copyMetaData(metaData) + .dataType(TbMsgDataType.JSON) + .data(JacksonUtil.toString(entityNode)) + .build(); + tbClusterService.pushMsgToRuleEngine(tenantId, device.getId(), msg, null); + } + + private TransportApiResponseMsg buildResponse(Device device) throws JsonProcessingException { + GetOrCreateDeviceFromGatewayResponseMsg.Builder builder = + GetOrCreateDeviceFromGatewayResponseMsg.newBuilder() + .setDeviceInfo(ProtoUtils.toDeviceInfoProto(device)); + DeviceProfile profile = + deviceProfileCache.get(device.getTenantId(), device.getDeviceProfileId()); + if (profile != null) { + builder.setDeviceProfile(ProtoUtils.toProto(profile)); + } + return TransportApiResponseMsg.newBuilder() + .setGetOrCreateDeviceResponseMsg(builder.build()) + .build(); + } + + private TransportApiResponseMsg buildLimitErrorResponse(EntitiesLimitExceededException e, + DeviceId gatewayId) { + log.warn("[{}][{}] API limit exception: [{}]", + e.getTenantId(), gatewayId, e.getMessage()); + return TransportApiResponseMsg.newBuilder() + .setGetOrCreateDeviceResponseMsg( + GetOrCreateDeviceFromGatewayResponseMsg.newBuilder() + .setError(TransportProtos.TransportApiRequestErrorCode.ENTITY_LIMIT) + ) + .build(); } private TransportApiResponseMsg handle(ProvisionDeviceRequestMsg requestMsg) { 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.yml b/application/src/main/resources/thingsboard.yml index 64d5a66a58..bde1873627 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}" @@ -117,23 +118,25 @@ server: response_timeout: "${DEFAULT_RULE_ENGINE_RESPONSE_TIMEOUT:10000}" # Application info parameters +# Exposes application metadata such as version string injected at build time. app: # Application version version: "@project.version@" -# Zookeeper connection parameters +# ZooKeeper connection parameters +# Controls ZooKeeper-based service discovery and cluster coordination for microservice deployments. zk: - # Enable/disable zookeeper discovery service. + # Enable/disable ZooKeeper discovery service. enabled: "${ZOOKEEPER_ENABLED:false}" - # Zookeeper connect string + # ZooKeeper connect string url: "${ZOOKEEPER_URL:localhost:2181}" - # Zookeeper retry interval in milliseconds + # ZooKeeper retry interval in milliseconds retry_interval_ms: "${ZOOKEEPER_RETRY_INTERVAL_MS:3000}" - # Zookeeper connection timeout in milliseconds + # ZooKeeper connection timeout in milliseconds connection_timeout_ms: "${ZOOKEEPER_CONNECTION_TIMEOUT_MS:3000}" - # Zookeeper session timeout in milliseconds + # ZooKeeper session timeout in milliseconds session_timeout_ms: "${ZOOKEEPER_SESSION_TIMEOUT_MS:3000}" - # Name of the directory in zookeeper 'filesystem' + # Name of the directory in ZooKeeper 'filesystem' zk_dir: "${ZOOKEEPER_NODES_DIR:/thingsboard}" # The recalculate_delay property is recommended in a microservices architecture setup for rule-engine services. # This property provides a pause to ensure that when a rule-engine service is restarted, other nodes don't immediately attempt to recalculate their partitions. @@ -141,6 +144,7 @@ zk: recalculate_delay: "${ZOOKEEPER_RECALCULATE_DELAY_MS:0}" # Cluster parameters +# Controls cluster statistics — tracks the number of messages exchanged between cluster nodes. cluster: stats: # Enable/Disable the cluster statistics. Calculates the number of messages sent between cluster nodes based on each type @@ -149,11 +153,13 @@ cluster: print_interval_ms: "${TB_CLUSTER_STATS_PRINT_INTERVAL_MS:10000}" # Plugins configuration parameters +# Defines classpath scan packages used to discover and register ThingsBoard extension plugins. plugins: # Comma-separated package list used during classpath scanning for plugins scan_packages: "${PLUGINS_SCAN_PACKAGES:org.thingsboard.server.extensions,org.thingsboard.rule.engine}" # Security parameters +# Configures JWT tokens, user login, device claiming, OAuth2, and CA certificate trust store. security: # JWT Token parameters jwt: # Since 3.4.2 values are persisted in the database during installation or upgrade. On Install, the key will be generated randomly if no custom value set. You can change it later from Web UI under SYS_ADMIN @@ -246,6 +252,7 @@ security: 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). @@ -254,6 +261,7 @@ mail: per_tenant_rate_limits: "${MAIL_PER_TENANT_RATE_LIMITS:}" # Usage statistics parameters +# Controls collection and reporting intervals for API usage stats at system, tenant, and customer levels. usage: stats: report: @@ -277,6 +285,7 @@ usage: report_interval: "${DEVICES_STATS_REPORT_INTERVAL:60}" # UI settings parameters +# Configures dashboard data limits and base URL for UI help assets. ui: # Dashboard parameters dashboard: @@ -285,9 +294,10 @@ ui: # Help parameters help: # Base URL for UI help assets - base-url: "${UI_HELP_BASE_URL:https://raw.githubusercontent.com/thingsboard/thingsboard-ui-help/release-4.3}" + base-url: "${UI_HELP_BASE_URL:https://raw.githubusercontent.com/thingsboard/thingsboard-ui-help/release-4.4}" # Database telemetry parameters +# Selects the storage backend (SQL, Cassandra, or TimescaleDB) for time-series and latest telemetry data. database: ts_max_intervals: "${DATABASE_TS_MAX_INTERVALS:700}" # Max number of DB queries generated by a single API call to fetch telemetry records ts: @@ -296,6 +306,7 @@ database: type: "${DATABASE_TS_LATEST_TYPE:sql}" # cassandra, sql, or timescale (for hybrid mode, DATABASE_TS_TYPE value should be cassandra, or timescale) # Cassandra driver configuration parameters +# Configures cluster name, keyspace, SSL, credentials, socket, and query settings for the Cassandra driver. cassandra: # Thingsboard cluster name cluster_name: "${CASSANDRA_CLUSTER_NAME:Thingsboard Cluster}" @@ -414,6 +425,7 @@ cassandra: 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: @@ -466,6 +478,10 @@ sql: # Interval in milliseconds for printing the latest statistic information about the tenant log_tenant_stats_interval_ms: "${SQL_LOG_TENANT_STATS_INTERVAL_MS:60000}" entity_data_query_nulls_order_strategy: "${SQL_ENTITY_DATA_QUERY_NULLS_ORDER_STRATEGY:default}" # Nulls ordering strategy for sql entity data query. Possible values: default, nulls_first, nulls_last. The default value is 'default', which means postgres default behavior will be applied: NULLS LAST for ASC and NULLS FIRST for DESC. The 'nulls_first' value means that NULL values will be ordered before non-NULL values regardless of the sort order. The 'nulls_last' value means that NULL values will be ordered after non-NULL values regardless of the sort order. + query: + key-filters-or-conditions: + # Enable/disable OR conditions between key filters in entity data queries + enabled: "${SQL_QUERY_KEY_FILTERS_OR_CONDITIONS_ENABLED:true}" postgres: # Specify partitioning size for timestamp key-value storage. Example: DAYS, MONTHS, YEARS, INDEFINITE. ts_key_value_partitioning: "${SQL_POSTGRES_TS_KV_PARTITIONING:MONTHS}" @@ -518,6 +534,7 @@ sql: query_timeout: "${SQL_RELATIONS_QUERY_TIMEOUT_SEC:20}" # This value has to be reasonably small to prevent the relation query from blocking all other DB calls # Actor system parameters +# Configures thread pools, timeouts, and behavior for the internal actor system processing devices, rules, and RPCs. actors: system: throughput: "${ACTORS_SYSTEM_THROUGHPUT:5}" # Number of messages the actor system will process per actor before switching to processing of messages for the next actor @@ -634,6 +651,8 @@ actors: # Time in seconds to receive calculation result. calculation_timeout: "${ACTORS_CALCULATION_TIMEOUT_SEC:5}" +# Debug settings parameters +# Configures the global default duration for debug mode used by rule chains and calculated fields. debug: settings: # Default duration (in minutes) for debug mode. Min value is 1 minute. Tenant profile settings override this one. @@ -641,6 +660,7 @@ debug: default_duration: "${DEBUG_SETTINGS_DEFAULT_DURATION_MINUTES:15}" # Cache settings parameters +# Configures cache type (Caffeine or Redis), pool size, and per-entity TTL and max-size limits for all caches. cache: # caffeine or redis(7.2 - latest compatible version) type: "${CACHE_TYPE:caffeine}" @@ -800,6 +820,7 @@ cache: spring.data.redis.repositories.enabled: false # Disable this because it is not required. # Redis/Valkey configuration parameters +# Configures standalone, cluster, or sentinel Redis connection, SSL, and connection pool settings used for caching. redis: # standalone or cluster or sentinel connection: @@ -836,9 +857,9 @@ redis: password: "${REDIS_SENTINEL_PASSWORD:}" # If set false will be used pool config build from values of the pool config section useDefaultPoolConfig: "${REDIS_USE_DEFAULT_POOL_CONFIG:true}" - # db index + # Redis logical database index to select after connecting. db: "${REDIS_DB:0}" - # db password + # Password for Redis authentication (leave empty if not required). password: "${REDIS_PASSWORD:}" # Redis username for ACL authentication (Redis 6.0+). Leave empty for legacy password-only auth username: "${REDIS_USERNAME:}" @@ -883,6 +904,7 @@ 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}" @@ -911,6 +933,7 @@ spring.mvc.cors: 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 @@ -929,6 +952,7 @@ spring.jpa.properties.hibernate.order_by.default_null_ordering: "${SPRING_JPA_PR spring.jpa.properties.hibernate.dialect: "${SPRING_JPA_DIALECT:org.thingsboard.server.dao.ThingsboardPostgreSQLDialect}" # we use custom dialect that contains ilike(arg1, arg2) function (is interpreted to postgres ILIKE operator) # SQL DAO Configuration parameters +# Configures Spring JPA, Hibernate DDL, and HikariCP datasource connections for the primary and events databases. spring: data: jpa: @@ -979,6 +1003,7 @@ spring: registerMbeans: "${SPRING_EVENTS_DATASOURCE_HIKARI_REGISTER_MBEANS:false}" # Audit log parameters +# Configures audit logging levels per entity type and optional forwarding to an external sink such as Elasticsearch. audit-log: # Enable/disable audit log functionality. enabled: "${AUDIT_LOG_ENABLED:true}" @@ -1021,6 +1046,7 @@ audit-log: password: "${AUDIT_LOG_SINK_PASSWORD:}" # Password used to access external sink system # Device state parameters +# Configures device inactivity detection, state persistence strategy, and rate limits for state rule nodes. state: # Device inactivity timeout is a global configuration parameter that defines when the device will be marked as "inactive" by the server. # The parameter value is in seconds. A user can overwrite this parameter for an individual device by setting the “inactivityTimeout” server-side attribute (NOTE: expects value in milliseconds). @@ -1051,7 +1077,8 @@ state: # Refill is set to be greedy. Please refer to Bucket4j library documentation for more details. rateLimit: "${DEVICE_STATE_NODE_RATE_LIMIT_CONFIGURATION:1:1,30:60,60:3600}" -# Tbel parameters +# TBEL parameters +# Configures the ThingsBoard Expression Language (TBEL) engine: limits, timeouts, thread pool, and blacklisting. tbel: # Enable/Disable TBEL feature. enabled: "${TBEL_ENABLED:true}" @@ -1080,6 +1107,7 @@ tbel: print_interval_ms: "${TB_TBEL_STATS_PRINT_INTERVAL_MS:10000}" # JS parameters +# Configures the JavaScript execution engine (local Nashorn or remote Node.js): limits, sandboxing, and thread pools. js: # local (Nashorn Engine, deprecated) OR remote JS-Executors (NodeJS) evaluator: "${JS_EVALUATOR:local}" @@ -1127,6 +1155,7 @@ js: print_interval_ms: "${TB_JS_REMOTE_STATS_PRINT_INTERVAL_MS:10000}" # Transport configuration parameters +# Configures session management, rate limits, and protocol-specific settings for HTTP, MQTT, CoAP, LwM2M, and SNMP transports. transport: sessions: # Session inactivity timeout is a global configuration parameter that defines how long the device transport session will be opened after the last message arrives from the device. @@ -1448,6 +1477,7 @@ transport: check_interval_seconds: "${TB_TRANSPORT_SSL_CERTIFICATE_RELOAD_CHECK_INTERVAL_SECONDS:60}" # CoAP server parameters +# Configures the standalone CoAP server bind address, port, DTLS encryption, and credential settings. coap: server: # Enable/disable coap server. @@ -1532,6 +1562,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: @@ -1585,6 +1616,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}" @@ -1653,6 +1685,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}" @@ -1660,6 +1693,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/**}" @@ -1691,6 +1725,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). @@ -1749,11 +1784,23 @@ queue: # The endpoint identification algorithm used by clients to validate server hostname. The default value is https ssl.algorithm: "${TB_QUEUE_KAFKA_CONFLUENT_SSL_ALGORITHM:https}" # The mechanism used to authenticate Schema Registry requests. SASL/PLAIN should only be used with TLS/SSL as a transport layer to ensure that clear passwords are not transmitted on the wire without encryption + # Set to OAUTHBEARER to authenticate via OAuth2/OIDC client-credentials (configure the oauth.* settings below) sasl.mechanism: "${TB_QUEUE_KAFKA_CONFLUENT_SASL_MECHANISM:PLAIN}" # Using JAAS Configuration for specifying multiple SASL mechanisms on a broker sasl.config: "${TB_QUEUE_KAFKA_CONFLUENT_SASL_JAAS_CONFIG:org.apache.kafka.common.security.plain.PlainLoginModule required username=\"CLUSTER_API_KEY\" password=\"CLUSTER_API_SECRET\";}" # Protocol used to communicate with brokers. Valid values are: PLAINTEXT, SSL, SASL_PLAINTEXT, SASL_SSL security.protocol: "${TB_QUEUE_KAFKA_CONFLUENT_SECURITY_PROTOCOL:SASL_SSL}" + # Optional: OAuth2 (OIDC) client-credentials settings. Applied only when sasl.mechanism is OAUTHBEARER. + # ThingsBoard wires Kafka's built-in OAuthBearerLoginCallbackHandler; the token is fetched and refreshed automatically. + oauth: + # Optional: OAuth2 client id + client-id: "${TB_QUEUE_KAFKA_CONFLUENT_OAUTH_CLIENT_ID:}" + # Optional: OAuth2 client secret + client-secret: "${TB_QUEUE_KAFKA_CONFLUENT_OAUTH_CLIENT_SECRET:}" + # Optional: OAuth2/OIDC token endpoint URL to request the bearer token from + endpoint-url: "${TB_QUEUE_KAFKA_CONFLUENT_OAUTH_ENDPOINT_URL:}" + # Optional: OAuth2 scope/audience requested for the token. Required by some IdPs for client-credentials (e.g. Azure AD's "api:///.default") + scope: "${TB_QUEUE_KAFKA_CONFLUENT_OAUTH_SCOPE:}" # Key-value properties for Kafka consumer per specific topic, e.g. tb_ota_package is a topic name for ota, tb_rule_engine.sq is a topic name for default SequentialByOriginator queue. # Check TB_QUEUE_CORE_OTA_TOPIC and TB_QUEUE_RE_SQ_TOPIC params consumer-properties-per-topic: @@ -2030,6 +2077,9 @@ queue: print-interval-ms: "${TB_QUEUE_RULE_ENGINE_STATS_PRINT_INTERVAL_MS:60000}" # Max length of the error message that is printed by statistics max-error-message-length: "${TB_QUEUE_RULE_ENGINE_MAX_ERROR_MESSAGE_LENGTH:4096}" + prometheus-stats: + # Enable/disable Prometheus statistics for individual Rule Engine message processing (records time in ms for success/failure). + enabled: "${TB_QUEUE_RULE_ENGINE_PROMETHEUS_STATS_ENABLED:false}" # After a queue is deleted (or the profile's isolation option was disabled), Rule Engine will continue reading related topics during this period before deleting the actual topics topic-deletion-delay: "${TB_QUEUE_RULE_ENGINE_TOPIC_DELETION_DELAY_SEC:15}" # Size of the thread pool that handles such operations as partition changes, config updates, queue deletion @@ -2101,12 +2151,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) @@ -2120,6 +2172,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}" @@ -2133,6 +2186,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}" @@ -2143,6 +2197,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}" @@ -2151,6 +2206,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: @@ -2163,6 +2219,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}" @@ -2171,6 +2228,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/cf/AlarmRulesTest.java b/application/src/test/java/org/thingsboard/server/cf/AlarmRulesTest.java index e38a23d905..9a001e0575 100644 --- a/application/src/test/java/org/thingsboard/server/cf/AlarmRulesTest.java +++ b/application/src/test/java/org/thingsboard/server/cf/AlarmRulesTest.java @@ -38,7 +38,7 @@ import org.thingsboard.server.common.data.alarm.rule.condition.RepeatingAlarmCon import org.thingsboard.server.common.data.alarm.rule.condition.SimpleAlarmCondition; import org.thingsboard.server.common.data.alarm.rule.condition.expression.AlarmConditionExpression; import org.thingsboard.server.common.data.alarm.rule.condition.expression.AlarmConditionFilter; -import org.thingsboard.server.common.data.alarm.rule.condition.expression.ComplexOperation; +import org.thingsboard.server.common.data.query.ComplexOperation; import org.thingsboard.server.common.data.alarm.rule.condition.expression.SimpleAlarmConditionExpression; import org.thingsboard.server.common.data.alarm.rule.condition.expression.TbelAlarmConditionExpression; import org.thingsboard.server.common.data.alarm.rule.condition.expression.predicate.NoDataFilterPredicate; 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/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/EntityQueryControllerTest.java b/application/src/test/java/org/thingsboard/server/controller/EntityQueryControllerTest.java index 835f00985b..5de4c9ffcf 100644 --- a/application/src/test/java/org/thingsboard/server/controller/EntityQueryControllerTest.java +++ b/application/src/test/java/org/thingsboard/server/controller/EntityQueryControllerTest.java @@ -28,6 +28,7 @@ import org.junit.Before; import org.junit.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.test.context.TestPropertySource; +import org.springframework.test.util.ReflectionTestUtils; import org.springframework.test.context.bean.override.mockito.MockitoBean; import org.springframework.test.web.servlet.ResultActions; import org.testcontainers.shaded.org.apache.commons.lang3.RandomStringUtils; @@ -62,6 +63,8 @@ import org.thingsboard.server.common.data.query.AlarmDataQuery; import org.thingsboard.server.common.data.query.AliasEntityId; import org.thingsboard.server.common.data.query.AvailableEntityKeysV2; import org.thingsboard.server.common.data.query.AvailableEntityKeysV2.KeyInfo; +import org.thingsboard.server.common.data.query.ComplexFilterPredicate; +import org.thingsboard.server.common.data.query.ComplexOperation; import org.thingsboard.server.common.data.query.DeviceTypeFilter; import org.thingsboard.server.common.data.query.DynamicValue; import org.thingsboard.server.common.data.query.DynamicValueSourceType; @@ -89,6 +92,7 @@ import org.thingsboard.server.common.data.relation.RelationEntityTypeFilter; import org.thingsboard.server.common.data.security.Authority; import org.thingsboard.server.common.data.tenant.profile.DefaultTenantProfileConfiguration; import org.thingsboard.server.common.data.tenant.profile.TenantProfileData; +import org.thingsboard.server.dao.entity.BaseEntityService; import org.thingsboard.server.dao.queue.QueueStatsService; import org.thingsboard.server.dao.service.DaoSqlTest; import org.thingsboard.server.edqs.util.EdqsRocksDb; @@ -123,6 +127,8 @@ public class EntityQueryControllerTest extends AbstractControllerTest { @Autowired private QueueStatsService queueStatsService; + @Autowired + private BaseEntityService baseEntityService; @MockitoBean private EdqsRocksDb edqsRocksDb; @@ -1343,7 +1349,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(); @@ -1729,4 +1735,523 @@ public class EntityQueryControllerTest extends AbstractControllerTest { return new EntityDataQuery(filter, new EntityDataPageLink(1, 0, null, null), Collections.emptyList(), null, null); } + // --- OR conditions tests --- + + private Device createDeviceWithSharedAttributes(String name, String type, String sharedAttributesPayload) throws Exception { + Device device = new Device(); + device.setName(name); + device.setType(type); + device = doPost("/api/device", device, Device.class); + if (sharedAttributesPayload != null) { + doPost("/api/plugins/telemetry/" + device.getId() + "/" + DataConstants.SHARED_SCOPE, + sharedAttributesPayload, String.class, status().isOk()); + } + return device; + } + + private Device createDeviceWithTimeseries(String name, String type, String timeseriesPayload) throws Exception { + Device device = new Device(); + device.setName(name); + device.setType(type); + device = doPost("/api/device", device, Device.class); + JsonNode payload = JacksonUtil.toJsonNode(timeseriesPayload); + doPost("/api/plugins/telemetry/" + EntityType.DEVICE.name() + "/" + device.getUuidId() + "/timeseries/SERVER_SCOPE", payload) + .andExpect(status().isOk()); + return device; + } + + private Alarm createAlarm(DeviceId originator, String type, AlarmSeverity severity) throws Exception { + Alarm alarm = new Alarm(); + alarm.setOriginator(originator); + alarm.setType(type); + alarm.setSeverity(severity); + return doPost("/api/alarm", alarm, Alarm.class); + } + + private static DeviceTypeFilter deviceTypeFilter(String type) { + DeviceTypeFilter filter = new DeviceTypeFilter(); + filter.setDeviceTypes(List.of(type)); + filter.setDeviceNameFilter(""); + return filter; + } + + private static KeyFilter numericKeyFilter(EntityKeyType keyType, String key, + NumericFilterPredicate.NumericOperation operation, double value) { + KeyFilter keyFilter = new KeyFilter(); + keyFilter.setKey(new EntityKey(keyType, key)); + keyFilter.setValueType(EntityKeyValueType.NUMERIC); + NumericFilterPredicate predicate = new NumericFilterPredicate(); + predicate.setValue(FilterPredicateValue.fromDouble(value)); + predicate.setOperation(operation); + keyFilter.setPredicate(predicate); + return keyFilter; + } + + private static KeyFilter numericAttributeKeyFilter(String key, + NumericFilterPredicate.NumericOperation operation, double value) { + return numericKeyFilter(EntityKeyType.ATTRIBUTE, key, operation, value); + } + + private static KeyFilter stringKeyFilter(EntityKeyType keyType, String key, + StringFilterPredicate.StringOperation operation, String value) { + KeyFilter keyFilter = new KeyFilter(); + keyFilter.setKey(new EntityKey(keyType, key)); + keyFilter.setValueType(EntityKeyValueType.STRING); + StringFilterPredicate predicate = new StringFilterPredicate(); + predicate.setValue(FilterPredicateValue.fromString(value)); + predicate.setOperation(operation); + keyFilter.setPredicate(predicate); + return keyFilter; + } + + private static KeyFilter stringAttributeKeyFilter(String key, + StringFilterPredicate.StringOperation operation, String value) { + return stringKeyFilter(EntityKeyType.ATTRIBUTE, key, operation, value); + } + + private static EntityDataPageLink pageLinkSortedByName(int pageSize, int page, String textSearch) { + EntityDataSortOrder sortOrder = new EntityDataSortOrder( + new EntityKey(EntityKeyType.ENTITY_FIELD, "name"), EntityDataSortOrder.Direction.ASC); + return new EntityDataPageLink(pageSize, page, textSearch, sortOrder); + } + + private static List nameEntityField() { + return Collections.singletonList(new EntityKey(EntityKeyType.ENTITY_FIELD, "name")); + } + + private static List extractNames(PageData result) { + return result.getData().stream() + .map(e -> e.getLatest().get(EntityKeyType.ENTITY_FIELD).get("name").getValue()) + .collect(Collectors.toList()); + } + + @Test + public void testCountEntitiesWithOrKeyFiltersOperation() throws Exception { + String type = "orTestType"; + createDeviceWithSharedAttributes("OrTestDeviceA", type, "{\"temperature\":60}"); + createDeviceWithSharedAttributes("OrTestDeviceB", type, "{\"temperature\":5}"); + createDeviceWithSharedAttributes("OrTestDeviceC", type, "{\"temperature\":30}"); + + List keyFilters = List.of( + numericAttributeKeyFilter("temperature", NumericFilterPredicate.NumericOperation.GREATER, 50), + numericAttributeKeyFilter("temperature", NumericFilterPredicate.NumericOperation.LESS, 10)); + + // OR: deviceA (60>50) and deviceB (5<10) match => count=2 + EntityCountQuery orQuery = new EntityCountQuery(deviceTypeFilter(type), keyFilters, ComplexOperation.OR); + await().atMost(TIMEOUT, TimeUnit.SECONDS).untilAsserted(() -> countByQueryAndCheck(orQuery, 2)); + + // AND: no device has temperature both >50 AND <10 => count=0 + EntityCountQuery andQuery = new EntityCountQuery(deviceTypeFilter(type), keyFilters, ComplexOperation.AND); + countByQueryAndCheck(andQuery, 0); + } + + @Test + public void testFindEntityDataWithOrKeyFiltersOperation() throws Exception { + String type = "orDataType"; + createDeviceWithSharedAttributes("OrDataDeviceX", type, "{\"status\":\"active\"}"); + createDeviceWithSharedAttributes("OrDataDeviceY", type, "{\"humidity\":80}"); + createDeviceWithSharedAttributes("OrDataDeviceZ", type, null); // no matching attribute + + List keyFilters = List.of( + stringAttributeKeyFilter("status", StringFilterPredicate.StringOperation.EQUAL, "active"), + numericAttributeKeyFilter("humidity", NumericFilterPredicate.NumericOperation.GREATER, 70)); + + // OR: deviceX matches status=active, deviceY matches humidity>70 + EntityDataQuery orQuery = new EntityDataQuery(deviceTypeFilter(type), pageLinkSortedByName(10, 0, null), + nameEntityField(), null, keyFilters, ComplexOperation.OR); + await().atMost(TIMEOUT, TimeUnit.SECONDS).untilAsserted(() -> findByQueryAndCheck(orQuery, 2)); + PageData result = findByQueryAndCheck(orQuery, 2); + assertThat(extractNames(result)).containsExactlyInAnyOrder("OrDataDeviceX", "OrDataDeviceY"); + } + + @Test + public void testFindEntityDataWithOrDoesNotLeakFilterOnlyEntityFields() throws Exception { + // Regression test: under OR, an entity-field filter (e.g. label) that isn't declared in + // entityFields must not leak its value into EntityData.latest[ENTITY_FIELD]. + String type = "orLeakGuardType"; + Device d1 = createDeviceWithSharedAttributes("OrLeakDeviceA", type, "{\"status\":\"active\"}"); + d1.setLabel("leak-label-A"); + doPost("/api/device", d1, Device.class); + + Device d2 = createDeviceWithSharedAttributes("OrLeakDeviceB", type, null); + d2.setLabel("leak-label-B"); + doPost("/api/device", d2, Device.class); + + List keyFilters = List.of( + stringAttributeKeyFilter("status", StringFilterPredicate.StringOperation.EQUAL, "active"), + buildStringKeyFilter(EntityKeyType.ENTITY_FIELD, "label", StringFilterPredicate.StringOperation.EQUAL, "leak-label-B")); + + EntityDataQuery orQuery = new EntityDataQuery(deviceTypeFilter(type), pageLinkSortedByName(10, 0, null), + nameEntityField(), null, keyFilters, ComplexOperation.OR); + await().atMost(TIMEOUT, TimeUnit.SECONDS).untilAsserted(() -> findByQueryAndCheck(orQuery, 2)); + PageData result = findByQueryAndCheck(orQuery, 2); + assertThat(extractNames(result)).containsExactlyInAnyOrder("OrLeakDeviceA", "OrLeakDeviceB"); + for (EntityData entity : result.getData()) { + assertThat(entity.getLatest().get(EntityKeyType.ENTITY_FIELD)).containsOnlyKeys("name"); + } + } + + @Test + public void testFindEntityDataWithOrSameKeyFilters() throws Exception { + String type = "orSameKeyType"; + createDeviceWithSharedAttributes("OrSameKeyDeviceA", type, "{\"temperature\":60}"); + createDeviceWithSharedAttributes("OrSameKeyDeviceB", type, "{\"temperature\":5}"); + + // Same key "temperature" with two different predicates exercises the OR-ungrouping path + List keyFilters = List.of( + numericAttributeKeyFilter("temperature", NumericFilterPredicate.NumericOperation.GREATER, 50), + numericAttributeKeyFilter("temperature", NumericFilterPredicate.NumericOperation.LESS, 10)); + + EntityDataQuery orQuery = new EntityDataQuery(deviceTypeFilter(type), pageLinkSortedByName(10, 0, null), + nameEntityField(), null, keyFilters, ComplexOperation.OR); + await().atMost(TIMEOUT, TimeUnit.SECONDS).untilAsserted(() -> findByQueryAndCheck(orQuery, 2)); + PageData result = findByQueryAndCheck(orQuery, 2); + assertThat(extractNames(result)).containsExactlyInAnyOrder("OrSameKeyDeviceA", "OrSameKeyDeviceB"); + } + + @Test + public void testCountEntitiesWithoutKeyFiltersOperation() throws Exception { + String type = "backCompatType"; + createDeviceWithSharedAttributes("BackCompatDeviceA", type, "{\"temperature\":60}"); + createDeviceWithSharedAttributes("BackCompatDeviceB", type, "{\"temperature\":5}"); + + List keyFilters = List.of( + numericAttributeKeyFilter("temperature", NumericFilterPredicate.NumericOperation.GREATER, 50), + numericAttributeKeyFilter("temperature", NumericFilterPredicate.NumericOperation.LESS, 10)); + + // Await attribute propagation via an OR query that should find 2 when propagated + EntityCountQuery orCheckQuery = new EntityCountQuery(deviceTypeFilter(type), keyFilters, ComplexOperation.OR); + await().atMost(TIMEOUT, TimeUnit.SECONDS).untilAsserted(() -> countByQueryAndCheck(orCheckQuery, 2)); + + // Query without keyFiltersOperation (null) -- should behave as AND + EntityCountQuery nullOpQuery = new EntityCountQuery(deviceTypeFilter(type), keyFilters); + Long nullResult = countByQueryAndCheck(nullOpQuery, 0); + + // Query with explicit AND -- should produce the same result + EntityCountQuery andOpQuery = new EntityCountQuery(deviceTypeFilter(type), keyFilters, ComplexOperation.AND); + Long andResult = countByQueryAndCheck(andOpQuery, 0); + + Assert.assertEquals(nullResult, andResult); + } + + @Test + public void testAlarmDataQueryWithOrKeyFiltersOperation() throws Exception { + loginTenantAdmin(); + + String type = "orAlarmType"; + Device deviceHot = createDeviceWithSharedAttributes("OrAlarmDeviceHot", type, "{\"temperature\":60}"); + Device deviceCold = createDeviceWithSharedAttributes("OrAlarmDeviceCold", type, "{\"temperature\":5}"); + Device deviceMid = createDeviceWithSharedAttributes("OrAlarmDeviceMid", type, "{\"temperature\":30}"); + + createAlarm(deviceHot.getId(), "highTemp", AlarmSeverity.CRITICAL); + createAlarm(deviceCold.getId(), "lowTemp", AlarmSeverity.WARNING); + createAlarm(deviceMid.getId(), "normalTemp", AlarmSeverity.WARNING); + + List keyFilters = List.of( + numericAttributeKeyFilter("temperature", NumericFilterPredicate.NumericOperation.GREATER, 50), + numericAttributeKeyFilter("temperature", NumericFilterPredicate.NumericOperation.LESS, 10)); + + AlarmDataPageLink pageLink = new AlarmDataPageLink(); + pageLink.setPage(0); + pageLink.setPageSize(100); + pageLink.setSortOrder(new EntityDataSortOrder(new EntityKey(EntityKeyType.ALARM_FIELD, "createdTime"))); + List alarmFields = List.of(new EntityKey(EntityKeyType.ALARM_FIELD, "type")); + + // OR query: should return alarms for deviceHot (60>50) and deviceCold (5<10) = 2 alarms + AlarmDataQuery orAlarmQuery = new AlarmDataQuery(deviceTypeFilter(type), pageLink, null, null, keyFilters, alarmFields, ComplexOperation.OR); + await().atMost(TIMEOUT, TimeUnit.SECONDS).untilAsserted(() -> findAlarmsByQueryAndCheck(orAlarmQuery, 2)); + PageData alarmResult = findAlarmsByQueryAndCheck(orAlarmQuery, 2); + List alarmTypes = alarmResult.getData().stream().map(AlarmData::getType).collect(Collectors.toList()); + assertThat(alarmTypes).containsExactlyInAnyOrder("highTemp", "lowTemp"); + + // AND query: no device has temp both >50 AND <10 => 0 alarms + AlarmDataQuery andAlarmQuery = new AlarmDataQuery(deviceTypeFilter(type), pageLink, null, null, keyFilters, alarmFields, ComplexOperation.AND); + findAlarmsByQueryAndCheck(andAlarmQuery, 0); + } + + @Test + public void testCountAlarmsByQueryWithOrKeyFiltersOperation() throws Exception { + loginTenantAdmin(); + + String type = "orAlarmCntType"; + Device deviceHot = createDeviceWithSharedAttributes("OrAlarmCntDeviceHot", type, "{\"temperature\":60}"); + Device deviceCold = createDeviceWithSharedAttributes("OrAlarmCntDeviceCold", type, "{\"temperature\":5}"); + Device deviceMid = createDeviceWithSharedAttributes("OrAlarmCntDeviceMid", type, "{\"temperature\":30}"); + + // 2 alarms for deviceHot, 1 for deviceCold, 1 for deviceMid + createAlarm(deviceHot.getId(), "highTemp1", AlarmSeverity.CRITICAL); + createAlarm(deviceHot.getId(), "highTemp2", AlarmSeverity.CRITICAL); + createAlarm(deviceCold.getId(), "lowTemp", AlarmSeverity.WARNING); + createAlarm(deviceMid.getId(), "normalTemp", AlarmSeverity.WARNING); + + List keyFilters = List.of( + numericAttributeKeyFilter("temperature", NumericFilterPredicate.NumericOperation.GREATER, 50), + numericAttributeKeyFilter("temperature", NumericFilterPredicate.NumericOperation.LESS, 10)); + + // OR: deviceHot (2 alarms) + deviceCold (1 alarm) match => 3 alarms total + AlarmCountQuery orQuery = new AlarmCountQuery(deviceTypeFilter(type), keyFilters, ComplexOperation.OR); + await().atMost(TIMEOUT, TimeUnit.SECONDS).untilAsserted(() -> countAlarmsByQueryAndCheck(orQuery, 3)); + + // AND: no device matches both filters => 0 + AlarmCountQuery andQuery = new AlarmCountQuery(deviceTypeFilter(type), keyFilters, ComplexOperation.AND); + countAlarmsByQueryAndCheck(andQuery, 0); + } + + @Test + public void testCountEntitiesWithOrMixedEntityFieldAndAttribute() throws Exception { + // Exercises the entity field predicate relocation to middle-layer WHERE under OR + String type = "orMixedType"; + createDeviceWithSharedAttributes("OrMixedAlpha", type, "{\"temperature\":10}"); + createDeviceWithSharedAttributes("OrMixedBeta", type, "{\"temperature\":60}"); + createDeviceWithSharedAttributes("OrMixedGamma", type, "{\"temperature\":10}"); + + List keyFilters = List.of( + stringKeyFilter(EntityKeyType.ENTITY_FIELD, "name", StringFilterPredicate.StringOperation.CONTAINS, "Alpha"), + numericAttributeKeyFilter("temperature", NumericFilterPredicate.NumericOperation.GREATER, 50)); + + // OR: Alpha matches name contains "Alpha", Beta matches temp>50 => count=2 + EntityCountQuery orQuery = new EntityCountQuery(deviceTypeFilter(type), keyFilters, ComplexOperation.OR); + await().atMost(TIMEOUT, TimeUnit.SECONDS).untilAsserted(() -> countByQueryAndCheck(orQuery, 2)); + + // AND: only Alpha has name "Alpha" AND temp is 10 (not >50) => count=0 + EntityCountQuery andQuery = new EntityCountQuery(deviceTypeFilter(type), keyFilters, ComplexOperation.AND); + countByQueryAndCheck(andQuery, 0); + } + + @Test + public void testCountEntitiesWithOrStringAttributes() throws Exception { + String type = "orStrType"; + createDeviceWithSharedAttributes("OrStrDeviceA", type, "{\"color\":\"red\"}"); + createDeviceWithSharedAttributes("OrStrDeviceB", type, "{\"color\":\"blue\"}"); + createDeviceWithSharedAttributes("OrStrDeviceC", type, "{\"color\":\"green\"}"); + + List keyFilters = List.of( + stringAttributeKeyFilter("color", StringFilterPredicate.StringOperation.EQUAL, "red"), + stringAttributeKeyFilter("color", StringFilterPredicate.StringOperation.EQUAL, "blue")); + + // OR: red and blue match => count=2 + EntityCountQuery orQuery = new EntityCountQuery(deviceTypeFilter(type), keyFilters, ComplexOperation.OR); + await().atMost(TIMEOUT, TimeUnit.SECONDS).untilAsserted(() -> countByQueryAndCheck(orQuery, 2)); + + // AND: no device is both red AND blue => count=0 + EntityCountQuery andQuery = new EntityCountQuery(deviceTypeFilter(type), keyFilters, ComplexOperation.AND); + countByQueryAndCheck(andQuery, 0); + } + + @Test + public void testCountEntitiesWithOrSingleFilter() throws Exception { + String type = "orSingleType"; + createDeviceWithSharedAttributes("OrSingleDeviceA", type, "{\"temperature\":60}"); + createDeviceWithSharedAttributes("OrSingleDeviceB", type, "{\"temperature\":30}"); + + List keyFilters = List.of( + numericAttributeKeyFilter("temperature", NumericFilterPredicate.NumericOperation.GREATER, 50)); + + // Single filter with OR should behave identically to AND + EntityCountQuery orQuery = new EntityCountQuery(deviceTypeFilter(type), keyFilters, ComplexOperation.OR); + await().atMost(TIMEOUT, TimeUnit.SECONDS).untilAsserted(() -> countByQueryAndCheck(orQuery, 1)); + Long orResult = countByQueryAndCheck(orQuery, 1); + + EntityCountQuery andQuery = new EntityCountQuery(deviceTypeFilter(type), keyFilters, ComplexOperation.AND); + Long andResult = countByQueryAndCheck(andQuery, 1); + + Assert.assertEquals(orResult, andResult); + } + + @Test + public void testCountEntitiesWithOrThreeFilters() throws Exception { + String type = "or3fType"; + createDeviceWithSharedAttributes("Or3fDeviceA", type, "{\"temperature\":60,\"humidity\":50,\"pressure\":1000}"); + createDeviceWithSharedAttributes("Or3fDeviceB", type, "{\"temperature\":20,\"humidity\":90,\"pressure\":1000}"); + createDeviceWithSharedAttributes("Or3fDeviceC", type, "{\"temperature\":20,\"humidity\":50,\"pressure\":1050}"); + createDeviceWithSharedAttributes("Or3fDeviceD", type, "{\"temperature\":20,\"humidity\":50,\"pressure\":1000}"); + + List keyFilters = List.of( + numericAttributeKeyFilter("temperature", NumericFilterPredicate.NumericOperation.GREATER, 50), + numericAttributeKeyFilter("humidity", NumericFilterPredicate.NumericOperation.GREATER, 80), + numericAttributeKeyFilter("pressure", NumericFilterPredicate.NumericOperation.GREATER, 1040)); + + // OR: A matches temp>50, B matches hum>80, C matches press>1040, D matches none => 3 + EntityCountQuery orQuery = new EntityCountQuery(deviceTypeFilter(type), keyFilters, ComplexOperation.OR); + await().atMost(TIMEOUT, TimeUnit.SECONDS).untilAsserted(() -> countByQueryAndCheck(orQuery, 3)); + + // AND: no device matches all three => 0 + EntityCountQuery andQuery = new EntityCountQuery(deviceTypeFilter(type), keyFilters, ComplexOperation.AND); + countByQueryAndCheck(andQuery, 0); + } + + @Test + public void testFindEntityDataWithOrPagination() throws Exception { + // 5 devices, 4 match OR filters; explicit temperatures keep the setup easy to read. + String type = "orPageType"; + int[] temperatures = {61, 62, 2, 1, 25}; // devices 1,2: temp>50; 3,4: temp<10; 5: no match + for (int i = 0; i < temperatures.length; i++) { + createDeviceWithSharedAttributes( + String.format("OrPageDevice%02d", i + 1), type, + "{\"temperature\":" + temperatures[i] + "}"); + } + + List keyFilters = List.of( + numericAttributeKeyFilter("temperature", NumericFilterPredicate.NumericOperation.GREATER, 50), + numericAttributeKeyFilter("temperature", NumericFilterPredicate.NumericOperation.LESS, 10)); + + // Page 1: pageSize=2, totalElements=4, data.size()=2 + EntityDataQuery orQuery1 = new EntityDataQuery(deviceTypeFilter(type), pageLinkSortedByName(2, 0, null), + nameEntityField(), null, keyFilters, ComplexOperation.OR); + await().atMost(TIMEOUT, TimeUnit.SECONDS).untilAsserted(() -> findByQueryAndCheck(orQuery1, 4)); + PageData page1 = findByQueryAndCheck(orQuery1, 4); + Assert.assertEquals(2, page1.getData().size()); + Assert.assertTrue(page1.hasNext()); + + // Page 2: remaining 2 of 4 + EntityDataQuery orQuery2 = new EntityDataQuery(deviceTypeFilter(type), pageLinkSortedByName(2, 1, null), + nameEntityField(), null, keyFilters, ComplexOperation.OR); + PageData page2 = findByQueryAndCheck(orQuery2, 4); + Assert.assertEquals(2, page2.getData().size()); + Assert.assertFalse(page2.hasNext()); + + // All names across both pages should be from the 4 matching devices (01..04) + List allNames = new ArrayList<>(extractNames(page1)); + allNames.addAll(extractNames(page2)); + assertThat(allNames).hasSize(4).doesNotContain("OrPageDevice05"); + } + + @Test + public void testCountEntitiesWithOrZeroMatches() throws Exception { + String type = "orZeroType"; + createDeviceWithSharedAttributes("OrZeroDeviceA", type, "{\"temperature\":30}"); + createDeviceWithSharedAttributes("OrZeroDeviceB", type, "{\"temperature\":40}"); + + // Await attribute propagation via a filter that actually matches both devices + EntityCountQuery propagationCheck = new EntityCountQuery(deviceTypeFilter(type), List.of( + numericAttributeKeyFilter("temperature", NumericFilterPredicate.NumericOperation.GREATER, 20)), + ComplexOperation.OR); + await().atMost(TIMEOUT, TimeUnit.SECONDS).untilAsserted(() -> countByQueryAndCheck(propagationCheck, 2)); + + List keyFilters = List.of( + numericAttributeKeyFilter("temperature", NumericFilterPredicate.NumericOperation.GREATER, 50), + numericAttributeKeyFilter("temperature", NumericFilterPredicate.NumericOperation.LESS, 10)); + + // OR with no matches: neither filter matches any device => count=0 + EntityCountQuery orQuery = new EntityCountQuery(deviceTypeFilter(type), keyFilters, ComplexOperation.OR); + countByQueryAndCheck(orQuery, 0); + } + + @Test + public void testOrKeyFiltersOperationRejectedWhenDisabled() throws Exception { + loginTenantAdmin(); + + ReflectionTestUtils.setField(baseEntityService, "keyFiltersOrConditionsEnabled", false); + try { + DeviceTypeFilter filter = deviceTypeFilter("default"); + + // POST a query with OR operation -- should be rejected with 400 + EntityCountQuery orQuery = new EntityCountQuery(filter, Collections.emptyList(), ComplexOperation.OR); + String errorMessage = getErrorMessage( + doPost("/api/entitiesQuery/count", orQuery).andExpect(status().isBadRequest()) + ); + assertThat(errorMessage).contains("OR conditions between key filters are disabled"); + + // POST a query without keyFiltersOperation (null/AND) -- should still succeed + EntityCountQuery andQuery = new EntityCountQuery(filter, Collections.emptyList()); + doPost("/api/entitiesQuery/count", andQuery).andExpect(status().isOk()); + + // POST a query with explicit AND -- should also succeed + EntityCountQuery explicitAndQuery = new EntityCountQuery(filter, Collections.emptyList(), ComplexOperation.AND); + doPost("/api/entitiesQuery/count", explicitAndQuery).andExpect(status().isOk()); + } finally { + ReflectionTestUtils.setField(baseEntityService, "keyFiltersOrConditionsEnabled", true); + } + } + + @Test + public void testFindEntityDataWithOrAndTextSearch() throws Exception { + // 3 devices: 2 match OR filters, but only 1 also matches textSearch at a time + String type = "orTextType"; + createDeviceWithSharedAttributes("OrTextAlpha", type, "{\"temperature\":60}"); + createDeviceWithSharedAttributes("OrTextBeta", type, "{\"temperature\":5}"); + createDeviceWithSharedAttributes("OrTextGamma", type, "{\"temperature\":30}"); + + List keyFilters = List.of( + numericAttributeKeyFilter("temperature", NumericFilterPredicate.NumericOperation.GREATER, 50), + numericAttributeKeyFilter("temperature", NumericFilterPredicate.NumericOperation.LESS, 10)); + + // OR without textSearch: Alpha and Beta match => 2 + EntityDataQuery orQueryNoText = new EntityDataQuery(deviceTypeFilter(type), pageLinkSortedByName(10, 0, null), + nameEntityField(), null, keyFilters, ComplexOperation.OR); + await().atMost(TIMEOUT, TimeUnit.SECONDS).untilAsserted(() -> findByQueryAndCheck(orQueryNoText, 2)); + + // OR with textSearch="Alpha": only Alpha matches both OR filter AND text search + EntityDataQuery orQueryWithText = new EntityDataQuery(deviceTypeFilter(type), pageLinkSortedByName(10, 0, "Alpha"), + nameEntityField(), null, keyFilters, ComplexOperation.OR); + assertThat(extractNames(findByQueryAndCheck(orQueryWithText, 1))).containsExactly("OrTextAlpha"); + + // OR with textSearch="Beta": only Beta matches both OR filter AND text search + EntityDataQuery orQueryBeta = new EntityDataQuery(deviceTypeFilter(type), pageLinkSortedByName(10, 0, "Beta"), + nameEntityField(), null, keyFilters, ComplexOperation.OR); + assertThat(extractNames(findByQueryAndCheck(orQueryBeta, 1))).containsExactly("OrTextBeta"); + + // OR with textSearch="Gamma": Gamma doesn't match any OR filter => 0 + EntityDataQuery orQueryGamma = new EntityDataQuery(deviceTypeFilter(type), pageLinkSortedByName(10, 0, "Gamma"), + nameEntityField(), null, keyFilters, ComplexOperation.OR); + findByQueryAndCheck(orQueryGamma, 0); + } + + @Test + public void testCountEntitiesWithOrTimeSeriesKeyFilters() throws Exception { + String type = "orTsType"; + createDeviceWithTimeseries("OrTsDeviceA", type, "{\"temperature\":60}"); + createDeviceWithTimeseries("OrTsDeviceB", type, "{\"temperature\":5}"); + createDeviceWithTimeseries("OrTsDeviceC", type, "{\"temperature\":30}"); + + List keyFilters = List.of( + numericKeyFilter(EntityKeyType.TIME_SERIES, "temperature", NumericFilterPredicate.NumericOperation.GREATER, 50), + numericKeyFilter(EntityKeyType.TIME_SERIES, "temperature", NumericFilterPredicate.NumericOperation.LESS, 10)); + + // OR: deviceA (60>50) and deviceB (5<10) match => count=2 + EntityCountQuery orQuery = new EntityCountQuery(deviceTypeFilter(type), keyFilters, ComplexOperation.OR); + await().atMost(TIMEOUT, TimeUnit.SECONDS).untilAsserted(() -> countByQueryAndCheck(orQuery, 2)); + + // AND: no device has ts temperature both >50 AND <10 => count=0 + EntityCountQuery andQuery = new EntityCountQuery(deviceTypeFilter(type), keyFilters, ComplexOperation.AND); + countByQueryAndCheck(andQuery, 0); + } + + @Test + public void testCountEntitiesWithOrComplexFilterPredicate() throws Exception { + // Key-level ComplexFilterPredicate combined with query-level OR + String type = "orCplxType"; + createDeviceWithSharedAttributes("OrCplxDeviceA", type, "{\"temperature\":65,\"humidity\":50}"); + createDeviceWithSharedAttributes("OrCplxDeviceB", type, "{\"temperature\":25,\"humidity\":90}"); + createDeviceWithSharedAttributes("OrCplxDeviceC", type, "{\"temperature\":25,\"humidity\":50}"); + + // Key filter 1: temperature > 50 AND temperature < 70 (complex predicate within key filter) — matches A only + NumericFilterPredicate gt50 = new NumericFilterPredicate(); + gt50.setValue(FilterPredicateValue.fromDouble(50)); + gt50.setOperation(NumericFilterPredicate.NumericOperation.GREATER); + NumericFilterPredicate lt70 = new NumericFilterPredicate(); + lt70.setValue(FilterPredicateValue.fromDouble(70)); + lt70.setOperation(NumericFilterPredicate.NumericOperation.LESS); + ComplexFilterPredicate complexTempPred = new ComplexFilterPredicate(); + complexTempPred.setOperation(ComplexOperation.AND); + complexTempPred.setPredicates(List.of(gt50, lt70)); + KeyFilter tempComplexFilter = new KeyFilter(); + tempComplexFilter.setKey(new EntityKey(EntityKeyType.ATTRIBUTE, "temperature")); + tempComplexFilter.setValueType(EntityKeyValueType.NUMERIC); + tempComplexFilter.setPredicate(complexTempPred); + + // Key filter 2: humidity > 80 (simple predicate) — matches B only + List keyFilters = List.of( + tempComplexFilter, + numericAttributeKeyFilter("humidity", NumericFilterPredicate.NumericOperation.GREATER, 80)); + + // Query-level OR: A matches key filter 1, B matches key filter 2 => 2 + EntityCountQuery orQuery = new EntityCountQuery(deviceTypeFilter(type), keyFilters, ComplexOperation.OR); + await().atMost(TIMEOUT, TimeUnit.SECONDS).untilAsserted(() -> countByQueryAndCheck(orQuery, 2)); + + // Query-level AND: no device matches both key filters => 0 + EntityCountQuery andQuery = new EntityCountQuery(deviceTypeFilter(type), keyFilters, ComplexOperation.AND); + countByQueryAndCheck(andQuery, 0); + } + } diff --git a/application/src/test/java/org/thingsboard/server/controller/RuleChainControllerTest.java b/application/src/test/java/org/thingsboard/server/controller/RuleChainControllerTest.java index bcffd9099e..7f613168b0 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/WebsocketApiTest.java b/application/src/test/java/org/thingsboard/server/controller/WebSocketApiTest.java similarity index 85% 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 6ec0f3ad64..2174b7425f 100644 --- a/application/src/test/java/org/thingsboard/server/controller/WebsocketApiTest.java +++ b/application/src/test/java/org/thingsboard/server/controller/WebSocketApiTest.java @@ -76,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; @@ -96,7 +94,8 @@ 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; @@ -136,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)); @@ -152,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]); @@ -170,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)); @@ -184,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]); @@ -194,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); @@ -202,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]); } @@ -529,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); @@ -597,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); @@ -613,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); } @@ -685,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()); @@ -702,34 +701,34 @@ 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); } @@ -779,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(); @@ -804,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); @@ -822,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); } @@ -855,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); @@ -882,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); } @@ -1042,7 +1041,7 @@ public class WebsocketApiTest extends AbstractControllerTest { .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 4dc637725f..970c244fad 100644 --- a/application/src/test/java/org/thingsboard/server/controller/plugin/TbWebSocketHandlerTest.java +++ b/application/src/test/java/org/thingsboard/server/controller/plugin/TbWebSocketHandlerTest.java @@ -36,9 +36,13 @@ import org.thingsboard.server.common.data.id.EntityId; import org.thingsboard.server.common.data.id.TenantId; import org.thingsboard.server.common.data.id.UserId; import org.thingsboard.server.dao.tenant.TbTenantProfileCache; +import org.thingsboard.server.service.security.auth.jwt.JwtAuthenticationProvider; +import org.thingsboard.server.service.security.auth.pat.ApiKeyAuthenticationProvider; import org.thingsboard.server.service.security.model.SecurityUser; import org.thingsboard.server.service.security.model.UserPrincipal; +import org.thingsboard.server.service.ws.WebSocketService; import org.thingsboard.server.service.ws.WebSocketSessionRef; +import org.thingsboard.server.service.ws.WebSocketSessionType; import java.io.IOException; import java.lang.reflect.Method; @@ -262,4 +266,101 @@ class TbWebSocketHandlerTest { return ref; } + private AuthTestFixture createAuthTestFixture() throws IOException { + TbWebSocketHandler handler = spy(new TbWebSocketHandler()); + willDoNothing().given(handler).close(any(), any()); + + ApiKeyAuthenticationProvider apiKeyProvider = mock(ApiKeyAuthenticationProvider.class); + JwtAuthenticationProvider jwtProvider = mock(JwtAuthenticationProvider.class); + WebSocketService wsService = mock(WebSocketService.class); + + ReflectionTestUtils.setField(handler, "apiKeyAuthenticationProvider", apiKeyProvider); + ReflectionTestUtils.setField(handler, "authenticationProvider", jwtProvider); + ReflectionTestUtils.setField(handler, "webSocketService", wsService); + ReflectionTestUtils.setField(handler, "tenantProfileCache", mock(TbTenantProfileCache.class)); + ReflectionTestUtils.setField(handler, "authTimeoutMs", 10000); + ReflectionTestUtils.invokeMethod(handler, "init"); + + WebSocketSessionRef ref = WebSocketSessionRef.builder() + .sessionId(UUID.randomUUID().toString()) + .sessionType(WebSocketSessionType.GENERAL) + .build(); + + NativeWebSocketSession wsSession = mock(NativeWebSocketSession.class); + Session nativeSess = mock(Session.class); + willReturn(nativeSess).given(wsSession).getNativeSession(Session.class); + RemoteEndpoint.Async async = mock(RemoteEndpoint.Async.class); + willReturn(async).given(nativeSess).getAsyncRemote(); + willReturn("test-session-id").given(wsSession).getId(); + + TbWebSocketHandler.SessionMetaData sessionMd = handler.new SessionMetaData(wsSession, ref); + + return new AuthTestFixture(handler, apiKeyProvider, jwtProvider, ref, sessionMd); + } + + @Test + void processMsg_authenticatesWithApiKey() throws Exception { + AuthTestFixture f = createAuthTestFixture(); + + SecurityUser securityUser = mock(SecurityUser.class, Mockito.RETURNS_DEEP_STUBS); + willReturn(securityUser).given(f.apiKeyProvider).authenticate("my-api-key"); + + String msg = "{\"authCmd\":{\"cmdId\":1,\"apiKey\":\"my-api-key\"},\"cmds\":[]}"; + f.handler.processMsg(f.sessionMd, msg); + + verify(f.apiKeyProvider).authenticate("my-api-key"); + verify(f.jwtProvider, never()).authenticate(anyString()); + assertThat(f.ref.getSecurityCtx()).isSameAs(securityUser); + } + + @Test + void processMsg_authenticatesWithJwtToken() throws Exception { + AuthTestFixture f = createAuthTestFixture(); + + SecurityUser securityUser = mock(SecurityUser.class, Mockito.RETURNS_DEEP_STUBS); + willReturn(securityUser).given(f.jwtProvider).authenticate("my-jwt-token"); + + String msg = "{\"authCmd\":{\"cmdId\":1,\"token\":\"my-jwt-token\"},\"cmds\":[]}"; + f.handler.processMsg(f.sessionMd, msg); + + verify(f.jwtProvider).authenticate("my-jwt-token"); + verify(f.apiKeyProvider, never()).authenticate(anyString()); + assertThat(f.ref.getSecurityCtx()).isSameAs(securityUser); + } + + @Test + void processMsg_apiKeyTakesPrecedenceOverToken() throws Exception { + AuthTestFixture f = createAuthTestFixture(); + + SecurityUser securityUser = mock(SecurityUser.class, Mockito.RETURNS_DEEP_STUBS); + willReturn(securityUser).given(f.apiKeyProvider).authenticate("my-api-key"); + + String msg = "{\"authCmd\":{\"cmdId\":1,\"apiKey\":\"my-api-key\",\"token\":\"my-jwt-token\"},\"cmds\":[]}"; + f.handler.processMsg(f.sessionMd, msg); + + verify(f.apiKeyProvider).authenticate("my-api-key"); + verify(f.jwtProvider, never()).authenticate(anyString()); + assertThat(f.ref.getSecurityCtx()).isSameAs(securityUser); + } + + @Test + void extractQueryParam_parsesCorrectly() throws Exception { + TbWebSocketHandler handler = new TbWebSocketHandler(); + Method method = TbWebSocketHandler.class.getDeclaredMethod("extractQueryParam", String.class, String.class); + method.setAccessible(true); + + assertThat(method.invoke(handler, "token=jwt123", "token")).isEqualTo("jwt123"); + assertThat(method.invoke(handler, "token=jwt123&other=abc123", "token")).isEqualTo("jwt123"); + assertThat(method.invoke(handler, "other=value", "token")).isNull(); + assertThat(method.invoke(handler, "tokenExtra=value", "token")).isNull(); + } + + private record AuthTestFixture( + TbWebSocketHandler handler, + ApiKeyAuthenticationProvider apiKeyProvider, + JwtAuthenticationProvider jwtProvider, + WebSocketSessionRef ref, + TbWebSocketHandler.SessionMetaData sessionMd + ) {} + } diff --git a/application/src/test/java/org/thingsboard/server/service/edge/rpc/EdgeGrpcSslTest.java b/application/src/test/java/org/thingsboard/server/service/edge/rpc/EdgeGrpcSslTest.java index 04a1b87833..7614bdd310 100644 --- 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 @@ -32,7 +32,6 @@ 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.controller.AbstractWebTest; import org.thingsboard.server.gen.edge.v1.EdgeRpcServiceGrpc; import java.io.ByteArrayInputStream; @@ -56,7 +55,7 @@ import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.awaitility.Awaitility.await; /** - * Tests for Edge gRPC SSL setup using the production {@link EdgeGrpcService#setupSsl} method. + * Tests for Edge gRPC SSL setup using the production {@link GrpcServer#setupSsl} method. *

* Covers: * 1. Separate cert and key PEM inputs @@ -68,6 +67,8 @@ import static org.awaitility.Awaitility.await; */ class EdgeGrpcSslTest { + private static final int TIMEOUT = 30; + static { if (Security.getProvider(BouncyCastleProvider.PROVIDER_NAME) == null) { Security.addProvider(new BouncyCastleProvider()); @@ -170,13 +171,14 @@ class EdgeGrpcSslTest { Path certOnlyFile = writeTempPem("cert-only", cert); assertThatThrownBy(() -> startServer(certOnlyFile.toString(), "", null)) - .isInstanceOf(IllegalArgumentException.class); + .isInstanceOf(RuntimeException.class) + .hasCauseInstanceOf(IllegalArgumentException.class); } // --- Server startup using production EdgeGrpcService.setupSsl() --- private Server startServer(String certFileResource, String privateKeyResource, String keyPassword) throws Exception { - EdgeGrpcService edgeGrpcService = new EdgeGrpcService(); + GrpcServer edgeGrpcService = new GrpcServer(new EdgeRpcServiceGrpc.EdgeRpcServiceImplBase() {}); ReflectionTestUtils.setField(edgeGrpcService, "certFileResource", certFileResource); ReflectionTestUtils.setField(edgeGrpcService, "privateKeyResource", privateKeyResource); ReflectionTestUtils.setField(edgeGrpcService, "keyPassword", keyPassword != null ? keyPassword : ""); @@ -200,7 +202,7 @@ class EdgeGrpcSslTest { .build(); channel.getState(true); // trigger connection attempt - await().atMost(AbstractWebTest.TIMEOUT, TimeUnit.SECONDS) + await().atMost(TIMEOUT, TimeUnit.SECONDS) .pollInterval(50, TimeUnit.MILLISECONDS) .untilAsserted(() -> { var state = channel.getState(false); diff --git a/application/src/test/java/org/thingsboard/server/service/edge/rpc/KafkaEdgeGrpcSessionTest.java b/application/src/test/java/org/thingsboard/server/service/edge/rpc/session/manager/KafkaBasedEdgeGrpcSessionManagerTest.java similarity index 77% rename from application/src/test/java/org/thingsboard/server/service/edge/rpc/KafkaEdgeGrpcSessionTest.java rename to application/src/test/java/org/thingsboard/server/service/edge/rpc/session/manager/KafkaBasedEdgeGrpcSessionManagerTest.java index 39ba71b927..7ddc2af192 100644 --- a/application/src/test/java/org/thingsboard/server/service/edge/rpc/KafkaEdgeGrpcSessionTest.java +++ b/application/src/test/java/org/thingsboard/server/service/edge/rpc/session/manager/KafkaBasedEdgeGrpcSessionManagerTest.java @@ -13,9 +13,8 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package org.thingsboard.server.service.edge.rpc; +package org.thingsboard.server.service.edge.rpc.session.manager; -import io.grpc.stub.StreamObserver; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; @@ -24,7 +23,6 @@ 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.common.msg.queue.TopicPartitionInfo; -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; @@ -33,6 +31,11 @@ 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 org.thingsboard.server.service.edge.rpc.DownlinkMessageMapper; +import org.thingsboard.server.service.edge.rpc.EdgeEventStorageSettings; +import org.thingsboard.server.service.edge.rpc.EdgeSessionState; +import org.thingsboard.server.service.edge.rpc.session.EdgeSession; +import org.thingsboard.server.service.edge.rpc.session.EdgeSessionsHolder; import java.util.Collections; import java.util.List; @@ -51,40 +54,48 @@ import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.when; -class KafkaEdgeGrpcSessionTest { +class KafkaBasedEdgeGrpcSessionManagerTest { private static final long POLL_INTERVAL_MS = 20L; private EdgeContextComponent ctx; private TbCoreQueueFactory tbCoreQueueFactory; - private KafkaEdgeGrpcSession session; + private EdgeSessionState state; + private KafkaBasedEdgeGrpcSessionManager manager; @BeforeEach void setUp() { ctx = mock(EdgeContextComponent.class); - TopicService topicService = mock(TopicService.class); tbCoreQueueFactory = mock(TbCoreQueueFactory.class); + TopicService topicService = mock(TopicService.class); KafkaAdmin kafkaAdmin = mock(KafkaAdmin.class); - @SuppressWarnings("unchecked") - StreamObserver outputStream = mock(StreamObserver.class); + EdgeSessionsHolder sessions = mock(EdgeSessionsHolder.class); + + manager = new KafkaBasedEdgeGrpcSessionManager(tbCoreQueueFactory, topicService, kafkaAdmin, sessions); - session = new KafkaEdgeGrpcSession(ctx, topicService, tbCoreQueueFactory, kafkaAdmin, outputStream, - (edgeId, s) -> {}, (edge, uuid) -> {}, null, 0, 0); + Edge edge = new Edge(new EdgeId(UUID.randomUUID())); + edge.setTenantId(TenantId.fromUUID(UUID.randomUUID())); + state = new EdgeSessionState(); + state.setEdge(edge); - ReflectionTestUtils.setField(session, "edge", new Edge(new EdgeId(UUID.randomUUID()))); - ReflectionTestUtils.setField(session, "tenantId", TenantId.fromUUID(UUID.randomUUID())); + EdgeSession session = mock(EdgeSession.class); + when(session.getState()).thenReturn(state); + + ReflectionTestUtils.setField(manager, "session", session); + ReflectionTestUtils.setField(manager, "ctx", ctx); + ReflectionTestUtils.setField(manager, "downlinkMessageMapper", mock(DownlinkMessageMapper.class)); } @AfterEach void tearDown() { - if (session != null) { - session.destroy(); + if (manager != null) { + manager.destroy(); } } @Test void readyOnlyWhenConnectedNotSyncingNotHighPriority() { - setReadinessFlags(true, false, false); + setReadiness(true, false, false); assertThat(isReadyToProcessGeneralEvents()) .as("connected, not syncing, no high-priority work -> ready") .isTrue(); @@ -92,7 +103,7 @@ class KafkaEdgeGrpcSessionTest { @Test void notReadyWhenDisconnected() { - setReadinessFlags(false, false, false); + setReadiness(false, false, false); assertThat(isReadyToProcessGeneralEvents()) .as("disconnected -> not ready") .isFalse(); @@ -100,7 +111,7 @@ class KafkaEdgeGrpcSessionTest { @Test void notReadyWhileSyncInProgress() { - setReadinessFlags(true, true, false); + setReadiness(true, true, false); assertThat(isReadyToProcessGeneralEvents()) .as("sync in progress -> not ready (this is the window where events were being dropped)") .isFalse(); @@ -108,18 +119,15 @@ class KafkaEdgeGrpcSessionTest { @Test void notReadyWhileHighPriorityProcessing() { - setReadinessFlags(true, false, true); + setReadiness(true, false, true); assertThat(isReadyToProcessGeneralEvents()) .as("high-priority processing -> not ready") .isFalse(); } @Test - void processEdgeEventsWiresReadinessPredicateIntoConsumerGate() { - // processEdgeEvents() builds the consumer lazily; stub just enough of that path. - EdgeEventStorageSettings storageSettings = new EdgeEventStorageSettings(); - storageSettings.setNoRecordsSleepInterval(1000L); - when(ctx.getEdgeEventStorageSettings()).thenReturn(storageSettings); + void initConsumerWiresReadinessPredicateIntoConsumerGate() { + stubStorageSettings(); @SuppressWarnings("unchecked") TbQueueConsumer> queueConsumer = mock(TbQueueConsumer.class); @@ -127,40 +135,37 @@ class KafkaEdgeGrpcSessionTest { when(queueConsumer.isStopped()).thenReturn(true); when(tbCoreQueueFactory.createEdgeEventMsgConsumer(any(), any())).thenReturn(queueConsumer); - // The consumer is only started when the session is ready. - setReadinessFlags(true, false, false); - session.processEdgeEvents(); + setReadiness(true, false, false); + initConsumer(); - QueueConsumerManager> manager = session.getConsumer(); - assertThat(manager).as("processEdgeEvents must build the consumer when ready").isNotNull(); + QueueConsumerManager> consumer = manager.getConsumer(); + assertThat(consumer).as("consumer must be built").isNotNull(); - BooleanSupplier readinessCheck = (BooleanSupplier) ReflectionTestUtils.getField(manager, "readinessCheck"); + BooleanSupplier readinessCheck = (BooleanSupplier) ReflectionTestUtils.getField(consumer, "readinessCheck"); assertThat(readinessCheck) .as("the edge consumer must be wired with a readinessCheck (the .readinessCheck(...) builder line)") .isNotNull(); - // It must be the live predicate, not a snapshot: flipping the session's state must flip the gate. + // It must be the live predicate, not a snapshot: flipping the session state must flip the gate. assertThat(readinessCheck.getAsBoolean()).as("ready session -> gate open").isTrue(); - setReadinessFlags(true, true, false); + state.tryStartSync(); assertThat(readinessCheck.getAsBoolean()).as("sync starts -> gate closes, consumer pauses polling").isFalse(); } @Test void eventArrivingDuringSyncIsHeldByTheEdgeConsumerUntilSyncCompletes() { - EdgeEventStorageSettings storageSettings = new EdgeEventStorageSettings(); - storageSettings.setNoRecordsSleepInterval(POLL_INTERVAL_MS); - when(ctx.getEdgeEventStorageSettings()).thenReturn(storageSettings); + stubStorageSettings(); RecordingEdgeEventConsumer queueConsumer = new RecordingEdgeEventConsumer(); when(tbCoreQueueFactory.createEdgeEventMsgConsumer(any(), any())).thenReturn(queueConsumer); - // The consumer is launched only while the session is ready - that is how it starts in production. - setReadinessFlags(true, false, false); - session.processEdgeEvents(); + // The consumer is launched only while the session is ready. + setReadiness(true, false, false); + initConsumer(); // Sync starts: the gate closes. Wait until the loop has actually parked on it (poll count stops advancing) // before enqueuing - otherwise we would race an in-flight poll() and the test would be non-deterministic. - setReadinessFlags(true, true, false); + state.tryStartSync(); awaitParkedOnClosedGate(queueConsumer); // An event lands in the edge-event topic during the sync window - exactly the case that used to be dropped. @@ -169,8 +174,7 @@ class KafkaEdgeGrpcSessionTest { int pollsBeforeEvent = queueConsumer.getPollCount(); queueConsumer.enqueue(List.of(event)); - // While sync is in progress the consumer stays parked: it neither polls nor consumes the event, - // so the event survives in the queue instead of being read-and-skipped. + // While sync is in progress the consumer stays parked: it neither polls nor consumes the event. sleepQuietly(POLL_INTERVAL_MS * 5); assertThat(queueConsumer.getPolledEvents()) .as("event must not be polled while sync is in progress (it must stay queued, not be dropped)") @@ -180,23 +184,35 @@ class KafkaEdgeGrpcSessionTest { .isEqualTo(pollsBeforeEvent); // Sync completes: the gate opens and the held event is finally picked up by the consumer. - // (We assert at the poll boundary - the actual drop point - since the downlink-send path that - // processMsgs drives afterwards is not reachable from a unit test.) - setReadinessFlags(true, false, false); + state.finishSync(); await().atMost(5, TimeUnit.SECONDS) .untilAsserted(() -> assertThat(queueConsumer.getPolledEvents()) .as("event held during sync must be picked up once sync completes, not lost") .hasSize(1)); } + private void stubStorageSettings() { + EdgeEventStorageSettings storageSettings = mock(EdgeEventStorageSettings.class); + when(storageSettings.getNoRecordsSleepInterval()).thenReturn(POLL_INTERVAL_MS); + when(ctx.getEdgeEventStorageSettings()).thenReturn(storageSettings); + } + + private void initConsumer() { + ReflectionTestUtils.invokeMethod(manager, "initConsumerAndExecutor", state.getTenantId(), state.getEdgeId(), state); + } + private boolean isReadyToProcessGeneralEvents() { - return Boolean.TRUE.equals(ReflectionTestUtils.invokeMethod(session, "isReadyToProcessGeneralEvents")); + return Boolean.TRUE.equals(ReflectionTestUtils.invokeMethod(manager, "isReadyToProcessGeneralEvents")); } - private void setReadinessFlags(boolean connected, boolean syncInProgress, boolean highPriorityProcessing) { - ReflectionTestUtils.setField(session, "connected", connected); - ReflectionTestUtils.setField(session, "syncInProgress", syncInProgress); - ReflectionTestUtils.setField(session, "isHighPriorityProcessing", highPriorityProcessing); + private void setReadiness(boolean connected, boolean syncInProgress, boolean highPriorityProcessing) { + state.setConnected(connected); + if (syncInProgress) { + state.tryStartSync(); + } else { + state.finishSync(); + } + ReflectionTestUtils.setField(manager, "isHighPriorityProcessing", highPriorityProcessing); } private static void awaitParkedOnClosedGate(RecordingEdgeEventConsumer consumer) { diff --git a/application/src/test/java/org/thingsboard/server/service/iot_hub/InstallReportTest.java b/application/src/test/java/org/thingsboard/server/service/iot_hub/InstallReportTest.java new file mode 100644 index 0000000000..73f968fc01 --- /dev/null +++ b/application/src/test/java/org/thingsboard/server/service/iot_hub/InstallReportTest.java @@ -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. + */ +package org.thingsboard.server.service.iot_hub; + +import org.junit.jupiter.api.Test; + +import java.security.MessageDigest; +import java.nio.charset.StandardCharsets; +import java.util.HexFormat; +import java.util.UUID; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +class InstallReportTest { + + private static String sha256(String s) throws Exception { + MessageDigest d = MessageDigest.getInstance("SHA-256"); + return HexFormat.of().formatHex(d.digest(s.getBytes(StandardCharsets.UTF_8))); + } + + @Test + void buildInstallReport_hashesMatchSaltedFormula() throws Exception { + UUID tenantId = UUID.randomUUID(); + UUID userId = UUID.randomUUID(); + + InstallReport r = DefaultIotHubService.buildInstallReport(tenantId, userId, "4.2.0", "CE"); + + assertEquals(sha256(tenantId.toString() + tenantId), r.tenantHash()); + assertEquals(sha256(tenantId.toString() + userId), r.userHash()); + assertEquals("4.2.0", r.tbVersion()); + assertEquals("CE", r.edition()); + } +} diff --git a/application/src/test/java/org/thingsboard/server/service/queue/TbMsgPackProcessingContextTest.java b/application/src/test/java/org/thingsboard/server/service/queue/TbMsgPackProcessingContextTest.java index 3ea827d330..8d349bb858 100644 --- a/application/src/test/java/org/thingsboard/server/service/queue/TbMsgPackProcessingContextTest.java +++ b/application/src/test/java/org/thingsboard/server/service/queue/TbMsgPackProcessingContextTest.java @@ -15,14 +15,17 @@ */ package org.thingsboard.server.service.queue; -import lombok.extern.slf4j.Slf4j; -import org.junit.After; -import org.junit.Assert; -import org.junit.Test; -import org.junit.runner.RunWith; -import org.mockito.junit.MockitoJUnitRunner; +import com.google.common.util.concurrent.MoreExecutors; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; import org.thingsboard.common.util.ThingsBoardThreadFactory; import org.thingsboard.server.common.data.DataConstants; +import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.common.msg.queue.RuleEngineException; import org.thingsboard.server.gen.transport.TransportProtos; import org.thingsboard.server.queue.common.TbProtoQueueMsg; import org.thingsboard.server.service.queue.processing.TbRuleEngineSubmitStrategy; @@ -35,30 +38,241 @@ import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; import java.util.concurrent.TimeUnit; -import static org.junit.Assert.assertTrue; +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.junit.jupiter.api.Assertions.fail; import static org.mockito.ArgumentMatchers.any; +import static org.mockito.BDDMockito.then; +import static org.mockito.Mockito.lenient; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; -@Slf4j -@RunWith(MockitoJUnitRunner.class) -public class TbMsgPackProcessingContextTest { +@ExtendWith(MockitoExtension.class) +class TbMsgPackProcessingContextTest { + + TenantId tenantId = TenantId.fromUUID(UUID.randomUUID()); + + @Mock + TbRuleEngineSubmitStrategy submitStrategy; + @Mock + TbProtoQueueMsg mockMsg; + + ConcurrentMap> pendingMap; - public static final int TIMEOUT = 10; ExecutorService executorService; - @After - public void tearDown() { + @BeforeEach + void setup() { + pendingMap = new ConcurrentHashMap<>(); + lenient().when(submitStrategy.getPendingMap()).thenReturn(pendingMap); + } + + @AfterEach + void tearDown() { if (executorService != null) { - executorService.shutdownNow(); + MoreExecutors.shutdownAndAwaitTermination(executorService, 5, TimeUnit.SECONDS); } } @Test - public void testHighConcurrencyCase() throws InterruptedException { - //log.warn("preparing the test..."); + void testAwait_shouldReturnTrue_whenOnSuccessIsCalledBeforeTimeout() throws InterruptedException { + // GIVEN - a context with one pending message + executorService = Executors.newSingleThreadExecutor(); + + UUID msgId = UUID.randomUUID(); + pendingMap.put(msgId, mockMsg); + var context = new TbMsgPackProcessingContext("test-queue", submitStrategy, false); + + // WHEN - onSuccess() is called in another thread before timeout + executorService.submit(() -> { + try { + Thread.sleep(100); + context.onSuccess(msgId); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + }); + + // THEN - await() should return true (successful completion) + boolean result = context.await(5000, TimeUnit.MILLISECONDS); + assertThat(result).as("await() should return true when latch is counted down before timeout").isTrue(); + + // Verify the message was moved to success map + assertThat(context.getSuccessMap()).containsKey(msgId); + assertThat(context.getPendingMap()).isEmpty(); + assertThat(context.getExceptionsMap()).isEmpty(); + + + // Verify submit strategy was notified about successful message processing + then(submitStrategy).should().onSuccess(msgId); + } + + @Test + void testAwait_shouldReturnTrue_whenOnFailureIsCalledBeforeTimeout() throws InterruptedException { + // GIVEN - a context with one pending message + executorService = Executors.newSingleThreadExecutor(); + + UUID msgId = UUID.randomUUID(); + pendingMap.put(msgId, mockMsg); + var context = new TbMsgPackProcessingContext("test-queue", submitStrategy, false); + + var exception = new RuleEngineException("Test exception"); + + // WHEN - onFailure() is called in another thread before timeout + executorService.submit(() -> { + try { + Thread.sleep(100); + context.onFailure(tenantId, msgId, exception); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + }); + + // THEN - await() should return true (successful completion, even if message processing failed) + boolean result = context.await(5000, TimeUnit.MILLISECONDS); + assertThat(result).as("await() should return true when latch is counted down before timeout").isTrue(); + + // Verify the exception was added to exceptions map + assertThat(context.getSuccessMap()).isEmpty(); + assertThat(context.getPendingMap()).isEmpty(); + assertThat(context.getExceptionsMap()).containsEntry(tenantId, exception); + } + + @Test + void testAwait_shouldReturnFalse_whenTimeoutOccurs() throws InterruptedException { + // GIVEN - a context with one pending message and no processing + UUID msgId = UUID.randomUUID(); + pendingMap.put(msgId, mockMsg); + var context = new TbMsgPackProcessingContext("test-queue", submitStrategy, false); + + // WHEN - await() is called with short timeout and no message processing happens + long startTime = System.nanoTime(); + boolean result = context.await(100, TimeUnit.MILLISECONDS); + long elapsedTime = System.nanoTime() - startTime; + + // THEN - await() should return false (timeout occurred) + assertThat(result).as("await() should return false when timeout occurs").isFalse(); + assertThat(elapsedTime).as("await() should wait for at least the timeout duration").isGreaterThanOrEqualTo(100L); + + // Message should still be in pending map + assertThat(context.getSuccessMap()).isEmpty(); + assertThat(context.getPendingMap()).containsKey(msgId); + assertThat(context.getExceptionsMap()).isEmpty(); + } + + @Test + void testAwait_shouldHandleMultiplePendingMessages() throws InterruptedException { + // GIVEN - a context with multiple pending messages + executorService = Executors.newSingleThreadExecutor(); + + UUID msgId1 = UUID.randomUUID(); + UUID msgId2 = UUID.randomUUID(); + UUID msgId3 = UUID.randomUUID(); + + pendingMap.put(msgId1, mockMsg); + pendingMap.put(msgId2, mockMsg); + pendingMap.put(msgId3, mockMsg); + + var context = new TbMsgPackProcessingContext("test-queue", submitStrategy, false); + + // WHEN - messages are processed one by one + executorService.submit(() -> { + try { + Thread.sleep(50); + context.onSuccess(msgId1); + Thread.sleep(50); + context.onSuccess(msgId2); + Thread.sleep(50); + context.onSuccess(msgId3); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + }); + + // THEN - await() should return true only after all messages are processed + boolean result = context.await(5000, TimeUnit.MILLISECONDS); + assertThat(result).as("await() should return true after all messages are processed").isTrue(); + + // All messages should be in success map + assertThat(context.getSuccessMap()).containsKeys(msgId1, msgId2, msgId3); + assertThat(context.getPendingMap()).isEmpty(); + assertThat(context.getExceptionsMap()).isEmpty(); + } + + @Test + void testAwait_shouldNotCountDownPrematurely_withMultipleMessages() throws InterruptedException { + // GIVEN - a context with multiple pending messages + executorService = Executors.newSingleThreadExecutor(); + + UUID msgId1 = UUID.randomUUID(); + UUID msgId2 = UUID.randomUUID(); + + pendingMap.put(msgId1, mockMsg); + pendingMap.put(msgId2, mockMsg); + + var context = new TbMsgPackProcessingContext("test-queue", submitStrategy, false); + + // WHEN - only one message is processed + executorService.submit(() -> { + try { + Thread.sleep(100); + context.onSuccess(msgId1); + // msgId2 still in processing + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + }); + + // THEN: await should timeout because not all messages were processed + boolean result = context.await(2000, TimeUnit.MILLISECONDS); + assertThat(result).as("await() should timeout when not all messages are processed").isFalse(); + + // One message in success, one still pending + assertThat(context.getSuccessMap()).containsOnlyKeys(msgId1); + assertThat(context.getPendingMap()).containsOnlyKeys(msgId2); + assertThat(context.getExceptionsMap()).isEmpty(); + } + + @Test + void testAwait_shouldHandleMixedSuccessAndFailure() throws InterruptedException { + // GIVEN - multiple messages + executorService = Executors.newSingleThreadExecutor(); + + UUID msgId1 = UUID.randomUUID(); + UUID msgId2 = UUID.randomUUID(); + + pendingMap.put(msgId1, mockMsg); + pendingMap.put(msgId2, mockMsg); + + var context = new TbMsgPackProcessingContext("test-queue", submitStrategy, false); + + var exception = new RuleEngineException("Test exception"); + + // WHEN - one succeeds, one fails + executorService.submit(() -> { + try { + Thread.sleep(50); + context.onSuccess(msgId1); + Thread.sleep(50); + context.onFailure(tenantId, msgId2, exception); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + }); + + // THEN - await() should complete successfully + boolean result = context.await(5000, TimeUnit.MILLISECONDS); + assertThat(result).as("await() should return true when all messages are processed").isTrue(); + + assertThat(context.getSuccessMap()).containsOnlyKeys(msgId1); + assertThat(context.getPendingMap()).isEmpty(); + assertThat(context.getExceptionsMap()).containsEntry(tenantId, exception); + } + + @Test + void testHighConcurrencyCase() throws InterruptedException { int msgCount = 1000; int parallelCount = 5; executorService = Executors.newFixedThreadPool(parallelCount, ThingsBoardThreadFactory.forName(getClass().getSimpleName() + "-test-scope")); @@ -76,28 +290,24 @@ public class TbMsgPackProcessingContextTest { final CountDownLatch startLatch = new CountDownLatch(1); final CountDownLatch finishLatch = new CountDownLatch(parallelCount); for (int i = 0; i < parallelCount; i++) { - //final String taskName = "" + uuid + " " + i; executorService.submit(() -> { - //log.warn("ready {}", taskName); readyLatch.countDown(); try { startLatch.await(); } catch (InterruptedException e) { - Assert.fail("failed to await"); + fail("failed to await"); } - //log.warn("go {}", taskName); - context.onSuccess(uuid); - finishLatch.countDown(); }); } - assertTrue(readyLatch.await(TIMEOUT, TimeUnit.SECONDS)); + assertTrue(readyLatch.await(10, TimeUnit.SECONDS)); Thread.yield(); startLatch.countDown(); //run all-at-once submitted tasks - assertTrue(finishLatch.await(TIMEOUT, TimeUnit.SECONDS)); + assertTrue(finishLatch.await(10, TimeUnit.SECONDS)); } - assertTrue(context.await(TIMEOUT, TimeUnit.SECONDS)); + assertTrue(context.await(10, TimeUnit.SECONDS)); verify(strategyMock, times(msgCount)).onSuccess(any(UUID.class)); } + } diff --git a/application/src/test/java/org/thingsboard/server/service/queue/ruleengine/RuleEngineConsumerLoopTest.java b/application/src/test/java/org/thingsboard/server/service/queue/ruleengine/RuleEngineConsumerLoopTest.java new file mode 100644 index 0000000000..20ea6fa881 --- /dev/null +++ b/application/src/test/java/org/thingsboard/server/service/queue/ruleengine/RuleEngineConsumerLoopTest.java @@ -0,0 +1,281 @@ +/** + * Copyright © 2016-2026 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.service.queue.ruleengine; + +import com.google.common.util.concurrent.MoreExecutors; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InOrder; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.thingsboard.common.util.ThingsBoardExecutors; +import org.thingsboard.common.util.ThingsBoardThreadFactory; +import org.thingsboard.server.actors.ActorSystemContext; +import org.thingsboard.server.common.data.id.DeviceId; +import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.common.data.msg.TbMsgType; +import org.thingsboard.server.common.data.queue.ProcessingStrategy; +import org.thingsboard.server.common.data.queue.ProcessingStrategyType; +import org.thingsboard.server.common.data.queue.Queue; +import org.thingsboard.server.common.data.queue.SubmitStrategy; +import org.thingsboard.server.common.data.queue.SubmitStrategyType; +import org.thingsboard.server.common.msg.TbMsg; +import org.thingsboard.server.common.msg.TbMsgMetaData; +import org.thingsboard.server.common.msg.queue.ServiceType; +import org.thingsboard.server.common.msg.queue.TopicPartitionInfo; +import org.thingsboard.server.common.stats.StatsFactory; +import org.thingsboard.server.gen.transport.TransportProtos; +import org.thingsboard.server.queue.TbQueueAdmin; +import org.thingsboard.server.queue.TbQueueConsumer; +import org.thingsboard.server.queue.TbQueueMsg; +import org.thingsboard.server.queue.common.DefaultTbQueueMsgHeaders; +import org.thingsboard.server.queue.common.TbProtoQueueMsg; +import org.thingsboard.server.queue.discovery.PartitionService; +import org.thingsboard.server.queue.discovery.QueueKey; +import org.thingsboard.server.queue.discovery.TbServiceInfoProvider; +import org.thingsboard.server.queue.memory.DefaultInMemoryStorage; +import org.thingsboard.server.queue.memory.InMemoryStorage; +import org.thingsboard.server.queue.memory.InMemoryTbQueueConsumer; +import org.thingsboard.server.queue.provider.TbQueueProducerProvider; +import org.thingsboard.server.queue.provider.TbRuleEngineQueueFactory; +import org.thingsboard.server.service.queue.TbMsgPackProcessingContext; +import org.thingsboard.server.service.queue.TbMsgPackProcessingContextFactory; +import org.thingsboard.server.service.queue.processing.TbRuleEngineProcessingStrategyFactory; +import org.thingsboard.server.service.queue.processing.TbRuleEngineSubmitStrategy; +import org.thingsboard.server.service.queue.processing.TbRuleEngineSubmitStrategyFactory; +import org.thingsboard.server.service.stats.RuleEngineStatisticsService; + +import java.time.Duration; +import java.util.List; +import java.util.Set; +import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.awaitility.Awaitility.await; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.ArgumentMatchers.isNull; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.doAnswer; +import static org.mockito.Mockito.inOrder; +import static org.mockito.Mockito.spy; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class RuleEngineConsumerLoopTest { + + TenantId tenantId = TenantId.fromUUID(UUID.randomUUID()); + DeviceId deviceId = new DeviceId(UUID.randomUUID()); + + InMemoryStorage storage; + + @Mock + ActorSystemContext actorContext; + @Mock + StatsFactory statsFactory; + @Mock + TbRuleEngineQueueFactory queueFactory; + @Mock + RuleEngineStatisticsService statisticsService; + @Mock + TbServiceInfoProvider serviceInfoProvider; + @Mock + PartitionService partitionService; + @Mock + TbQueueProducerProvider producerProvider; + @Mock + TbQueueAdmin queueAdmin; + @Mock + TbMsgPackProcessingContextFactory packProcessingContextFactory; + @Mock + TbMsgPackProcessingContext packCtx; + + Queue mainQueue; + + TbQueueConsumer> consumer; + + TbRuleEngineConsumerContext ruleEngineConsumerContext; + TbRuleEngineQueueConsumerManager consumerManager; + + ExecutorService consumersExecutor; + ScheduledExecutorService scheduler; + ExecutorService mgmtExecutor; + + @BeforeEach + void setup() throws InterruptedException { + consumersExecutor = Executors.newCachedThreadPool(ThingsBoardThreadFactory.forName("tb-rule-engine-consumer")); + scheduler = ThingsBoardExecutors.newSingleThreadScheduledExecutor("tb-rule-engine-consumer-scheduler"); + mgmtExecutor = ThingsBoardExecutors.newWorkStealingPool(1, "tb-rule-engine-mgmt"); + + mainQueue = new Queue(); + mainQueue.setTenantId(TenantId.SYS_TENANT_ID); + mainQueue.setName("Main"); + mainQueue.setTopic("tb_rule_engine.main"); + mainQueue.setPollInterval(25); + mainQueue.setPartitions(1); + mainQueue.setConsumerPerPartition(false); + mainQueue.setPackProcessingTimeout(2000L); + + var submitStrategy = new SubmitStrategy(); + submitStrategy.setType(SubmitStrategyType.BURST); + submitStrategy.setBatchSize(1000); + mainQueue.setSubmitStrategy(submitStrategy); + + var processingStrategy = new ProcessingStrategy(); + processingStrategy.setType(ProcessingStrategyType.SKIP_ALL_FAILURES); + processingStrategy.setRetries(3); + processingStrategy.setFailurePercentage(0.0); + processingStrategy.setPauseBetweenRetries(3); + processingStrategy.setMaxPauseBetweenRetries(3); + mainQueue.setProcessingStrategy(processingStrategy); + + storage = new DefaultInMemoryStorage(); + + consumer = spy(new InMemoryTbQueueConsumer<>(storage, mainQueue.getTopic())); + given(queueFactory.createToRuleEngineMsgConsumer(eq(mainQueue), isNull())).willReturn(consumer); + + ruleEngineConsumerContext = new TbRuleEngineConsumerContext( + actorContext, statsFactory, new TbRuleEngineSubmitStrategyFactory(), new TbRuleEngineProcessingStrategyFactory(), + queueFactory, statisticsService, serviceInfoProvider, partitionService, producerProvider, queueAdmin + ); + ruleEngineConsumerContext.setPollDuration(25); + ruleEngineConsumerContext.setPackProcessingTimeout(2000); + ruleEngineConsumerContext.setStatsEnabled(false); // true by default + ruleEngineConsumerContext.setPrometheusStatsEnabled(false); + ruleEngineConsumerContext.setTopicDeletionDelayInSec(15); + ruleEngineConsumerContext.setMgmtThreadPoolSize(12); + + // Tell the (mock) context factory to return (mock) message pack context + given(packProcessingContextFactory.create( + eq(mainQueue.getName()), + any(TbRuleEngineSubmitStrategy.class), + eq(false) + )).willAnswer(invocation -> { + TbRuleEngineSubmitStrategy realStrategy = invocation.getArgument(1); + when(packCtx.getPendingMap()).thenAnswer(i -> realStrategy.getPendingMap()); + when(packCtx.getFailedMap()).thenReturn(new ConcurrentHashMap<>()); + return packCtx; + }); + + // Tell the (mock) context's await() to return 'false' (always timeout) immediately + given(packCtx.await(anyLong(), any(TimeUnit.class))).willReturn(false); + + consumerManager = TbRuleEngineQueueConsumerManager.create() + .ctx(ruleEngineConsumerContext) + .queueKey(new QueueKey(ServiceType.TB_RULE_ENGINE, mainQueue)) + .consumerExecutor(consumersExecutor) + .scheduler(scheduler) + .taskExecutor(mgmtExecutor) + .packProcessingContextFactory(packProcessingContextFactory) + .build(); + } + + @AfterEach + void destroy() { + MoreExecutors.shutdownAndAwaitTermination(scheduler, Duration.ofSeconds(30)); + MoreExecutors.shutdownAndAwaitTermination(mgmtExecutor, Duration.ofSeconds(30)); + MoreExecutors.shutdownAndAwaitTermination(consumersExecutor, Duration.ofSeconds(30)); + } + + @Test + void consumerLoopTest_verifyOperationsOrder() throws InterruptedException { + // Create partition + var partition = TopicPartitionInfo.builder() + .tenantId(TenantId.SYS_TENANT_ID) + .topic(mainQueue.getTopic()) + .partition(0) + .myPartition(true) + .useInternalPartition(false) + .build(); + + // Put 10k messages to the queue + for (int i = 0; i < 10_000; i++) { + var tbMsg = TbMsg.newMsg() + .type(TbMsgType.POST_TELEMETRY_REQUEST) + .originator(deviceId) + .data("{\"temperature\":123}") + .metaData(TbMsgMetaData.EMPTY) + .build(); + + var toRuleEngineMsg = TransportProtos.ToRuleEngineMsg.newBuilder() + .setTenantIdLSB(tenantId.getId().getLeastSignificantBits()) + .setTenantIdMSB(tenantId.getId().getMostSignificantBits()) + .setTbMsgProto(TbMsg.toProto(tbMsg)) + .addAllRelationTypes(Set.of("Success")) + .build(); + + storage.put(partition.getFullTopicName(), new TbProtoQueueMsg<>(UUID.randomUUID(), toRuleEngineMsg, new DefaultTbQueueMsgHeaders())); + } + + // Count how many polls were made + var totalPolls = new AtomicInteger(0); + var emptyPolls = new AtomicInteger(0); + doAnswer(invocation -> { + totalPolls.incrementAndGet(); + @SuppressWarnings("unchecked") + var messages = (List) invocation.callRealMethod(); + if (messages.isEmpty()) { + emptyPolls.incrementAndGet(); + } + return messages; + }).when(consumer).poll(mainQueue.getPollInterval()); + + // Count how many commits were made + var totalCommits = new AtomicInteger(0); + doAnswer(invocation -> { + totalCommits.incrementAndGet(); + return invocation.callRealMethod(); + }).when(consumer).commit(); + + // Initialize consumer + consumerManager.init(mainQueue); + + // Assign partition to the consumer + consumerManager.update(Set.of(partition)); + + // Give some time for the consumer to get all messages + await().atMost(Duration.ofSeconds(10L)).until(() -> storage.getLagTotal() == 0); + + // Stop consumer + consumerManager.stop(); + consumerManager.awaitStop(); + + // Determine number of non-empty consumer iterations made, since polling does not stop immediately after consuming all messages and may do a few empty polls + int nonEmptyPolls = totalPolls.get() - emptyPolls.get(); + + // Verify that there is 10 polls and 10 matching commits + // Each poll consumes 1k messages and queue has 10k total, so that means 10k total msgs / 1k msgs per poll = 10 polls + assertThat(nonEmptyPolls).isEqualTo(10).isEqualTo(totalCommits.get()); + + // Verify that poll-await-commit cycle happened in order with correct await timeout + InOrder inOrder = inOrder(consumer, packCtx); + for (int i = 0; i < nonEmptyPolls; i++) { + inOrder.verify(consumer).poll(mainQueue.getPollInterval()); + inOrder.verify(packCtx).await(mainQueue.getPackProcessingTimeout(), TimeUnit.MILLISECONDS); + inOrder.verify(consumer).commit(); + } + } + +} diff --git a/application/src/test/java/org/thingsboard/server/service/queue/ruleengine/TbRuleEngineQueueConsumerManagerTest.java b/application/src/test/java/org/thingsboard/server/service/queue/ruleengine/TbRuleEngineQueueConsumerManagerTest.java index 228b987cf0..e50698200b 100644 --- a/application/src/test/java/org/thingsboard/server/service/queue/ruleengine/TbRuleEngineQueueConsumerManagerTest.java +++ b/application/src/test/java/org/thingsboard/server/service/queue/ruleengine/TbRuleEngineQueueConsumerManagerTest.java @@ -59,6 +59,7 @@ import org.thingsboard.server.queue.provider.KafkaMonolithQueueFactory; import org.thingsboard.server.queue.provider.KafkaTbRuleEngineQueueFactory; import org.thingsboard.server.queue.provider.TbQueueProducerProvider; import org.thingsboard.server.queue.provider.TbRuleEngineQueueFactory; +import org.thingsboard.server.service.queue.TbMsgPackProcessingContextFactory; import org.thingsboard.server.service.queue.processing.TbRuleEngineProcessingStrategyFactory; import org.thingsboard.server.service.queue.processing.TbRuleEngineSubmitStrategyFactory; import org.thingsboard.server.service.stats.RuleEngineStatisticsService; @@ -194,6 +195,7 @@ public class TbRuleEngineQueueConsumerManagerTest { .consumerExecutor(consumersExecutor) .scheduler(scheduler) .taskExecutor(mgmtExecutor) + .packProcessingContextFactory(new TbMsgPackProcessingContextFactory.DefaultTbMsgPackProcessingContextFactory()) .build(); } diff --git a/application/src/test/java/org/thingsboard/server/service/queue/ruleengine/TbRuleEngineStrategyTest.java b/application/src/test/java/org/thingsboard/server/service/queue/ruleengine/TbRuleEngineStrategyTest.java index 3b33d91348..ca47af7b19 100644 --- a/application/src/test/java/org/thingsboard/server/service/queue/ruleengine/TbRuleEngineStrategyTest.java +++ b/application/src/test/java/org/thingsboard/server/service/queue/ruleengine/TbRuleEngineStrategyTest.java @@ -45,6 +45,7 @@ import org.thingsboard.server.queue.TbQueueConsumer; import org.thingsboard.server.queue.common.TbProtoQueueMsg; import org.thingsboard.server.queue.common.consumer.TbQueueConsumerTask.ConsumerKey; import org.thingsboard.server.queue.discovery.QueueKey; +import org.thingsboard.server.service.queue.TbMsgPackProcessingContextFactory; import org.thingsboard.server.service.queue.processing.TbRuleEngineProcessingStrategyFactory; import org.thingsboard.server.service.queue.processing.TbRuleEngineSubmitStrategyFactory; @@ -196,6 +197,7 @@ public class TbRuleEngineStrategyTest { var consumerManager = TbRuleEngineQueueConsumerManager.create() .ctx(ruleEngineConsumerContext) .queueKey(queueKey) + .packProcessingContextFactory(new TbMsgPackProcessingContextFactory.DefaultTbMsgPackProcessingContextFactory()) .build(); consumerManager.init(queue); diff --git a/application/src/test/java/org/thingsboard/server/service/script/TbelInvokeDocsIoTest.java b/application/src/test/java/org/thingsboard/server/service/script/TbelInvokeDocsIoTest.java index 9a3421b219..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 @@ -2356,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); @@ -2388,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); @@ -2420,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); 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/transport/lwm2m/client/SimpleLwM2MDevice.java b/application/src/test/java/org/thingsboard/server/transport/lwm2m/client/SimpleLwM2MDevice.java index 191bc97579..7d4c4b75e5 100644 --- a/application/src/test/java/org/thingsboard/server/transport/lwm2m/client/SimpleLwM2MDevice.java +++ b/application/src/test/java/org/thingsboard/server/transport/lwm2m/client/SimpleLwM2MDevice.java @@ -160,7 +160,13 @@ public class SimpleLwM2MDevice extends BaseInstanceEnabler implements Destroyabl if (!arguments.isEmpty()) withArguments = " with arguments " + arguments; log.info("Execute on Device resource /{}/{}/{} {}", getModel().id, getId(), resourceId, withArguments); - return ExecuteResponse.success(); + switch (resourceId) { + case 4: + case 5: + return ExecuteResponse.success(); + default: + return super.execute(identity, resourceId, arguments); + } } @Override diff --git a/application/src/test/java/org/thingsboard/server/transport/lwm2m/rpc/sql/RpcLwm2mIntegrationExecuteTest.java b/application/src/test/java/org/thingsboard/server/transport/lwm2m/rpc/sql/RpcLwm2mIntegrationExecuteTest.java index f5b4538c1c..92e25a793e 100644 --- a/application/src/test/java/org/thingsboard/server/transport/lwm2m/rpc/sql/RpcLwm2mIntegrationExecuteTest.java +++ b/application/src/test/java/org/thingsboard/server/transport/lwm2m/rpc/sql/RpcLwm2mIntegrationExecuteTest.java @@ -29,6 +29,7 @@ import static org.thingsboard.server.transport.lwm2m.Lwm2mTestHelper.OBJECT_INST import static org.thingsboard.server.transport.lwm2m.Lwm2mTestHelper.RESOURCE_ID_2; import static org.thingsboard.server.transport.lwm2m.Lwm2mTestHelper.RESOURCE_ID_3; import static org.thingsboard.server.transport.lwm2m.Lwm2mTestHelper.RESOURCE_ID_4; +import static org.thingsboard.server.transport.lwm2m.Lwm2mTestHelper.RESOURCE_ID_5; import static org.thingsboard.server.transport.lwm2m.Lwm2mTestHelper.RESOURCE_ID_8; import static org.thingsboard.server.transport.lwm2m.Lwm2mTestHelper.RESOURCE_ID_9; @@ -90,26 +91,101 @@ public class RpcLwm2mIntegrationExecuteTest extends AbstractRpcLwM2MIntegrationT /** - * execute_resource_with_parameters (execute reboot after 60 seconds on device) - * Execute {"id":"3/0/4","value":60} + * execute_resource_with_parameters (execute reboot if digit = 5 on device) + * Execute {"id":"3/0/4","value":5} * {"result":"CHANGED"} */ @Test - public void testExecuteResourceWithParametersById_Result_CHANGED() throws Exception { + public void testExecuteResourceWithParametersSingleDigitValueById_Result_Ok() throws Exception { String expectedPath = objectInstanceIdVer_3 + "/" + RESOURCE_ID_4; - Object expectedValue = 60; + Object expectedValue = 5; String actualResult = sendRPCExecuteWithValueById(expectedPath, expectedValue); ObjectNode rpcActualResult = JacksonUtil.fromString(actualResult, ObjectNode.class); assertEquals(ResponseCode.CHANGED.getName(), rpcActualResult.get("result").asText()); } + /** + * execute_resource_with_parameters (execute Factory Reset if digit = 2 -> after 60 seconds on device) + * Execute {"id":"3/0/5","value":"2='60'"} + + */ + @Test + public void testExecuteResourceWithParametersArgumentIdAndValueById_Result_Ok() throws Exception { + String expectedPath = objectInstanceIdVer_3 + "/" + RESOURCE_ID_5; + Object expectedValue = "2='60'"; + String actualResult = sendRPCExecuteWithValueById(expectedPath, expectedValue); + ObjectNode rpcActualResult = JacksonUtil.fromString(actualResult, ObjectNode.class); + assertEquals(ResponseCode.CHANGED.getName(), rpcActualResult.get("result").asText()); + } + + /** + * execute_resource_with_parameters (execute Factory Reset with two arguments: + * digit 2 without a value and digit 0 with the link value on device) + * Execute {"id":"3/0/5","value":"2,0='https://thingsboard.io/docs/reference/lwm2m-api/'"} + */ + @Test + public void testExecuteResourceWithParametersMultipleArgumentsIncludingLinkById_Result_Ok() throws Exception { + String expectedPath = objectInstanceIdVer_3 + "/" + RESOURCE_ID_5; + Object expectedValue = "2,0='https://thingsboard.io/docs/reference/lwm2m-api/'"; + String actualResult = sendRPCExecuteWithValueById(expectedPath, expectedValue); + ObjectNode rpcActualResult = JacksonUtil.fromString(actualResult, ObjectNode.class); + assertEquals(ResponseCode.CHANGED.getName(), rpcActualResult.get("result").asText()); + } + + /** + * execute_resource_with_parameters (execute Factory Reset with multiple arguments without values) + * According to the OMA LwM2M execute arguments format, this represents ten arguments (digits 0-9), all without values. + * Execute {"id":"3/0/5","value":"0,1,2,3,4,5,6,7,8,9"} + */ + @Test + public void testExecuteResourceWithParametersMultipleArgumentsById_Result_Ok() throws Exception { + String expectedPath = objectInstanceIdVer_3 + "/" + RESOURCE_ID_5; + Object expectedValue = "0,1,2,3,4,5,6,7,8,9"; + String actualResult = sendRPCExecuteWithValueById(expectedPath, expectedValue); + ObjectNode rpcActualResult = JacksonUtil.fromString(actualResult, ObjectNode.class); + assertEquals(ResponseCode.CHANGED.getName(), rpcActualResult.get("result").asText()); + } + + + /** + * execute_resource_with_parameters (execute Factory Reset after 60 seconds on device) + * Execute {"id":"3/0/5","value":"'60'"} + */ + @Test + public void testExecuteResourceWithParametersSingleDigitValueInvalidById_Result_BAD_REQUEST_Error_IntegerBetween_0_And_9_Expected() throws Exception { + String expectedPath = objectInstanceIdVer_3 + "/" + RESOURCE_ID_5; + Object expectedValue = "'60'"; + String actualResult = sendRPCExecuteWithValueById(expectedPath, expectedValue); + ObjectNode rpcActualResult = JacksonUtil.fromString(actualResult, ObjectNode.class); + assertEquals(ResponseCode.BAD_REQUEST.getName(), rpcActualResult.get("result").asText()); + String expected = "Unable to parse Arguments [" + expectedValue + "] : Invalid digit ['] (an integer between 0 and 9 is expected)"; + String actual = rpcActualResult.get("error").asText(); + assertTrue(actual.contains(expected)); + } + + /** + * execute_resource_with_parameters (execute Bad with Unable to parse Arguments) + * Execute {"id":"3/0/5","value":"0,1,2,3,4,5,6,7,8,9,60"} + */ + @Test + public void testExecuteResourceWithParametersMultipleArgumentsById_Result_BAD_REQUEST_Error_UnableParseArguments() throws Exception { + String expectedPath = objectInstanceIdVer_3 + "/" + RESOURCE_ID_5; + Object expectedValue = "0,1,2,3,4,5,6,7,8,9,60"; + String actualResult = sendRPCExecuteWithValueById(expectedPath, expectedValue);; + ObjectNode rpcActualResult = JacksonUtil.fromString(actualResult, ObjectNode.class); + assertEquals(ResponseCode.BAD_REQUEST.getName(), rpcActualResult.get("result").asText()); + String expected = "Unable to parse Arguments [" + expectedValue + "] : [,] separator expected at index 21 after [0,1,2,3,4,5,6,7,8,9,6]"; + String actual = rpcActualResult.get("error").asText(); + assertTrue(actual.contains(expected)); + } + /** * Bootstrap-Request Trigger * Execute {"id":"1/0/9"} * {"result":"BAD_REQUEST","error":"probably no bootstrap server configured"} */ @Test - public void testExecuteBootstrapRequestTriggerById_Result_BAD_REQUEST_Error_NoBootstrapServerConfigured() throws Exception { + public void testExecuteBootstrapRequestTriggerById_Result_BAD_REQUEST_Error_NoBootstrapServer() throws Exception { String expectedPath = objectInstanceIdVer_1 + "/" + RESOURCE_ID_9; String actualResult = sendRPCExecuteById(expectedPath); ObjectNode rpcActualResult = JacksonUtil.fromString(actualResult, ObjectNode.class); @@ -125,7 +201,7 @@ public class RpcLwm2mIntegrationExecuteTest extends AbstractRpcLwM2MIntegrationT * {"result":"BAD_REQUEST","error":"Resource with /5_1.0/0/3 is not executable."} */ @Test - public void testExecuteResourceWithOperationNotExecuteById_Result_METHOD_NOT_ALLOWED() throws Exception { + public void testExecuteResourceWithOperationNotExecuteById_Result_BAD_REQUEST_Error_Is_Not_Executable() throws Exception { String expectedPath = objectInstanceIdVer_5 + "/" + RESOURCE_ID_3; String actualResult = sendRPCExecuteById(expectedPath); ObjectNode rpcActualResult = JacksonUtil.fromString(actualResult, ObjectNode.class); @@ -141,7 +217,7 @@ public class RpcLwm2mIntegrationExecuteTest extends AbstractRpcLwM2MIntegrationT * {"result":"BAD_REQUEST","error":"Specified object id 50 absent in the list supported objects of the client or is security object!"} */ @Test - public void testExecuteNonExistingResourceOnNonExistingObjectById_Result_BAD_REQUEST() throws Exception { + public void testExecuteNonExistingResourceOnNonExistingObjectById_Result_BAD_REQUEST_Error_Specified_Object_Absent_List_Supported() throws Exception { String expectedPath = OBJECT_ID_VER_50 + "/" + OBJECT_INSTANCE_ID_0 + "/" + RESOURCE_ID_3; String actualResult = sendRPCExecuteById(expectedPath); ObjectNode rpcActualResult = JacksonUtil.fromString(actualResult, ObjectNode.class); @@ -159,7 +235,7 @@ public class RpcLwm2mIntegrationExecuteTest extends AbstractRpcLwM2MIntegrationT * {"result":"BAD_REQUEST","error":"Specified object id 0 absent in the list supported objects of the client or is security object!"} */ @Test - public void testExecuteSecurityObjectById_Result_NOT_FOUND() throws Exception { + public void testExecuteSecurityObjectById_Result_BAD_REQUEST_Error_SpecifiedObjectAbsent() throws Exception { String expectedPath = objectIdVer_0 + "/" + OBJECT_INSTANCE_ID_0 + "/" + RESOURCE_ID_3; String actualResult = sendRPCExecuteById(expectedPath); ObjectNode rpcActualResult = JacksonUtil.fromString(actualResult, ObjectNode.class); @@ -178,8 +254,26 @@ public class RpcLwm2mIntegrationExecuteTest extends AbstractRpcLwM2MIntegrationT } private String sendRPCExecuteWithValueById(String path, Object value) throws Exception { - String setRpcRequest = "{\"method\": \"Execute\", \"params\": {\"id\": \"" + path + "\", \"value\": " + value + " }}"; - return doPostAsync("/api/plugins/rpc/twoway/" + lwM2MTestClient.getDeviceIdStr(), setRpcRequest, String.class, status().isOk()); + ObjectNode params = JacksonUtil.newObjectNode(); + params.put("id", path); + + // Jackson сам вирішить: ставити лапки (рядок) чи ні (число/boolean/null) + if (value instanceof String) { + params.put("value", (String) value); + } else if (value instanceof Integer) { + params.put("value", (Integer) value); + } else if (value instanceof Boolean) { + params.put("value", (Boolean) value); + } else { + params.set("value", JacksonUtil.valueToTree(value)); + } + + ObjectNode setRpcRequest = JacksonUtil.newObjectNode(); + setRpcRequest.put("method", "Execute"); + setRpcRequest.set("params", params); + + return doPostAsync("/api/plugins/rpc/twoway/" + lwM2MTestClient.getDeviceIdStr(), + JacksonUtil.toString(setRpcRequest), String.class, status().isOk()); } } diff --git a/application/src/test/java/org/thingsboard/server/transport/mqtt/AbstractMqttIntegrationTest.java b/application/src/test/java/org/thingsboard/server/transport/mqtt/AbstractMqttIntegrationTest.java index 962d85ec7c..2ed3e0d161 100644 --- a/application/src/test/java/org/thingsboard/server/transport/mqtt/AbstractMqttIntegrationTest.java +++ b/application/src/test/java/org/thingsboard/server/transport/mqtt/AbstractMqttIntegrationTest.java @@ -88,7 +88,7 @@ public abstract class AbstractMqttIntegrationTest extends AbstractTransportInteg assertNotNull(accessToken); } if (config.getGatewayName() != null) { - savedGateway = createDevice(config.getGatewayName(), deviceProfile.getName(), true); + savedGateway = createDevice(config.getGatewayName(), deviceProfile.getName(), !config.isSparkplug); DeviceCredentials gatewayCredentials = doGet("/api/device/" + savedGateway.getId().getId().toString() + "/credentials", DeviceCredentials.class); assertNotNull(gatewayCredentials); 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 a9bdbb55db..68237a1839 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 @@ -32,6 +32,7 @@ import org.thingsboard.server.common.data.Device; import org.thingsboard.server.common.data.StringUtils; import org.thingsboard.server.common.data.TransportPayloadType; import org.thingsboard.server.common.data.exception.ThingsboardException; +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; @@ -39,6 +40,7 @@ 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.common.data.relation.EntityRelation; import org.thingsboard.server.gen.transport.mqtt.SparkplugBProto; import org.thingsboard.server.transport.mqtt.AbstractMqttIntegrationTest; import org.thingsboard.server.transport.mqtt.MqttTestConfigProperties; @@ -51,13 +53,19 @@ import java.util.Calendar; import java.util.List; import java.util.Optional; import java.util.Set; +import java.util.concurrent.ExecutorService; import java.util.concurrent.ThreadLocalRandom; import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicReference; +import static java.util.concurrent.Executors.newFixedThreadPool; import static org.awaitility.Awaitility.await; import static org.eclipse.paho.mqttv5.common.packet.MqttWireMessage.MESSAGE_TYPE_CONNACK; +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.common.util.JacksonUtil.newArrayNode; +import static org.thingsboard.server.service.transport.DefaultTransportApiService.GATEWAY_CREATED_RELATION; import static org.thingsboard.server.transport.mqtt.util.sparkplug.MetricDataType.Bytes; import static org.thingsboard.server.transport.mqtt.util.sparkplug.MetricDataType.Int16; import static org.thingsboard.server.transport.mqtt.util.sparkplug.MetricDataType.Int32; @@ -67,8 +75,13 @@ import static org.thingsboard.server.transport.mqtt.util.sparkplug.MetricDataTyp import static org.thingsboard.server.transport.mqtt.util.sparkplug.MetricDataType.UInt32; import static org.thingsboard.server.transport.mqtt.util.sparkplug.MetricDataType.UInt64; import static org.thingsboard.server.transport.mqtt.util.sparkplug.MetricDataType.UInt8; +import static org.thingsboard.server.transport.mqtt.util.sparkplug.SparkplugConnectionState.ONLINE; +import static org.thingsboard.server.transport.mqtt.util.sparkplug.SparkplugMessageType.STATE; +import static org.thingsboard.server.transport.mqtt.util.sparkplug.SparkplugMessageType.messageName; import static org.thingsboard.server.transport.mqtt.util.sparkplug.SparkplugMetricUtil.createMetric; +import static org.thingsboard.server.transport.mqtt.util.sparkplug.SparkplugTopicService.DEVICE_NAME_SPLIT_SEPARATOR; import static org.thingsboard.server.transport.mqtt.util.sparkplug.SparkplugTopicService.TOPIC_ROOT_SPB_V_1_0; +import static org.thingsboard.server.transport.mqtt.util.sparkplug.SparkplugTopicService.TOPIC_SPLIT_SEPARATOR; /** * Created by nickAS21 on 12.01.23 @@ -82,6 +95,7 @@ public abstract class AbstractMqttV5ClientSparkplugTest extends AbstractMqttInte protected ThreadLocalRandom random = ThreadLocalRandom.current(); protected static final String groupId = "SparkplugBGroupId"; + protected static final String edgeNodeDeviceName = "Test Connect Sparkplug client node"; protected static final String edgeNode = "SparkpluBNode"; protected static final String keysBdSeq = "bdSeq"; protected static final String alias = "Failed Telemetry/Attribute proto sparkplug payload. SparkplugMessageType "; @@ -99,8 +113,9 @@ public abstract class AbstractMqttV5ClientSparkplugTest extends AbstractMqttInte protected Set sparkplugAttributesMetricNames; public void beforeSparkplugTest() throws Exception { + MqttTestConfigProperties configProperties = MqttTestConfigProperties.builder() - .gatewayName("Test Connect Sparkplug client node") + .gatewayName(edgeNodeDeviceName) .isSparkplug(true) .sparkplugAttributesMetricNames(sparkplugAttributesMetricNames) .transportPayloadType(TransportPayloadType.PROTOBUF) @@ -108,6 +123,25 @@ public abstract class AbstractMqttV5ClientSparkplugTest extends AbstractMqttInte processBeforeTest(configProperties); } + public void seedLegacyAndFullPathDevices() throws Exception { + // 1. Create the first device with a short name (legacy style) + String deviceName1 = deviceId + "_1"; + Device device1 = createDevice(deviceName1, deviceProfile.getName(), false); + + // 2. Establish 'Created' relation so the transport identifies this gateway as the owner + EntityRelation relation1 = createFromRelation(savedGateway, device1, GATEWAY_CREATED_RELATION); + doPost("/api/relation", relation1).andExpect(status().isOk()); + + // 3. Create the second device with a full-path name + String deviceName2 = groupId + DEVICE_NAME_SPLIT_SEPARATOR + edgeNode + DEVICE_NAME_SPLIT_SEPARATOR + deviceId + "_2"; + Device device2 = createDevice(deviceName2, deviceProfile.getName(), false); + + // 4. Establish 'Created' relation for the second device as well + EntityRelation relation2 = createFromRelation(savedGateway, device2, GATEWAY_CREATED_RELATION); + doPost("/api/relation", relation2).andExpect(status().isOk()); + + } + public void clientWithCorrectNodeAccessTokenWithNDEATH() throws Exception { long ts = calendar.getTimeInMillis(); long value = bdSeq = 0; @@ -122,9 +156,9 @@ public abstract class AbstractMqttV5ClientSparkplugTest extends AbstractMqttInte public void clientWithCorrectNodeAccessTokenWithNDEATH(long ts, long value) throws Exception { IMqttToken connectionResult = clientMqttV5ConnectWithNDEATH(ts, value, -1L); MqttWireMessage response = connectionResult.getResponse(); - Assert.assertEquals(MESSAGE_TYPE_CONNACK, response.getType()); + assertEquals(MESSAGE_TYPE_CONNACK, response.getType()); MqttConnAck connAckMsg = (MqttConnAck) response; - Assert.assertEquals(MqttReturnCode.RETURN_CODE_SUCCESS, connAckMsg.getReturnCode()); + assertEquals(MqttReturnCode.RETURN_CODE_SUCCESS, connAckMsg.getReturnCode()); } public IMqttToken clientMqttV5ConnectWithNDEATH(long ts, long value, Long alias, String... nameSpaceBad) throws Exception { @@ -146,7 +180,7 @@ public abstract class AbstractMqttV5ClientSparkplugTest extends AbstractMqttInte options.setSessionExpiryInterval(0L); options.setUserName(gatewayAccessToken); String nameSpace = nameSpaceBad.length == 0 ? TOPIC_ROOT_SPB_V_1_0 : nameSpaceBad[0]; - String topic = nameSpace + "/" + groupId + "/" + SparkplugMessageType.NDEATH.name() + "/" + edgeNode; + String topic = nameSpace + TOPIC_SPLIT_SEPARATOR + groupId + TOPIC_SPLIT_SEPARATOR + SparkplugMessageType.NDEATH.name() + TOPIC_SPLIT_SEPARATOR + edgeNode; // The NDEATH message MUST set the MQTT Will QoS to 1 and Retained flag to false MqttMessage msg = new MqttMessage(); msg.setId(0); @@ -159,38 +193,35 @@ public abstract class AbstractMqttV5ClientSparkplugTest extends AbstractMqttInte protected List connectClientWithCorrectAccessTokenWithNDEATHCreatedDevices(int cntDevices, long ts) throws Exception { List devices = new ArrayList<>(); clientWithCorrectNodeAccessTokenWithNDEATH(); - String keyInt = "Node Metric int32"; + MetricDataType metricDataType = Int32; + String key = "Node Metric int32"; int valueDeviceInt32 = 1024; - SparkplugBProto.Payload.Metric metricInt = createMetric(valueDeviceInt32, ts, keyInt, Int32, -1L); - String keyStringEmpty = "Node Metric String Empty"; - String valueDeviceStringEmpty = ""; - SparkplugBProto.Payload.Metric metricStringEmpty = createMetric(valueDeviceStringEmpty, ts, keyStringEmpty, MetricDataType.String, -1L); + SparkplugBProto.Payload.Metric metric = createMetric(valueDeviceInt32, ts, key, metricDataType, -1L); SparkplugBProto.Payload.Builder payloadBirthNode = SparkplugBProto.Payload.newBuilder() .setTimestamp(ts) .setSeq(getBdSeqNum()); - payloadBirthNode.addMetrics(metricInt); - payloadBirthNode.addMetrics(metricStringEmpty); + payloadBirthNode.addMetrics(metric); payloadBirthNode.setTimestamp(ts); if (client.isConnected()) { - client.publish(TOPIC_ROOT_SPB_V_1_0 + "/" + groupId + "/" + SparkplugMessageType.NBIRTH.name() + "/" + edgeNode, + client.publish(TOPIC_ROOT_SPB_V_1_0 + TOPIC_SPLIT_SEPARATOR + groupId + TOPIC_SPLIT_SEPARATOR + SparkplugMessageType.NBIRTH.name() + TOPIC_SPLIT_SEPARATOR + edgeNode, payloadBirthNode.build().toByteArray(), 0, false); } valueDeviceInt32 = 4024; - metricInt = createMetric(valueDeviceInt32, ts, metricBirthName_Int32, metricBirthDataType_Int32, -1L); + metric = createMetric(valueDeviceInt32, ts, metricBirthName_Int32, metricBirthDataType_Int32, -1L); for (int i = 0; i < cntDevices; i++) { SparkplugBProto.Payload.Builder payloadBirthDevice = SparkplugBProto.Payload.newBuilder() .setTimestamp(ts) .setSeq(getSeqNum()); - String deviceName = deviceId + "_" + i; - - payloadBirthDevice.addMetrics(metricInt); + String deviceIdName = deviceId + "_" + i; + String deviceName = groupId + DEVICE_NAME_SPLIT_SEPARATOR + edgeNode + DEVICE_NAME_SPLIT_SEPARATOR + deviceIdName; + payloadBirthDevice.addMetrics(metric); if (client.isConnected()) { - client.publish(TOPIC_ROOT_SPB_V_1_0 + "/" + groupId + "/" + SparkplugMessageType.DBIRTH.name() + "/" + edgeNode + "/" + deviceName, + client.publish(TOPIC_ROOT_SPB_V_1_0 + TOPIC_SPLIT_SEPARATOR + groupId + TOPIC_SPLIT_SEPARATOR + SparkplugMessageType.DBIRTH.name() + TOPIC_SPLIT_SEPARATOR + edgeNode + TOPIC_SPLIT_SEPARATOR + deviceIdName, payloadBirthDevice.build().toByteArray(), 0, false); AtomicReference device = new AtomicReference<>(); - await(alias + "find device [" + deviceName + "] after created") - .atMost(200, TimeUnit.SECONDS) + await(alias + "find device [" + deviceIdName + "] after created") + .atMost(40, TimeUnit.SECONDS) .ignoreExceptions() .until(() -> { device.set(doGet("/api/tenant/devices?deviceName=" + deviceName, Device.class)); @@ -198,13 +229,272 @@ public abstract class AbstractMqttV5ClientSparkplugTest extends AbstractMqttInte }); devices.add(device.get()); } - } - Assert.assertEquals(cntDevices, devices.size()); + assertEquals(cntDevices, devices.size()); return devices; } + protected void connectClientWithCorrectAccessTokenWithNDEATHDevicesCreatingBefore_Test(int cntDevices) throws Exception { + long ts = calendar.getTimeInMillis(); + List devices = new ArrayList<>(); + clientWithCorrectNodeAccessTokenWithNDEATH(); + MetricDataType metricDataType = Int32; + String key = "Node Metric int32"; + int valueDeviceInt32 = 1024; + SparkplugBProto.Payload.Metric metric = createMetric(valueDeviceInt32, ts, key, metricDataType, -1L); + SparkplugBProto.Payload.Builder payloadBirthNode = SparkplugBProto.Payload.newBuilder() + .setTimestamp(ts) + .setSeq(getBdSeqNum()); + payloadBirthNode.addMetrics(metric); + payloadBirthNode.setTimestamp(ts); + if (client.isConnected()) { + client.publish(TOPIC_ROOT_SPB_V_1_0 + TOPIC_SPLIT_SEPARATOR + groupId + TOPIC_SPLIT_SEPARATOR + SparkplugMessageType.NBIRTH.name() + TOPIC_SPLIT_SEPARATOR + edgeNode, + payloadBirthNode.build().toByteArray(), 0, false); + } + + valueDeviceInt32 = 4024; + metric = createMetric(valueDeviceInt32, ts, metricBirthName_Int32, metricBirthDataType_Int32, -1L); + // as old device name -> deviceId + String deviceIdNameLabel1 = deviceId + "_1"; + + if (client.isConnected()) { + SparkplugBProto.Payload.Builder payloadBirthDevice1 = SparkplugBProto.Payload.newBuilder() + .setTimestamp(ts) + .setSeq(getSeqNum()); + payloadBirthDevice1.addMetrics(metric); + client.publish(TOPIC_ROOT_SPB_V_1_0 + TOPIC_SPLIT_SEPARATOR + groupId + TOPIC_SPLIT_SEPARATOR + SparkplugMessageType.DBIRTH.name() + TOPIC_SPLIT_SEPARATOR + edgeNode + TOPIC_SPLIT_SEPARATOR + deviceIdNameLabel1, + payloadBirthDevice1.build().toByteArray(), 0, false); + + } + String deviceName1 = groupId + DEVICE_NAME_SPLIT_SEPARATOR + edgeNode + DEVICE_NAME_SPLIT_SEPARATOR + deviceIdNameLabel1; + AtomicReference device1 = new AtomicReference<>(); + await(alias + "find device [" + deviceName1 + "] before connecting") + .atMost(40, TimeUnit.SECONDS) + .until(() -> { + device1.set(doGet("/api/tenant/devices?deviceName=" + deviceName1, Device.class)); + return device1.get() != null; + }); + devices.add(device1.get()); + + // as new device name -> groupId + ":" + edgeNode + ":" + deviceId; + String deviceIdName2 = deviceId + "_2"; + if (client.isConnected()) { + SparkplugBProto.Payload.Builder payloadBirthDevice2 = SparkplugBProto.Payload.newBuilder() + .setTimestamp(ts) + .setSeq(getSeqNum()); + payloadBirthDevice2.addMetrics(metric); + client.publish(TOPIC_ROOT_SPB_V_1_0 + TOPIC_SPLIT_SEPARATOR + groupId + TOPIC_SPLIT_SEPARATOR + SparkplugMessageType.DBIRTH.name() + TOPIC_SPLIT_SEPARATOR + edgeNode + TOPIC_SPLIT_SEPARATOR + deviceIdName2, + payloadBirthDevice2.build().toByteArray(), 0, false); + } + String deviceName2 = groupId + DEVICE_NAME_SPLIT_SEPARATOR + edgeNode + DEVICE_NAME_SPLIT_SEPARATOR + deviceIdName2; + AtomicReference device2 = new AtomicReference<>(); + await(alias + "find device [" + deviceName2 + "] before connecting") + .atMost(40, TimeUnit.SECONDS) + .until(() -> { + device2.set(doGet("/api/tenant/devices?deviceName=" + deviceName2, Device.class)); + return device2.get() != null; + }); + devices.add(device2.get()); + assertEquals(cntDevices, devices.size()); + state_ONLINE_ALL (devices, calendar.getTimeInMillis()); + // Without full topic: as it was in the old version. When deviceId is updated to full theme, Label is also updated to old deviceId + assertEquals(deviceIdNameLabel1, device1.get().getLabel()); + // // With a full topic: if new. When creating a device by a client to a full topic, if the Label was not filled in - we do not touch it. + Assert.assertNull(device2.get().getLabel()); + } + + /** + * Coverage: Rename when a device with the target full-path name already exists (collision). + */ + protected void renameCollisionWhenTargetNameAlreadyExists_Test() throws Exception { + long ts = calendar.getTimeInMillis(); + String shortName = deviceId + "_1"; // Created in beforeTest + String fullPathName = groupId + DEVICE_NAME_SPLIT_SEPARATOR + edgeNode + DEVICE_NAME_SPLIT_SEPARATOR + shortName; + + // Manually create a device that already has the "new" full-path name to trigger a collision + createDevice(fullPathName, deviceProfile.getName(), false); + + clientWithCorrectNodeAccessTokenWithNDEATH(); + + SparkplugBProto.Payload.Builder payload = SparkplugBProto.Payload.newBuilder() + .setTimestamp(ts) + .setSeq(getSeqNum()); + payload.addMetrics(createMetric(123, ts, metricBirthName_Int32, metricBirthDataType_Int32, -1L)); + + // Gateway sends DBIRTH for the short name. + // Transport will try to rename it but should find a conflict and handle it gracefully. + client.publish(TOPIC_ROOT_SPB_V_1_0 + TOPIC_SPLIT_SEPARATOR + groupId + "/DBIRTH/" + edgeNode + TOPIC_SPLIT_SEPARATOR + shortName, + payload.build().toByteArray(), 0, false); + + await("Checking stability after collision") + .atMost(40, TimeUnit.SECONDS) + .until(() -> { + Device oldDevice = doGet("/api/tenant/devices?deviceName=" + shortName, Device.class); + Device conflictDevice = doGet("/api/tenant/devices?deviceName=" + fullPathName, Device.class); + // Both devices must still exist, proving no exception crashed the process + return oldDevice != null && conflictDevice != null; + }); + } + + /** + * Coverage: The privilege concern — attempt to rename a device not owned by the gateway. + * This test verifies that the original device's ID remains unchanged, meaning it was not hijacked. + */ + protected void unauthorizedRenameAttemptBad_Test() throws Exception { + long ts = calendar.getTimeInMillis(); + String strangerName = "unauthorized_device_rename"; + + // 1. Create a "stranger" device via API (it has no 'Created' relation to the gateway) + Device stranger = new Device(); + stranger.setName(strangerName); + stranger.setType("default"); + doPost("/api/device", stranger); + final DeviceId originalStrangerId = stranger.getId(); + + clientWithCorrectNodeAccessTokenWithNDEATH(); + + SparkplugBProto.Payload.Builder payload = SparkplugBProto.Payload.newBuilder() + .setTimestamp(ts).setSeq(getSeqNum()); + + // 2. Unauthorized gateway attempts to rename this device via Sparkplug topic path + client.publish(TOPIC_ROOT_SPB_V_1_0 + TOPIC_SPLIT_SEPARATOR + groupId + "/DBIRTH/" + edgeNode + TOPIC_SPLIT_SEPARATOR + strangerName, + payload.build().toByteArray(), 0, false); + + String expectedFullPath = groupId + DEVICE_NAME_SPLIT_SEPARATOR + edgeNode + DEVICE_NAME_SPLIT_SEPARATOR + strangerName; + + // 3. Verify security: the original device must still be linked to its short name with the same ID + await("Verify original device was not hijacked") + .atMost(40, TimeUnit.SECONDS) + .pollDelay(2, TimeUnit.SECONDS) + .untilAsserted(() -> { + // Check if the original device still exists with its original ID + Device currentStranger = doGet("/api/tenant/devices?deviceName=" + strangerName, Device.class); + assertNotNull("Original device disappeared!", currentStranger); + assertEquals("Security breach: Original device ID changed!", originalStrangerId, currentStranger.getId()); + + // Even if the gateway created a NEW device with a full path, it must have a different ID + Device newDevice = doGet("/api/tenant/devices?deviceName=" + expectedFullPath, Device.class); + if (newDevice != null) { + Assert.assertNotEquals("Stranger device was successfully hijacked (IDs match)!", originalStrangerId, newDevice.getId()); + } + }); + } + + /** + * Coverage: The privilege concern — attempt to rename a device not owned by the gateway. + */ + protected void unauthorizedRenameAttempt_Test() throws Exception { + long ts = calendar.getTimeInMillis(); + String strangerName = "unauthorized_device_rename"; + + // Create a device without a "Created" relation to the gateway + Device stranger = new Device(); + stranger.setName(strangerName); + stranger.setType("default"); + doPost("/api/device", stranger); + Device originalStrangerDevice = + doGet("/api/tenant/devices?deviceName=" + strangerName, Device.class); + + clientWithCorrectNodeAccessTokenWithNDEATH(); + + SparkplugBProto.Payload.Builder payload = SparkplugBProto.Payload.newBuilder() + .setTimestamp(ts).setSeq(getSeqNum()); + + // Unauthorized gateway attempts to rename the device via Sparkplug topic + client.publish(TOPIC_ROOT_SPB_V_1_0 + TOPIC_SPLIT_SEPARATOR + groupId + "/DBIRTH/" + edgeNode + TOPIC_SPLIT_SEPARATOR + strangerName, + payload.build().toByteArray(), 0, false); + + String expectedFullPath = groupId + DEVICE_NAME_SPLIT_SEPARATOR + edgeNode + DEVICE_NAME_SPLIT_SEPARATOR + strangerName; + await().atMost(30, TimeUnit.SECONDS).untilAsserted(() -> + doGet("/api/tenant/devices?deviceName=" + expectedFullPath, Device.class, status().isOk()) + ); + + Device strangerDevice = + doGet("/api/tenant/devices?deviceName=" + strangerName, Device.class); + assertNotNull(strangerDevice); + assertEquals(originalStrangerDevice.getId(), strangerDevice.getId()); + } + + protected void state_ONLINE_ALL (List devices, long ts) { + TsKvEntry tsKvEntry = new BasicTsKvEntry(ts, new StringDataEntry(messageName(STATE), ONLINE.name())); + await(alias + messageName(STATE) + ", device: " + savedGateway.getName()) + .atMost(40, TimeUnit.SECONDS) + .until(() -> { + var foundEntry = tsService.findAllLatest(tenantId, savedGateway.getId()).get().stream() + .filter(tsKv -> tsKv.getKey().equals(tsKvEntry.getKey())) + .filter(tsKv -> tsKv.getValue().equals(tsKvEntry.getValue())) + .filter(tsKv -> tsKv.getTs() == tsKvEntry.getTs()) + .findFirst(); + return foundEntry.isPresent(); + }); + + for (Device device : devices) { + await(alias + messageName(STATE) + ", device: " + device.getName()) + .atMost(40, TimeUnit.SECONDS) + .until(() -> { + var foundEntry = tsService.findAllLatest(tenantId, device.getId()).get().stream() + .filter(tsKv -> tsKv.getKey().equals(tsKvEntry.getKey())) + .filter(tsKv -> tsKv.getValue().equals(tsKvEntry.getValue())) + .filter(tsKv -> tsKv.getTs() == tsKvEntry.getTs()) + .findFirst(); + return foundEntry.isPresent(); + }); + } + } + + /** + * Coverage: Concurrent first-message registration with the lock mechanism. + */ + protected void concurrentFirstMessageRegistration_Test() throws Exception { + int threadCount = 5; + String concurrentDeviceName = "concurrent_device"; + clientWithCorrectNodeAccessTokenWithNDEATH(); + + ExecutorService executor = newFixedThreadPool(threadCount); + long ts = calendar.getTimeInMillis(); + + for (int i = 0; i < threadCount; i++) { + executor.submit(() -> { + try { + SparkplugBProto.Payload.Builder payload = SparkplugBProto.Payload.newBuilder() + .setTimestamp(ts).setSeq(0); + client.publish(TOPIC_ROOT_SPB_V_1_0 + TOPIC_SPLIT_SEPARATOR + groupId + "/DBIRTH/" + edgeNode + TOPIC_SPLIT_SEPARATOR + concurrentDeviceName, + payload.build().toByteArray(), 0, false); + } catch (Exception e) { + log.error("Concurrent publish failed", e); + } + }); + } + + String expectedName = groupId + DEVICE_NAME_SPLIT_SEPARATOR + edgeNode + DEVICE_NAME_SPLIT_SEPARATOR + concurrentDeviceName; + await("Wait for concurrent registration result") + .atMost(40, TimeUnit.SECONDS) + .until(() -> doGet("/api/tenant/devices?deviceName=" + expectedName, Device.class) != null); + + executor.shutdown(); + } + + /** + * Coverage: Sparkplug-message handling when msgId <= 0 (#7). + * Verifies that the transport does not close the session for Sparkplug clients using msgId 0. + */ + protected void sparkplugSessionStaysAliveWithZeroMsgId_Test() throws Exception { + // clientMqttV5ConnectWithNDEATH internally sets msgId = 0 for the Will message. + // This validates that the connection is accepted despite msgId being 0. + IMqttToken connectionResult = clientMqttV5ConnectWithNDEATH(calendar.getTimeInMillis(), 0, -1L); + Assert.assertTrue("Sparkplug connection should be successful with msgId=0", client.isConnected()); + + // Publish NBIRTH message which usually goes through the aggregate callback. + // This verifies that msgId=0 in the callback does not trigger closeDeviceSession. + connectionWithNBirth(Int32, "test_metric_msgId_0", 555); + + // Awaitility to ensure the session remains open after processing. + await("Verify Sparkplug session remains open after receiving msgId=0") + .atMost(40, TimeUnit.SECONDS) + .until(() -> client.isConnected()); + } + protected List connectClientWithCorrectAccessTokenWithNDEATHWithAliasCreatedDevices(long ts) throws Exception { List devices = new ArrayList<>(); Long alias = 0L; @@ -219,7 +509,7 @@ public abstract class AbstractMqttV5ClientSparkplugTest extends AbstractMqttInte payloadBirthNode.addMetrics(metric); payloadBirthNode.setTimestamp(ts); if (client.isConnected()) { - client.publish(TOPIC_ROOT_SPB_V_1_0 + "/" + groupId + "/" + SparkplugMessageType.NBIRTH.name() + "/" + edgeNode, + client.publish(TOPIC_ROOT_SPB_V_1_0 + TOPIC_SPLIT_SEPARATOR + groupId + TOPIC_SPLIT_SEPARATOR + SparkplugMessageType.NBIRTH.name() + TOPIC_SPLIT_SEPARATOR + edgeNode, payloadBirthNode.build().toByteArray(), 0, false); } @@ -228,15 +518,16 @@ public abstract class AbstractMqttV5ClientSparkplugTest extends AbstractMqttInte SparkplugBProto.Payload.Builder payloadBirthDevice = SparkplugBProto.Payload.newBuilder() .setTimestamp(ts) .setSeq(getSeqNum()); - String deviceName = deviceId + "_" + 1; + String deviceIdName = deviceId + "_1"; + String deviceName = groupId + DEVICE_NAME_SPLIT_SEPARATOR + edgeNode + DEVICE_NAME_SPLIT_SEPARATOR + deviceIdName; payloadBirthDevice.addMetrics(metric); if (client.isConnected()) { - client.publish(TOPIC_ROOT_SPB_V_1_0 + "/" + groupId + "/" + SparkplugMessageType.DBIRTH.name() + "/" + edgeNode + "/" + deviceName, + client.publish(TOPIC_ROOT_SPB_V_1_0 + TOPIC_SPLIT_SEPARATOR + groupId + TOPIC_SPLIT_SEPARATOR + SparkplugMessageType.DBIRTH.name() + TOPIC_SPLIT_SEPARATOR + edgeNode + TOPIC_SPLIT_SEPARATOR + deviceIdName, payloadBirthDevice.build().toByteArray(), 0, false); AtomicReference device = new AtomicReference<>(); await(alias + "find device [" + deviceName + "] after created") - .atMost(200, TimeUnit.SECONDS) + .atMost(40, TimeUnit.SECONDS) .ignoreExceptions() .until(() -> { device.set(doGet("/api/tenant/devices?deviceName=" + deviceName, Device.class)); @@ -245,7 +536,7 @@ public abstract class AbstractMqttV5ClientSparkplugTest extends AbstractMqttInte devices.add(device.get()); } - Assert.assertEquals(1, devices.size()); + assertEquals(1, devices.size()); return devices; } @@ -282,7 +573,7 @@ public abstract class AbstractMqttV5ClientSparkplugTest extends AbstractMqttInte listKeys.add(metricKey); if (client.isConnected()) { - client.publish(TOPIC_ROOT_SPB_V_1_0 + "/" + groupId + "/" + SparkplugMessageType.NBIRTH.name() + "/" + edgeNode, + client.publish(TOPIC_ROOT_SPB_V_1_0 + TOPIC_SPLIT_SEPARATOR + groupId + TOPIC_SPLIT_SEPARATOR + SparkplugMessageType.NBIRTH.name() + TOPIC_SPLIT_SEPARATOR + edgeNode, payloadBirthNode.build().toByteArray(), 0, false); } return listKeys; @@ -525,4 +816,9 @@ public abstract class AbstractMqttV5ClientSparkplugTest extends AbstractMqttInte } } + private EntityRelation createFromRelation(Device mainDevice, Device device, String relationType) { + return new EntityRelation(mainDevice.getId(), device.getId(), relationType); + } + + } diff --git a/application/src/test/java/org/thingsboard/server/transport/mqtt/sparkplug/connection/AbstractMqttV5ClientSparkplugConnectionTest.java b/application/src/test/java/org/thingsboard/server/transport/mqtt/sparkplug/connection/AbstractMqttV5ClientSparkplugConnectionTest.java index 8f9943b393..1402e5c4c3 100644 --- a/application/src/test/java/org/thingsboard/server/transport/mqtt/sparkplug/connection/AbstractMqttV5ClientSparkplugConnectionTest.java +++ b/application/src/test/java/org/thingsboard/server/transport/mqtt/sparkplug/connection/AbstractMqttV5ClientSparkplugConnectionTest.java @@ -37,10 +37,10 @@ import java.util.concurrent.atomic.AtomicReference; import static org.awaitility.Awaitility.await; import static org.thingsboard.server.transport.mqtt.util.sparkplug.SparkplugConnectionState.OFFLINE; -import static org.thingsboard.server.transport.mqtt.util.sparkplug.SparkplugConnectionState.ONLINE; import static org.thingsboard.server.transport.mqtt.util.sparkplug.SparkplugMessageType.STATE; import static org.thingsboard.server.transport.mqtt.util.sparkplug.SparkplugMessageType.messageName; import static org.thingsboard.server.transport.mqtt.util.sparkplug.SparkplugTopicService.TOPIC_ROOT_SPB_V_1_0; +import static org.thingsboard.server.transport.mqtt.util.sparkplug.SparkplugTopicService.TOPIC_SPLIT_SEPARATOR; /** * Created by nickAS21 on 12.01.23 @@ -95,31 +95,7 @@ public abstract class AbstractMqttV5ClientSparkplugConnectionTest extends Abstra protected void processConnectClientWithCorrectAccessTokenWithNDEATH_State_ONLINE_ALL(int cntDevices) throws Exception { long ts = calendar.getTimeInMillis(); List devices = connectClientWithCorrectAccessTokenWithNDEATHCreatedDevices(cntDevices, ts); - - TsKvEntry tsKvEntry = new BasicTsKvEntry(ts, new StringDataEntry(messageName(STATE), ONLINE.name())); - await(alias + messageName(STATE) + ", device: " + savedGateway.getName()) - .atMost(40, TimeUnit.SECONDS) - .until(() -> { - var foundEntry = tsService.findAllLatest(tenantId, savedGateway.getId()).get().stream() - .filter(tsKv -> tsKv.getKey().equals(tsKvEntry.getKey())) - .filter(tsKv -> tsKv.getValue().equals(tsKvEntry.getValue())) - .filter(tsKv -> tsKv.getTs() == tsKvEntry.getTs()) - .findFirst(); - return foundEntry.isPresent(); - }); - - for (Device device : devices) { - await(alias + messageName(STATE) + ", device: " + device.getName()) - .atMost(40, TimeUnit.SECONDS) - .until(() -> { - var foundEntry = tsService.findAllLatest(tenantId, device.getId()).get().stream() - .filter(tsKv -> tsKv.getKey().equals(tsKvEntry.getKey())) - .filter(tsKv -> tsKv.getValue().equals(tsKvEntry.getValue())) - .filter(tsKv -> tsKv.getTs() == tsKvEntry.getTs()) - .findFirst(); - return foundEntry.isPresent(); - }); - } + state_ONLINE_ALL (devices, ts); } protected void processConnectClientWithCorrectAccessTokenWithNDEATH_State_ONLINE_All_Then_OneDeviceOFFLINE(int cntDevices, int indexDeviceDisconnect) throws Exception { @@ -135,7 +111,7 @@ public abstract class AbstractMqttV5ClientSparkplugConnectionTest extends Abstra if (client.isConnected()) { List devicesList = new ArrayList<>(devices); Device device = devicesList.get(indexDeviceDisconnect); - client.publish(TOPIC_ROOT_SPB_V_1_0 + "/" + groupId + "/" + SparkplugMessageType.DDEATH.name() + "/" + edgeNode + "/" + device.getName(), + client.publish(TOPIC_ROOT_SPB_V_1_0 + TOPIC_SPLIT_SEPARATOR + groupId + TOPIC_SPLIT_SEPARATOR + SparkplugMessageType.DDEATH.name() + TOPIC_SPLIT_SEPARATOR + edgeNode + TOPIC_SPLIT_SEPARATOR + device.getName(), payloadDeathDevice.build().toByteArray(), 0, false); await(alias + messageName(STATE) + ", device: " + device.getName()) .atMost(40, TimeUnit.SECONDS) diff --git a/application/src/test/java/org/thingsboard/server/transport/mqtt/sparkplug/connection/MqttV5ClientSparkplugBConnectionDevicesCreatingBeforeTest.java b/application/src/test/java/org/thingsboard/server/transport/mqtt/sparkplug/connection/MqttV5ClientSparkplugBConnectionDevicesCreatingBeforeTest.java new file mode 100644 index 0000000000..08fc4ea9a5 --- /dev/null +++ b/application/src/test/java/org/thingsboard/server/transport/mqtt/sparkplug/connection/MqttV5ClientSparkplugBConnectionDevicesCreatingBeforeTest.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.transport.mqtt.sparkplug.connection; + +import org.eclipse.paho.mqttv5.common.MqttException; +import org.junit.After; +import org.junit.Before; +import org.junit.Test; +import org.thingsboard.server.dao.service.DaoSqlTest; + +/** + * Created by nickAS21 on 16.02.26 + */ +@DaoSqlTest +public class MqttV5ClientSparkplugBConnectionDevicesCreatingBeforeTest extends AbstractMqttV5ClientSparkplugConnectionTest { + + /** + * String deviceName_1 = deviceId + "_1"; Only name device. Without a complete topic: how it was in the old version. + * String deviceName_2 = groupId + DEVICE_NAME_SPLIT_REGEXP + edgeNode + DEVICE_NAME_SPLIT_REGEXP + deviceId + "_2"; With complete topic: how it was in the new version. + * @throws Exception + */ + @Before + public void beforeTest() throws Exception { + beforeSparkplugTest(); + seedLegacyAndFullPathDevices(); + } + + @After + public void afterTest() throws MqttException { + if (client.isConnected()) { + client.disconnect(); + } + } + + @Test + public void testClientWithCorrectAccessTokenWithNDEATHTwoDevicesCreatingBeforeFirstNameDeviceIdSecondNameFull() throws Exception { + connectClientWithCorrectAccessTokenWithNDEATHDevicesCreatingBefore_Test(2); + } + + @Test + public void testRenameWhenDeviceFullPathAlreadyExists_Collision() throws Exception { + renameCollisionWhenTargetNameAlreadyExists_Test(); + } + + @Test + public void testUnauthorizedRenameAttempt() throws Exception { + unauthorizedRenameAttempt_Test(); + } + + @Test + public void testConcurrentFirstMessageRegistration() throws Exception { + concurrentFirstMessageRegistration_Test(); + } + + @Test + public void testSparkplugSessionStaysAliveWithZeroMsgId() throws Exception { + sparkplugSessionStaysAliveWithZeroMsgId_Test(); + } +} diff --git a/application/src/test/java/org/thingsboard/server/transport/mqtt/sparkplug/rpc/MqttV5RpcSparkplugTest.java b/application/src/test/java/org/thingsboard/server/transport/mqtt/sparkplug/rpc/MqttV5RpcSparkplugTest.java index e36b4e6260..e90f6e94da 100644 --- a/application/src/test/java/org/thingsboard/server/transport/mqtt/sparkplug/rpc/MqttV5RpcSparkplugTest.java +++ b/application/src/test/java/org/thingsboard/server/transport/mqtt/sparkplug/rpc/MqttV5RpcSparkplugTest.java @@ -47,6 +47,7 @@ public class MqttV5RpcSparkplugTest extends AbstractMqttV5RpcSparkplugTest { public void testClientDeviceWithCorrectAccessTokenPublish_TwoWayRpc_Success() throws Exception { processClientDeviceWithCorrectAccessTokenPublish_TwoWayRpc_Success(); } + @Test public void testClientDeviceWithCorrectAccessTokenPublishWithAlias_TwoWayRpc_Success() throws Exception { processClientDeviceWithCorrectAccessTokenPublishWithAlias_TwoWayRpc_Success(); diff --git a/application/src/test/java/org/thingsboard/server/transport/mqtt/sparkplug/timeseries/AbstractMqttV5ClientSparkplugTelemetryTest.java b/application/src/test/java/org/thingsboard/server/transport/mqtt/sparkplug/timeseries/AbstractMqttV5ClientSparkplugTelemetryTest.java index 319b61b7f3..8f2d0659af 100644 --- a/application/src/test/java/org/thingsboard/server/transport/mqtt/sparkplug/timeseries/AbstractMqttV5ClientSparkplugTelemetryTest.java +++ b/application/src/test/java/org/thingsboard/server/transport/mqtt/sparkplug/timeseries/AbstractMqttV5ClientSparkplugTelemetryTest.java @@ -30,6 +30,7 @@ import java.util.concurrent.atomic.AtomicReference; import static org.awaitility.Awaitility.await; import static org.thingsboard.server.transport.mqtt.util.sparkplug.SparkplugTopicService.TOPIC_ROOT_SPB_V_1_0; +import static org.thingsboard.server.transport.mqtt.util.sparkplug.SparkplugTopicService.TOPIC_SPLIT_SEPARATOR; /** * Created by nickAS21 on 12.01.23 @@ -67,7 +68,7 @@ public abstract class AbstractMqttV5ClientSparkplugTelemetryTest extends Abstrac createdAddMetricValuePrimitiveTsKv(listTsKvEntry, listKeys, ndataPayload, ts); if (client.isConnected()) { - client.publish(TOPIC_ROOT_SPB_V_1_0 + "/" + groupId + "/" + messageTypeName + "/" + edgeNode, + client.publish(TOPIC_ROOT_SPB_V_1_0 + TOPIC_SPLIT_SEPARATOR + groupId + TOPIC_SPLIT_SEPARATOR + messageTypeName + TOPIC_SPLIT_SEPARATOR + edgeNode, ndataPayload.build().toByteArray(), 0, false); } @@ -96,7 +97,7 @@ public abstract class AbstractMqttV5ClientSparkplugTelemetryTest extends Abstrac createdAddMetricValueArraysPrimitiveTsKv(listTsKvEntry, listKeys, ndataPayload, ts); if (client.isConnected()) { - client.publish(TOPIC_ROOT_SPB_V_1_0 + "/" + groupId + "/" + messageTypeName + "/" + edgeNode, + client.publish(TOPIC_ROOT_SPB_V_1_0 + TOPIC_SPLIT_SEPARATOR + groupId + TOPIC_SPLIT_SEPARATOR + messageTypeName + TOPIC_SPLIT_SEPARATOR + edgeNode, ndataPayload.build().toByteArray(), 0, false); } 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/common/actor/pom.xml b/common/actor/pom.xml index b3f04d31a1..6326c45685 100644 --- a/common/actor/pom.xml +++ b/common/actor/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard - 4.3.1.3-SNAPSHOT + 4.4.0-SNAPSHOT common org.thingsboard.common diff --git a/common/cache/pom.xml b/common/cache/pom.xml index e95bb301b4..774015fc33 100644 --- a/common/cache/pom.xml +++ b/common/cache/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard - 4.3.1.3-SNAPSHOT + 4.4.0-SNAPSHOT common org.thingsboard.common diff --git a/common/cluster-api/pom.xml b/common/cluster-api/pom.xml index 63dc714000..5983b96b95 100644 --- a/common/cluster-api/pom.xml +++ b/common/cluster-api/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard - 4.3.1.3-SNAPSHOT + 4.4.0-SNAPSHOT common org.thingsboard.common diff --git a/common/coap-server/pom.xml b/common/coap-server/pom.xml index d713c6d065..32824826c3 100644 --- a/common/coap-server/pom.xml +++ b/common/coap-server/pom.xml @@ -22,7 +22,7 @@ 4.0.0 org.thingsboard - 4.3.1.3-SNAPSHOT + 4.4.0-SNAPSHOT common org.thingsboard.common diff --git a/common/dao-api/pom.xml b/common/dao-api/pom.xml index 87b217e205..12533cd813 100644 --- a/common/dao-api/pom.xml +++ b/common/dao-api/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard - 4.3.1.3-SNAPSHOT + 4.4.0-SNAPSHOT common org.thingsboard.common diff --git a/common/data/pom.xml b/common/data/pom.xml index 81bf55ffd7..7a521ac9d5 100644 --- a/common/data/pom.xml +++ b/common/data/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard - 4.3.1.3-SNAPSHOT + 4.4.0-SNAPSHOT common org.thingsboard.common diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/SystemParams.java b/common/data/src/main/java/org/thingsboard/server/common/data/SystemParams.java index e148c413b5..e1eae63acc 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/SystemParams.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/SystemParams.java @@ -45,6 +45,7 @@ public class SystemParams { long minAllowedAggregationIntervalInSecForCF; long intermediateAggregationIntervalInSecForCF; TrendzSettings trendzSettings; + boolean allowKeyFiltersOrConditions; String nullsOrderStrategy; boolean edqsEnabled; String iotHubBaseUrl; 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 f14e97a704..578b7909c0 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/alarm/rule/condition/expression/AlarmConditionFilter.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/alarm/rule/condition/expression/AlarmConditionFilter.java @@ -24,6 +24,7 @@ import lombok.Data; import org.thingsboard.server.common.data.alarm.rule.condition.expression.predicate.ComplexFilterPredicate; import org.thingsboard.server.common.data.alarm.rule.condition.expression.predicate.FilterPredicateType; import org.thingsboard.server.common.data.alarm.rule.condition.expression.predicate.KeyFilterPredicate; +import org.thingsboard.server.common.data.query.ComplexOperation; import org.thingsboard.server.common.data.query.EntityKeyValueType; import io.swagger.v3.oas.annotations.media.Schema; 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 390bbb3230..a581fa3a41 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/alarm/rule/condition/expression/SimpleAlarmConditionExpression.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/alarm/rule/condition/expression/SimpleAlarmConditionExpression.java @@ -22,6 +22,7 @@ import lombok.AllArgsConstructor; import lombok.Data; import lombok.NoArgsConstructor; import org.thingsboard.server.common.data.alarm.rule.condition.expression.predicate.FilterPredicateType; +import org.thingsboard.server.common.data.query.ComplexOperation; import java.util.List; diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/alarm/rule/condition/expression/predicate/ComplexFilterPredicate.java b/common/data/src/main/java/org/thingsboard/server/common/data/alarm/rule/condition/expression/predicate/ComplexFilterPredicate.java index 17c5279b3e..34f128d3e2 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/alarm/rule/condition/expression/predicate/ComplexFilterPredicate.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/alarm/rule/condition/expression/predicate/ComplexFilterPredicate.java @@ -18,7 +18,7 @@ package org.thingsboard.server.common.data.alarm.rule.condition.expression.predi import io.swagger.v3.oas.annotations.media.ArraySchema; import io.swagger.v3.oas.annotations.media.Schema; import lombok.Data; -import org.thingsboard.server.common.data.alarm.rule.condition.expression.ComplexOperation; +import org.thingsboard.server.common.data.query.ComplexOperation; import java.util.List; diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/query/AbstractDataQuery.java b/common/data/src/main/java/org/thingsboard/server/common/data/query/AbstractDataQuery.java index a678338864..3aabbae0a8 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/query/AbstractDataQuery.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/query/AbstractDataQuery.java @@ -49,4 +49,16 @@ public abstract class AbstractDataQuery extends En this.latestValues = latestValues; } + public AbstractDataQuery(EntityFilter entityFilter, + T pageLink, + List entityFields, + List latestValues, + List keyFilters, + ComplexOperation keyFiltersOperation) { + super(entityFilter, keyFilters, keyFiltersOperation); + this.pageLink = pageLink; + this.entityFields = entityFields; + this.latestValues = latestValues; + } + } diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/query/AlarmCountQuery.java b/common/data/src/main/java/org/thingsboard/server/common/data/query/AlarmCountQuery.java index 8431d7a303..d45335021f 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/query/AlarmCountQuery.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/query/AlarmCountQuery.java @@ -51,4 +51,8 @@ public class AlarmCountQuery extends EntityCountQuery { super(entityFilter, keyFilters); } + public AlarmCountQuery(EntityFilter entityFilter, List keyFilters, ComplexOperation keyFiltersOperation) { + super(entityFilter, keyFilters, keyFiltersOperation); + } + } diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/query/AlarmDataQuery.java b/common/data/src/main/java/org/thingsboard/server/common/data/query/AlarmDataQuery.java index 8d2ae6fae3..072f56abd1 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/query/AlarmDataQuery.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/query/AlarmDataQuery.java @@ -39,8 +39,13 @@ public class AlarmDataQuery extends AbstractDataQuery { this.alarmFields = alarmFields; } + public AlarmDataQuery(EntityFilter entityFilter, AlarmDataPageLink pageLink, List entityFields, List latestValues, List keyFilters, List alarmFields, ComplexOperation keyFiltersOperation) { + super(entityFilter, pageLink, entityFields, latestValues, keyFilters, keyFiltersOperation); + this.alarmFields = alarmFields; + } + @JsonIgnore public AlarmDataQuery next() { - return new AlarmDataQuery(getEntityFilter(), getPageLink().nextPageLink(), entityFields, latestValues, keyFilters, alarmFields); + return new AlarmDataQuery(getEntityFilter(), getPageLink().nextPageLink(), entityFields, latestValues, keyFilters, alarmFields, getKeyFiltersOperation()); } } diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/query/ComplexFilterPredicate.java b/common/data/src/main/java/org/thingsboard/server/common/data/query/ComplexFilterPredicate.java index 557f08eb2b..248211ad92 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/query/ComplexFilterPredicate.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/query/ComplexFilterPredicate.java @@ -34,9 +34,4 @@ 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/alarm/rule/condition/expression/ComplexOperation.java b/common/data/src/main/java/org/thingsboard/server/common/data/query/ComplexOperation.java similarity index 90% rename from common/data/src/main/java/org/thingsboard/server/common/data/alarm/rule/condition/expression/ComplexOperation.java rename to common/data/src/main/java/org/thingsboard/server/common/data/query/ComplexOperation.java index 9ed265f3b8..f08646ec4e 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/alarm/rule/condition/expression/ComplexOperation.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/query/ComplexOperation.java @@ -13,7 +13,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package org.thingsboard.server.common.data.alarm.rule.condition.expression; +package org.thingsboard.server.common.data.query; import io.swagger.v3.oas.annotations.media.Schema; diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/query/EntityCountQuery.java b/common/data/src/main/java/org/thingsboard/server/common/data/query/EntityCountQuery.java index b4680bfd5b..ee39fe7d77 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/query/EntityCountQuery.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/query/EntityCountQuery.java @@ -34,6 +34,9 @@ public class EntityCountQuery { @Getter protected List keyFilters; + @Getter + private ComplexOperation keyFiltersOperation; + public EntityCountQuery() { } @@ -45,4 +48,14 @@ public class EntityCountQuery { this.entityFilter = entityFilter; this.keyFilters = keyFilters; } + + public EntityCountQuery(EntityFilter entityFilter, List keyFilters, ComplexOperation keyFiltersOperation) { + this.entityFilter = entityFilter; + this.keyFilters = keyFilters; + this.keyFiltersOperation = keyFiltersOperation; + } + + public ComplexOperation getKeyFiltersOperationOrDefault() { + return keyFiltersOperation != null ? keyFiltersOperation : ComplexOperation.AND; + } } diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/query/EntityDataQuery.java b/common/data/src/main/java/org/thingsboard/server/common/data/query/EntityDataQuery.java index 959e3e3af6..f24ffd5821 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/query/EntityDataQuery.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/query/EntityDataQuery.java @@ -34,9 +34,13 @@ public class EntityDataQuery extends AbstractDataQuery { super(entityFilter, pageLink, entityFields, latestValues, keyFilters); } + public EntityDataQuery(EntityFilter entityFilter, EntityDataPageLink pageLink, List entityFields, List latestValues, List keyFilters, ComplexOperation keyFiltersOperation) { + super(entityFilter, pageLink, entityFields, latestValues, keyFilters, keyFiltersOperation); + } + @JsonIgnore public EntityDataQuery next() { - return new EntityDataQuery(getEntityFilter(), getPageLink().nextPageLink(), entityFields, latestValues, keyFilters); + return new EntityDataQuery(getEntityFilter(), getPageLink().nextPageLink(), entityFields, latestValues, keyFilters, getKeyFiltersOperation()); } } diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/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/security/DeviceCredentialsFilter.java b/common/data/src/main/java/org/thingsboard/server/common/data/security/DeviceCredentialsFilter.java index 201615eb98..6d7188733e 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/security/DeviceCredentialsFilter.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/security/DeviceCredentialsFilter.java @@ -16,7 +16,7 @@ package org.thingsboard.server.common.data.security; /** - * TODO: This is a temporary name. DeviceCredentialsId is resereved in dao layer + * TODO: This is a temporary name. DeviceCredentialsId is reserved in dao layer */ public interface DeviceCredentialsFilter { 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/discovery-api/pom.xml b/common/discovery-api/pom.xml index 275d7762d5..c7658bd738 100644 --- a/common/discovery-api/pom.xml +++ b/common/discovery-api/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard - 4.3.1.3-SNAPSHOT + 4.4.0-SNAPSHOT common org.thingsboard.common diff --git a/common/edge-api/pom.xml b/common/edge-api/pom.xml index 647accd360..379e4fb8fb 100644 --- a/common/edge-api/pom.xml +++ b/common/edge-api/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard - 4.3.1.3-SNAPSHOT + 4.4.0-SNAPSHOT common org.thingsboard.common diff --git a/common/edge-api/src/main/proto/edge.proto b/common/edge-api/src/main/proto/edge.proto index 11de63a977..8dcceb7a8f 100644 --- a/common/edge-api/src/main/proto/edge.proto +++ b/common/edge-api/src/main/proto/edge.proto @@ -55,6 +55,7 @@ enum EdgeVersion { V_4_3_1_1 = 4311; V_4_3_1_2 = 4312; V_4_3_1_3 = 4313; + V_4_4_0 = 4400; V_LATEST = 99999; } diff --git a/common/edqs/pom.xml b/common/edqs/pom.xml index 769606efad..549ee1515f 100644 --- a/common/edqs/pom.xml +++ b/common/edqs/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard - 4.3.1.3-SNAPSHOT + 4.4.0-SNAPSHOT common org.thingsboard.common diff --git a/common/edqs/src/main/java/org/thingsboard/server/edqs/query/EdqsCountQuery.java b/common/edqs/src/main/java/org/thingsboard/server/edqs/query/EdqsCountQuery.java index 0d2ed67987..d338362aeb 100644 --- a/common/edqs/src/main/java/org/thingsboard/server/edqs/query/EdqsCountQuery.java +++ b/common/edqs/src/main/java/org/thingsboard/server/edqs/query/EdqsCountQuery.java @@ -16,6 +16,7 @@ package org.thingsboard.server.edqs.query; import lombok.Builder; +import org.thingsboard.server.common.data.query.ComplexOperation; import org.thingsboard.server.common.data.query.EntityFilter; import java.util.List; @@ -23,8 +24,8 @@ import java.util.List; public class EdqsCountQuery extends EdqsQuery { @Builder - EdqsCountQuery(EntityFilter entityFilter, boolean hasKeyFilters, List keyFilters) { - super(entityFilter, hasKeyFilters, keyFilters); + EdqsCountQuery(EntityFilter entityFilter, boolean hasKeyFilters, List keyFilters, ComplexOperation keyFiltersOperation) { + super(entityFilter, hasKeyFilters, keyFilters, keyFiltersOperation); } } diff --git a/common/edqs/src/main/java/org/thingsboard/server/edqs/query/EdqsDataQuery.java b/common/edqs/src/main/java/org/thingsboard/server/edqs/query/EdqsDataQuery.java index 72ccb68515..bd93eca2f1 100644 --- a/common/edqs/src/main/java/org/thingsboard/server/edqs/query/EdqsDataQuery.java +++ b/common/edqs/src/main/java/org/thingsboard/server/edqs/query/EdqsDataQuery.java @@ -19,6 +19,7 @@ import lombok.Builder; import lombok.EqualsAndHashCode; import lombok.Getter; import org.thingsboard.server.common.data.StringUtils; +import org.thingsboard.server.common.data.query.ComplexOperation; import org.thingsboard.server.common.data.query.EntityDataSortOrder; import org.thingsboard.server.common.data.query.EntityFilter; import org.thingsboard.server.common.data.query.EntityKeyType; @@ -41,10 +42,10 @@ public class EdqsDataQuery extends EdqsQuery { private final List latestValues; @Builder - public EdqsDataQuery(EntityFilter entityFilter, List keyFilters, + public EdqsDataQuery(EntityFilter entityFilter, List keyFilters, ComplexOperation keyFiltersOperation, int pageSize, int page, String textSearch, DataKey sortKey, EntityDataSortOrder.Direction sortDirection, List entityFields, List latestValues) { - super(entityFilter, CollectionsUtil.isNotEmpty(keyFilters), keyFilters); + super(entityFilter, CollectionsUtil.isNotEmpty(keyFilters), keyFilters, keyFiltersOperation); this.pageSize = pageSize; this.page = page; this.hasTextSearch = StringUtils.isNotBlank(textSearch); diff --git a/common/edqs/src/main/java/org/thingsboard/server/edqs/query/EdqsQuery.java b/common/edqs/src/main/java/org/thingsboard/server/edqs/query/EdqsQuery.java index 9cfc5d442c..8d29ec89b8 100644 --- a/common/edqs/src/main/java/org/thingsboard/server/edqs/query/EdqsQuery.java +++ b/common/edqs/src/main/java/org/thingsboard/server/edqs/query/EdqsQuery.java @@ -16,6 +16,7 @@ package org.thingsboard.server.edqs.query; import lombok.Data; +import org.thingsboard.server.common.data.query.ComplexOperation; import org.thingsboard.server.common.data.query.EntityFilter; import java.util.List; @@ -26,5 +27,6 @@ public abstract class EdqsQuery { private final EntityFilter entityFilter; private final boolean hasKeyFilters; private final List keyFilters; + private final ComplexOperation keyFiltersOperation; } diff --git a/common/edqs/src/main/java/org/thingsboard/server/edqs/util/RepositoryUtils.java b/common/edqs/src/main/java/org/thingsboard/server/edqs/util/RepositoryUtils.java index a0a030ba08..6da25c260a 100644 --- a/common/edqs/src/main/java/org/thingsboard/server/edqs/util/RepositoryUtils.java +++ b/common/edqs/src/main/java/org/thingsboard/server/edqs/util/RepositoryUtils.java @@ -22,6 +22,7 @@ import org.thingsboard.server.common.data.edqs.DataPoint; import org.thingsboard.server.common.data.permission.QueryContext; import org.thingsboard.server.common.data.query.BooleanFilterPredicate; import org.thingsboard.server.common.data.query.ComplexFilterPredicate; +import org.thingsboard.server.common.data.query.ComplexOperation; import org.thingsboard.server.common.data.query.EntityCountQuery; import org.thingsboard.server.common.data.query.EntityDataQuery; import org.thingsboard.server.common.data.query.EntityDataSortOrder; @@ -60,8 +61,8 @@ import java.util.stream.Stream; import static org.apache.commons.lang3.StringUtils.containsIgnoreCase; import static org.thingsboard.server.common.data.StringUtils.equalsAny; import static org.thingsboard.server.common.data.StringUtils.splitByCommaWithoutQuotes; -import static org.thingsboard.server.common.data.query.ComplexFilterPredicate.ComplexOperation.AND; -import static org.thingsboard.server.common.data.query.ComplexFilterPredicate.ComplexOperation.OR; +import static org.thingsboard.server.common.data.query.ComplexOperation.AND; +import static org.thingsboard.server.common.data.query.ComplexOperation.OR; @Slf4j public class RepositoryUtils { @@ -139,6 +140,7 @@ public class RepositoryUtils { } query.entityFilter(oldQuery.getEntityFilter()); query.keyFilters(toKeyFilters(oldQuery.getKeyFilters())); + query.keyFiltersOperation(oldQuery.getKeyFiltersOperationOrDefault()); query.entityFields(toNewKeys(oldQuery.getEntityFields())); query.latestValues(toNewKeys(oldQuery.getLatestValues())); return query.build(); @@ -149,6 +151,7 @@ public class RepositoryUtils { .entityFilter(oldQuery.getEntityFilter()) .hasKeyFilters(CollectionsUtil.isNotEmpty(oldQuery.getKeyFilters())) .keyFilters(toKeyFilters(oldQuery.getKeyFilters())) + .keyFiltersOperation(oldQuery.getKeyFiltersOperationOrDefault()) .build(); } @@ -196,38 +199,57 @@ public class RepositoryUtils { } public static boolean checkKeyFilters(EntityData entity, List keyFilters) { - for (EdqsFilter keyFilter : keyFilters) { - EntityKeyValueType valueType = keyFilter.valueType(); - if (valueType == null) { - valueType = switch (keyFilter.predicate().getType()) { - case STRING -> EntityKeyValueType.STRING; - case NUMERIC -> EntityKeyValueType.NUMERIC; - case BOOLEAN -> EntityKeyValueType.BOOLEAN; - default -> throw new IllegalStateException(); - }; - } - DataKey dataKey = keyFilter.key(); - DataPoint dp = entity.getDataPoint(dataKey, null); - boolean checkResult = switch (valueType) { - case STRING -> { - String str = dp != null ? dp.valueToString() : null; - yield (dataKey.type() == EntityKeyType.ENTITY_FIELD) ? (str == null || checkKeyFilter(str, keyFilter.predicate())) : - (str != null && checkKeyFilter(str, keyFilter.predicate())); - } - case BOOLEAN -> { - Boolean booleanValue = dp != null ? dp.getBool() : null; - yield booleanValue != null && checkKeyFilter(booleanValue, keyFilter.predicate()); + return checkKeyFilters(entity, keyFilters, ComplexOperation.AND); + } + + public static boolean checkKeyFilters(EntityData entity, List keyFilters, ComplexOperation operation) { + ComplexOperation op = operation != null ? operation : ComplexOperation.AND; + if (op == ComplexOperation.OR) { + for (EdqsFilter keyFilter : keyFilters) { + if (evaluateSingleFilter(entity, keyFilter)) { + return true; } - case DATE_TIME, NUMERIC -> { - Double doubleValue = dp != null ? dp.getDouble() : null; - yield doubleValue != null && checkKeyFilter(doubleValue, keyFilter.predicate()); + } + // Vacuously true when OR is called with an empty filter list. Unreachable via checkFilters + // (which guards on isHasKeyFilters()), but kept defensive for any future direct caller. + return keyFilters.isEmpty(); + } else { + for (EdqsFilter keyFilter : keyFilters) { + if (!evaluateSingleFilter(entity, keyFilter)) { + return false; } - }; - if (!checkResult) { - return false; } + return true; } - return true; + } + + private static boolean evaluateSingleFilter(EntityData entity, EdqsFilter keyFilter) { + EntityKeyValueType valueType = keyFilter.valueType(); + if (valueType == null) { + valueType = switch (keyFilter.predicate().getType()) { + case STRING -> EntityKeyValueType.STRING; + case NUMERIC -> EntityKeyValueType.NUMERIC; + case BOOLEAN -> EntityKeyValueType.BOOLEAN; + default -> throw new IllegalStateException(); + }; + } + DataKey dataKey = keyFilter.key(); + DataPoint dp = entity.getDataPoint(dataKey, null); + return switch (valueType) { + case STRING -> { + String str = dp != null ? dp.valueToString() : null; + yield (dataKey.type() == EntityKeyType.ENTITY_FIELD) ? (str == null || checkKeyFilter(str, keyFilter.predicate())) : + (str != null && checkKeyFilter(str, keyFilter.predicate())); + } + case BOOLEAN -> { + Boolean booleanValue = dp != null ? dp.getBool() : null; + yield booleanValue != null && checkKeyFilter(booleanValue, keyFilter.predicate()); + } + case DATE_TIME, NUMERIC -> { + Double doubleValue = dp != null ? dp.getDouble() : null; + yield doubleValue != null && checkKeyFilter(doubleValue, keyFilter.predicate()); + } + }; } public static boolean checkKeyFilter(String value, KeyFilterPredicate keyFilterPredicate) { @@ -376,7 +398,7 @@ public class RepositoryUtils { if (entity == null || entity.getFields() == null) { return false; // Entity was already removed or not arrived yet; } - if (query.isHasKeyFilters() && !checkKeyFilters(entity, query.getKeyFilters())) { + if (query.isHasKeyFilters() && !checkKeyFilters(entity, query.getKeyFilters(), query.getKeyFiltersOperation())) { return false; } if (query instanceof EdqsDataQuery dataQuery) { diff --git a/common/message/pom.xml b/common/message/pom.xml index ee6d296262..478368afdc 100644 --- a/common/message/pom.xml +++ b/common/message/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard - 4.3.1.3-SNAPSHOT + 4.4.0-SNAPSHOT common org.thingsboard.common diff --git a/common/pom.xml b/common/pom.xml index c8f3935110..d241646816 100644 --- a/common/pom.xml +++ b/common/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard - 4.3.1.3-SNAPSHOT + 4.4.0-SNAPSHOT thingsboard common diff --git a/common/proto/pom.xml b/common/proto/pom.xml index bee053364f..e24705c25a 100644 --- a/common/proto/pom.xml +++ b/common/proto/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard - 4.3.1.3-SNAPSHOT + 4.4.0-SNAPSHOT common org.thingsboard.common diff --git a/common/proto/src/main/proto/queue.proto b/common/proto/src/main/proto/queue.proto index 2e5a382185..aa00b1c1a8 100644 --- a/common/proto/src/main/proto/queue.proto +++ b/common/proto/src/main/proto/queue.proto @@ -470,6 +470,7 @@ message GetOrCreateDeviceFromGatewayRequestMsg { int64 gatewayIdLSB = 2; string deviceName = 3; string deviceType = 4; + bool isSparkplug = 5; } message GetOrCreateDeviceFromGatewayResponseMsg { diff --git a/common/queue/pom.xml b/common/queue/pom.xml index b24edd37d3..a434552363 100644 --- a/common/queue/pom.xml +++ b/common/queue/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard - 4.3.1.3-SNAPSHOT + 4.4.0-SNAPSHOT common org.thingsboard.common diff --git a/common/queue/src/main/java/org/thingsboard/server/queue/kafka/TbKafkaSettings.java b/common/queue/src/main/java/org/thingsboard/server/queue/kafka/TbKafkaSettings.java index fd9f469eff..d71657fcbb 100644 --- a/common/queue/src/main/java/org/thingsboard/server/queue/kafka/TbKafkaSettings.java +++ b/common/queue/src/main/java/org/thingsboard/server/queue/kafka/TbKafkaSettings.java @@ -23,6 +23,7 @@ import org.apache.kafka.clients.CommonClientConfigs; import org.apache.kafka.clients.admin.AdminClientConfig; import org.apache.kafka.clients.consumer.ConsumerConfig; import org.apache.kafka.clients.producer.ProducerConfig; +import org.apache.kafka.common.config.SaslConfigs; import org.apache.kafka.common.config.SslConfigs; import org.apache.kafka.common.serialization.ByteArrayDeserializer; import org.apache.kafka.common.serialization.ByteArraySerializer; @@ -32,6 +33,7 @@ import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Value; import org.springframework.boot.context.properties.ConfigurationProperties; import org.springframework.stereotype.Component; +import org.thingsboard.server.common.data.StringUtils; import org.thingsboard.server.common.data.TbProperty; import org.thingsboard.server.queue.util.PropertyUtils; import org.thingsboard.server.queue.util.TbKafkaComponent; @@ -137,6 +139,18 @@ public class TbKafkaSettings { @Value("${queue.kafka.confluent.security.protocol:}") private String securityProtocol; + @Value("${queue.kafka.confluent.oauth.client-id:}") + private String oauthClientId; + + @Value("${queue.kafka.confluent.oauth.client-secret:}") + private String oauthClientSecret; + + @Value("${queue.kafka.confluent.oauth.endpoint-url:}") + private String oauthEndpointUrl; + + @Value("${queue.kafka.confluent.oauth.scope:}") + private String oauthScope; + @Value("${queue.kafka.other-inline:}") private String otherInline; @@ -213,9 +227,13 @@ public class TbKafkaSettings { if (useConfluent) { props.put("ssl.endpoint.identification.algorithm", sslAlgorithm); - props.put("sasl.mechanism", saslMechanism); - props.put("sasl.jaas.config", saslConfig); + props.put(SaslConfigs.SASL_MECHANISM, saslMechanism); props.put(CommonClientConfigs.SECURITY_PROTOCOL_CONFIG, securityProtocol); + if ("OAUTHBEARER".equalsIgnoreCase(saslMechanism)) { + applyOauthBearerProps(props); + } else { + props.put(SaslConfigs.SASL_JAAS_CONFIG, saslConfig); + } } props.put(CommonClientConfigs.REQUEST_TIMEOUT_MS_CONFIG, requestTimeoutMs); @@ -230,6 +248,34 @@ public class TbKafkaSettings { return props; } + private void applyOauthBearerProps(Properties props) { + if (StringUtils.isBlank(oauthClientId) || StringUtils.isBlank(oauthClientSecret) || StringUtils.isBlank(oauthEndpointUrl)) { + throw new IllegalStateException("Kafka SASL mechanism is OAUTHBEARER but " + + "queue.kafka.confluent.oauth.client-id / client-secret / endpoint-url are not all set"); + } + if (!oauthEndpointUrl.regionMatches(true, 0, "https://", 0, "https://".length())) { + log.warn("Kafka OAuth token endpoint URL is not HTTPS ({}); client credentials will be sent unencrypted", + oauthEndpointUrl); + } + StringBuilder jaasConfig = new StringBuilder( + "org.apache.kafka.common.security.oauthbearer.OAuthBearerLoginModule required" + + " clientId=\"" + escapeJaasValue(oauthClientId) + "\"" + + " clientSecret=\"" + escapeJaasValue(oauthClientSecret) + "\""); + if (StringUtils.isNotBlank(oauthScope)) { + // Some IdPs (e.g. Azure AD's ".default") require a scope for the client-credentials grant. + jaasConfig.append(" scope=\"").append(escapeJaasValue(oauthScope)).append("\""); + } + jaasConfig.append(";"); + props.put(SaslConfigs.SASL_JAAS_CONFIG, jaasConfig.toString()); + props.put(SaslConfigs.SASL_LOGIN_CALLBACK_HANDLER_CLASS, + "org.apache.kafka.common.security.oauthbearer.OAuthBearerLoginCallbackHandler"); + props.put(SaslConfigs.SASL_OAUTHBEARER_TOKEN_ENDPOINT_URL, oauthEndpointUrl); + } + + private static String escapeJaasValue(String value) { + return value.replace("\\", "\\\\").replace("\"", "\\\""); + } + void configureSSL(Properties props) { if (sslEnabled) { props.put(CommonClientConfigs.SECURITY_PROTOCOL_CONFIG, "SSL"); diff --git a/common/queue/src/test/java/org/thingsboard/server/queue/kafka/TbKafkaSettingsTest.java b/common/queue/src/test/java/org/thingsboard/server/queue/kafka/TbKafkaSettingsTest.java index 0175c45906..3792d76366 100644 --- a/common/queue/src/test/java/org/thingsboard/server/queue/kafka/TbKafkaSettingsTest.java +++ b/common/queue/src/test/java/org/thingsboard/server/queue/kafka/TbKafkaSettingsTest.java @@ -15,9 +15,15 @@ */ package org.thingsboard.server.queue.kafka; +import ch.qos.logback.classic.Level; +import ch.qos.logback.classic.Logger; +import ch.qos.logback.classic.spi.ILoggingEvent; +import ch.qos.logback.core.read.ListAppender; import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; import org.mockito.Mockito; +import org.slf4j.LoggerFactory; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.context.SpringBootTest; import org.springframework.test.context.TestPropertySource; @@ -25,76 +31,278 @@ import org.springframework.test.context.TestPropertySource; import java.util.Properties; import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.spy; -@SpringBootTest(classes = {TbKafkaSettings.class, KafkaAdmin.class}) -@TestPropertySource(properties = { - "queue.type=kafka", - "queue.kafka.bootstrap.servers=localhost:9092", - "queue.kafka.other-inline=metrics.recording.level:INFO;metrics.sample.window.ms:30000", - "queue.kafka.consumer-properties-per-topic-inline=" + - "tb_core_updated:max.poll.records=10;" + - "tb_core_updated:enable.auto.commit=true;" + - "tb_core_updated:bootstrap.servers=kafka1:9092,kafka2:9092;" + - "tb_edge_updated:max.poll.records=5;" + - "tb_edge_updated:auto.offset.reset=latest" -}) class TbKafkaSettingsTest { - @Autowired - TbKafkaSettings settings; + @Nested + @SpringBootTest(classes = {TbKafkaSettings.class, KafkaAdmin.class}) + @TestPropertySource(properties = { + "queue.type=kafka", + "queue.kafka.bootstrap.servers=localhost:9092", + "queue.kafka.other-inline=metrics.recording.level:INFO;metrics.sample.window.ms:30000", + "queue.kafka.consumer-properties-per-topic-inline=" + + "tb_core_updated:max.poll.records=10;" + + "tb_core_updated:enable.auto.commit=true;" + + "tb_core_updated:bootstrap.servers=kafka1:9092,kafka2:9092;" + + "tb_edge_updated:max.poll.records=5;" + + "tb_edge_updated:auto.offset.reset=latest" + }) + class InlinePropertiesAndSsl { + + @Autowired + TbKafkaSettings settings; + + @BeforeEach + void beforeEach() { + settings = spy(settings); // SpyBean is not aware on @ConditionalOnProperty, that is why the traditional spy in use + } + + @Test + void givenToProps_whenConfigureSSL_thenVerifyOnce() { + Properties props = settings.toProps(); + + assertThat(props).as("TB_QUEUE_KAFKA_REQUEST_TIMEOUT_MS").containsEntry("request.timeout.ms", 30000); + + //other-inline + assertThat(props).as("metrics.recording.level").containsEntry("metrics.recording.level", "INFO"); + assertThat(props).as("TB_QUEUE_KAFKA_SESSION_TIMEOUT_MS").containsEntry("metrics.sample.window.ms", "30000"); + + Mockito.verify(settings).toProps(); + Mockito.verify(settings).configureSSL(any()); + } + + @Test + void givenToAdminProps_whenConfigureSSL_thenVerifyOnce() { + settings.toAdminProps(); + Mockito.verify(settings).toProps(); + Mockito.verify(settings).configureSSL(any()); + } + + @Test + void givenToConsumerProps_whenConfigureSSL_thenVerifyOnce() { + settings.toConsumerProps("main"); + Mockito.verify(settings).toProps(); + Mockito.verify(settings).configureSSL(any()); + } + + @Test + void givenTotoProducerProps_whenConfigureSSL_thenVerifyOnce() { + settings.toProducerProps(); + Mockito.verify(settings).toProps(); + Mockito.verify(settings).configureSSL(any()); + } + + @Test + void givenMultipleTopicsInInlineConfig_whenParsed_thenEachTopicGetsExpectedProperties() { + Properties coreProps = settings.toConsumerProps("tb_core_updated"); + assertThat(coreProps.getProperty("max.poll.records")).isEqualTo("10"); + assertThat(coreProps.getProperty("enable.auto.commit")).isEqualTo("true"); + assertThat(coreProps.getProperty("bootstrap.servers")).isEqualTo("kafka1:9092,kafka2:9092"); + + Properties edgeProps = settings.toConsumerProps("tb_edge_updated"); + assertThat(edgeProps.getProperty("max.poll.records")).isEqualTo("5"); + assertThat(edgeProps.getProperty("auto.offset.reset")).isEqualTo("latest"); + } - @BeforeEach - void beforeEach() { - settings = spy(settings); //SpyBean is not aware on @ConditionalOnProperty, that is why the traditional spy in use } - @Test - void givenToProps_whenConfigureSSL_thenVerifyOnce() { - Properties props = settings.toProps(); + @Nested + @SpringBootTest(classes = {TbKafkaSettings.class, KafkaAdmin.class}) + @TestPropertySource(properties = { + "queue.type=kafka", + "queue.kafka.bootstrap.servers=localhost:9092", + "queue.kafka.use_confluent_cloud=true", + "queue.kafka.confluent.sasl.mechanism=OAUTHBEARER", + "queue.kafka.confluent.security.protocol=SASL_SSL", + "queue.kafka.confluent.oauth.client-id=my-client", + "queue.kafka.confluent.oauth.client-secret=my-secret", + "queue.kafka.confluent.oauth.endpoint-url=https://idp.example.com/oauth/token" + }) + class ConfluentOAuth { - assertThat(props).as("TB_QUEUE_KAFKA_REQUEST_TIMEOUT_MS").containsEntry("request.timeout.ms", 30000); + @Autowired + TbKafkaSettings settings; - //other-inline - assertThat(props).as("metrics.recording.level").containsEntry("metrics.recording.level", "INFO"); - assertThat(props).as("TB_QUEUE_KAFKA_SESSION_TIMEOUT_MS").containsEntry("metrics.sample.window.ms", "30000"); + @Test + void givenOauthbearerMechanism_whenToProps_thenConfiguresNativeOidcHandler() { + Properties props = settings.toProps(); + + assertThat(props).containsEntry("sasl.mechanism", "OAUTHBEARER"); + assertThat(props).containsEntry("security.protocol", "SASL_SSL"); + assertThat(props).containsEntry("sasl.login.callback.handler.class", + "org.apache.kafka.common.security.oauthbearer.OAuthBearerLoginCallbackHandler"); + assertThat(props).containsEntry("sasl.oauthbearer.token.endpoint.url", + "https://idp.example.com/oauth/token"); + assertThat(props.getProperty("sasl.jaas.config")) + .contains("org.apache.kafka.common.security.oauthbearer.OAuthBearerLoginModule required") + .contains("clientId=\"my-client\"") + .contains("clientSecret=\"my-secret\""); + } - Mockito.verify(settings).toProps(); - Mockito.verify(settings).configureSSL(any()); } - @Test - void givenToAdminProps_whenConfigureSSL_thenVerifyOnce() { - settings.toAdminProps(); - Mockito.verify(settings).toProps(); - Mockito.verify(settings).configureSSL(any()); + @Nested + @SpringBootTest(classes = {TbKafkaSettings.class, KafkaAdmin.class}) + @TestPropertySource(properties = { + "queue.type=kafka", + "queue.kafka.bootstrap.servers=localhost:9092", + "queue.kafka.use_confluent_cloud=true", + "queue.kafka.confluent.sasl.mechanism=OAUTHBEARER", + "queue.kafka.confluent.security.protocol=SASL_SSL", + "queue.kafka.confluent.oauth.client-id=client\"with\\\\quote", + "queue.kafka.confluent.oauth.client-secret=secret\"with\\\\backslash;and-semi", + "queue.kafka.confluent.oauth.endpoint-url=https://idp.example.com/oauth/token" + }) + class ConfluentOAuthEscaping { + + @Autowired + TbKafkaSettings settings; + + // Locks in that double quotes and backslashes in client credentials are escaped + // so the JAAS config string parses correctly and cannot inject extra options. + @Test + void givenSecretWithQuoteAndBackslash_whenToProps_thenEscapesJaasValues() { + Properties props = settings.toProps(); + + String jaas = props.getProperty("sasl.jaas.config"); + assertThat(jaas) + .contains("clientId=\"client\\\"with\\\\quote\"") + .contains("clientSecret=\"secret\\\"with\\\\backslash;and-semi\"") + .endsWith("\";"); + } + } - @Test - void givenToConsumerProps_whenConfigureSSL_thenVerifyOnce() { - settings.toConsumerProps("main"); - Mockito.verify(settings).toProps(); - Mockito.verify(settings).configureSSL(any()); + @Nested + @SpringBootTest(classes = {TbKafkaSettings.class, KafkaAdmin.class}) + @TestPropertySource(properties = { + "queue.type=kafka", + "queue.kafka.bootstrap.servers=localhost:9092", + "queue.kafka.use_confluent_cloud=true", + "queue.kafka.confluent.sasl.mechanism=OAUTHBEARER", + "queue.kafka.confluent.security.protocol=SASL_PLAINTEXT", + "queue.kafka.confluent.oauth.client-id=my-client", + "queue.kafka.confluent.oauth.client-secret=my-secret", + "queue.kafka.confluent.oauth.endpoint-url=http://idp.example.com/oauth/token" + }) + class ConfluentOAuthHttpWarning { + + @Autowired + TbKafkaSettings settings; + + @Test + void givenHttpEndpointUrl_whenToProps_thenProducesValidJaasConfigAndLogsWarning() { + Logger logger = (Logger) LoggerFactory.getLogger(TbKafkaSettings.class); + ListAppender appender = new ListAppender<>(); + appender.start(); + logger.addAppender(appender); + try { + Properties props = settings.toProps(); + + assertThat(props.getProperty("sasl.jaas.config")) + .contains("org.apache.kafka.common.security.oauthbearer.OAuthBearerLoginModule required") + .contains("clientId=\"my-client\"") + .contains("clientSecret=\"my-secret\""); + assertThat(props).containsEntry("sasl.oauthbearer.token.endpoint.url", + "http://idp.example.com/oauth/token"); + + assertThat(appender.list) + .filteredOn(e -> e.getLevel() == Level.WARN) + .anySatisfy(e -> assertThat(e.getFormattedMessage()).contains("not HTTPS")); + } finally { + logger.detachAppender(appender); + } + } + + } + + @Nested + @SpringBootTest(classes = {TbKafkaSettings.class, KafkaAdmin.class}) + @TestPropertySource(properties = { + "queue.type=kafka", + "queue.kafka.bootstrap.servers=localhost:9092", + "queue.kafka.use_confluent_cloud=true", + "queue.kafka.confluent.sasl.mechanism=OAUTHBEARER", + "queue.kafka.confluent.security.protocol=SASL_SSL", + "queue.kafka.confluent.oauth.client-id=my-client", + "queue.kafka.confluent.oauth.client-secret=my-secret", + "queue.kafka.confluent.oauth.endpoint-url=https://idp.example.com/oauth/token", + "queue.kafka.confluent.oauth.scope=api://my-app/.default" + }) + class ConfluentOAuthScope { + + @Autowired + TbKafkaSettings settings; + + @Test + void givenOauthScope_whenToProps_thenAppendsScopeToJaasConfig() { + Properties props = settings.toProps(); + + assertThat(props.getProperty("sasl.jaas.config")) + .contains("org.apache.kafka.common.security.oauthbearer.OAuthBearerLoginModule required") + .contains("clientId=\"my-client\"") + .contains("clientSecret=\"my-secret\"") + .contains("scope=\"api://my-app/.default\"") + .endsWith("\";"); + } + } - @Test - void givenTotoProducerProps_whenConfigureSSL_thenVerifyOnce() { - settings.toProducerProps(); - Mockito.verify(settings).toProps(); - Mockito.verify(settings).configureSSL(any()); + @Nested + @SpringBootTest(classes = {TbKafkaSettings.class, KafkaAdmin.class}) + @TestPropertySource(properties = { + "queue.type=kafka", + "queue.kafka.bootstrap.servers=localhost:9092", + "queue.kafka.use_confluent_cloud=true", + "queue.kafka.confluent.sasl.mechanism=OAUTHBEARER", + "queue.kafka.confluent.security.protocol=SASL_SSL", + "queue.kafka.confluent.oauth.client-id=my-client", + "queue.kafka.confluent.oauth.client-secret=my-secret" + }) + class ConfluentOAuthMissingConfig { + + @Autowired + TbKafkaSettings settings; + + @Test + void givenOauthbearerWithoutEndpointUrl_whenToProps_thenFailsFast() { + assertThatThrownBy(() -> settings.toProps()) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("OAUTHBEARER") + .hasMessageContaining("endpoint-url"); + } + } - @Test - void givenMultipleTopicsInInlineConfig_whenParsed_thenEachTopicGetsExpectedProperties() { - Properties coreProps = settings.toConsumerProps("tb_core_updated"); - assertThat(coreProps.getProperty("max.poll.records")).isEqualTo("10"); - assertThat(coreProps.getProperty("enable.auto.commit")).isEqualTo("true"); - assertThat(coreProps.getProperty("bootstrap.servers")).isEqualTo("kafka1:9092,kafka2:9092"); + @Nested + @SpringBootTest(classes = {TbKafkaSettings.class, KafkaAdmin.class}) + @TestPropertySource(properties = { + "queue.type=kafka", + "queue.kafka.bootstrap.servers=localhost:9092", + "queue.kafka.use_confluent_cloud=true", + "queue.kafka.confluent.sasl.mechanism=PLAIN", + "queue.kafka.confluent.security.protocol=SASL_SSL", + "queue.kafka.confluent.sasl.config=org.apache.kafka.common.security.plain.PlainLoginModule required username=\"key\" password=\"secret\";" + }) + class ConfluentPlain { + + @Autowired + TbKafkaSettings settings; + + @Test + void givenPlainMechanism_whenToProps_thenUsesLegacySaslConfigAndNoOauthKeys() { + Properties props = settings.toProps(); + + assertThat(props).containsEntry("sasl.mechanism", "PLAIN"); + assertThat(props.getProperty("sasl.jaas.config")) + .isEqualTo("org.apache.kafka.common.security.plain.PlainLoginModule required username=\"key\" password=\"secret\";"); + assertThat(props).doesNotContainKey("sasl.login.callback.handler.class"); + assertThat(props).doesNotContainKey("sasl.oauthbearer.token.endpoint.url"); + } - Properties edgeProps = settings.toConsumerProps("tb_edge_updated"); - assertThat(edgeProps.getProperty("max.poll.records")).isEqualTo("5"); - assertThat(edgeProps.getProperty("auto.offset.reset")).isEqualTo("latest"); } } diff --git a/common/script/pom.xml b/common/script/pom.xml index db477115cb..a11d320416 100644 --- a/common/script/pom.xml +++ b/common/script/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard - 4.3.1.3-SNAPSHOT + 4.4.0-SNAPSHOT common org.thingsboard.common diff --git a/common/script/remote-js-client/pom.xml b/common/script/remote-js-client/pom.xml index 8add60dd90..202cc20bd3 100644 --- a/common/script/remote-js-client/pom.xml +++ b/common/script/remote-js-client/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard.common - 4.3.1.3-SNAPSHOT + 4.4.0-SNAPSHOT script org.thingsboard.common.script diff --git a/common/script/script-api/pom.xml b/common/script/script-api/pom.xml index 45adffcceb..b074db321a 100644 --- a/common/script/script-api/pom.xml +++ b/common/script/script-api/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard.common - 4.3.1.3-SNAPSHOT + 4.4.0-SNAPSHOT script org.thingsboard.common.script 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/stats/pom.xml b/common/stats/pom.xml index 28930308ce..da650623f9 100644 --- a/common/stats/pom.xml +++ b/common/stats/pom.xml @@ -22,7 +22,7 @@ 4.0.0 org.thingsboard - 4.3.1.3-SNAPSHOT + 4.4.0-SNAPSHOT common org.thingsboard.common diff --git a/common/transport/coap/pom.xml b/common/transport/coap/pom.xml index 0608a957ab..76ac33b450 100644 --- a/common/transport/coap/pom.xml +++ b/common/transport/coap/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard.common - 4.3.1.3-SNAPSHOT + 4.4.0-SNAPSHOT transport org.thingsboard.common.transport diff --git a/common/transport/http/pom.xml b/common/transport/http/pom.xml index 77949bb51e..275a497b6e 100644 --- a/common/transport/http/pom.xml +++ b/common/transport/http/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard.common - 4.3.1.3-SNAPSHOT + 4.4.0-SNAPSHOT transport org.thingsboard.common.transport diff --git a/common/transport/lwm2m/pom.xml b/common/transport/lwm2m/pom.xml index e71e0d9a9d..f47255ac7e 100644 --- a/common/transport/lwm2m/pom.xml +++ b/common/transport/lwm2m/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard.common - 4.3.1.3-SNAPSHOT + 4.4.0-SNAPSHOT transport org.thingsboard.common.transport diff --git a/common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/server/downlink/DefaultLwM2mDownlinkMsgHandler.java b/common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/server/downlink/DefaultLwM2mDownlinkMsgHandler.java index 8f638cfa2a..c5b8779a3f 100644 --- a/common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/server/downlink/DefaultLwM2mDownlinkMsgHandler.java +++ b/common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/server/downlink/DefaultLwM2mDownlinkMsgHandler.java @@ -50,6 +50,8 @@ import org.eclipse.leshan.core.request.SimpleDownlinkRequest; import org.eclipse.leshan.core.request.WriteAttributesRequest; import org.eclipse.leshan.core.request.WriteCompositeRequest; import org.eclipse.leshan.core.request.WriteRequest; +import org.eclipse.leshan.core.request.argument.Arguments; +import org.eclipse.leshan.core.request.argument.InvalidArgumentException; import org.eclipse.leshan.core.request.exception.ClientSleepingException; import org.eclipse.leshan.core.request.exception.InvalidRequestException; import org.eclipse.leshan.core.request.exception.TimeoutException; @@ -116,6 +118,7 @@ import static org.eclipse.leshan.core.model.ResourceModel.Type.OPAQUE; import static org.thingsboard.server.common.transport.util.JsonUtils.isBase64; import static org.thingsboard.server.transport.lwm2m.utils.LwM2MTransportUtil.convertMultiResourceValuesFromRpcBody; import static org.thingsboard.server.transport.lwm2m.utils.LwM2MTransportUtil.createModelsDefault; +import static org.thingsboard.server.transport.lwm2m.utils.LwM2MTransportUtil.equalsResourceTypeGetSimpleName; import static org.thingsboard.server.transport.lwm2m.utils.LwM2MTransportUtil.fromVersionedIdToObjectId; import static org.thingsboard.server.transport.lwm2m.utils.LwM2MTransportUtil.getVerFromPathIdVerOrId; import static org.thingsboard.server.transport.lwm2m.utils.LwM2MTransportUtil.validateVersionedId; @@ -268,29 +271,34 @@ public class DefaultLwM2mDownlinkMsgHandler extends LwM2MExecutorAwareService im validateVersionedId(client, request); LwM2mPath pathIds = new LwM2mPath(fromVersionedIdToObjectId(request.getVersionedId())); ResourceModel resourceModelExecute = client.getResourceModel(request.getVersionedId(), modelProvider); - if (resourceModelExecute == null) { - LwM2mModel model = createModelsDefault(); - if (pathIds.isResource()) { - resourceModelExecute = model.getResourceModel(pathIds.getObjectId(), pathIds.getResourceId()); - } + if (resourceModelExecute == null && pathIds.isResource()) { + resourceModelExecute = createModelsDefault().getResourceModel(pathIds.getObjectId(), pathIds.getResourceId()); } if (resourceModelExecute == null) { - callback.onValidationError(request.toString(), "ResourceModel with " + request.getVersionedId() + - " is absent in system. Need ddd Lwm2m Model with id=" + pathIds.getObjectId() + " ver=" + - getVerFromPathIdVerOrId(request.getVersionedId()) + " to profile."); - } else if (resourceModelExecute.operations.isExecutable()) { - ExecuteRequest downlink; - if (request.getParams() != null && !resourceModelExecute.multiple) { - downlink = new ExecuteRequest(request.getObjectId(), (String) this.converter.convertValue(request.getParams(), - resourceModelExecute.type, ResourceModel.Type.STRING, new LwM2mPath(request.getObjectId()))); - } else { - downlink = new ExecuteRequest(request.getObjectId()); + throw new InvalidArgumentException(String.format("ResourceModel with %s is absent in the system. Need to add Model with id= %s ver=%s to profile.", + request.getVersionedId(), pathIds.getObjectId(), getVerFromPathIdVerOrId(request.getVersionedId()))); + } + if (!resourceModelExecute.operations.isExecutable()) { + throw new InvalidArgumentException(String.format("Resource with %s is not executable.", request.getVersionedId())); + } + + ExecuteRequest downlink; + Object params = request.getParams(); + // 4. Handle parameters if they exist and the resource is not a multiple-instance resource + if (params != null && !resourceModelExecute.multiple) { + ResourceModel.Type resourceModelType = equalsResourceTypeGetSimpleName(params); + if (resourceModelType == null) { + throw new InvalidArgumentException(String.format("Unsupported parameter type: %s. Only simple types (String, Integer, Boolean, etc.) are allowed for Execute arguments.", + params.getClass().getSimpleName())); } - sendSimpleRequest(client, downlink, request.getTimeout(), callback); + String args = (String) this.converter.convertValue(params, resourceModelType, ResourceModel.Type.STRING, pathIds); + downlink = new ExecuteRequest(request.getObjectId(), args); } else { - callback.onValidationError(request.toString(), "Resource with " + request.getVersionedId() + " is not executable."); + downlink = new ExecuteRequest(request.getObjectId()); } - } catch (InvalidRequestException e) { + sendSimpleRequest(client, downlink, request.getTimeout(), callback); + } catch (Exception e) { + log.error("[{}] Validation failed for Execute request: {}", client.getEndpoint(), e.getMessage()); callback.onValidationError(request.toString(), e.getMessage()); } } diff --git a/common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/server/rpc/DefaultLwM2MRpcRequestHandler.java b/common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/server/rpc/DefaultLwM2MRpcRequestHandler.java index 6980fb6341..a0cbd4f598 100644 --- a/common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/server/rpc/DefaultLwM2MRpcRequestHandler.java +++ b/common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/server/rpc/DefaultLwM2MRpcRequestHandler.java @@ -241,7 +241,13 @@ public class DefaultLwM2MRpcRequestHandler implements LwM2MRpcRequestHandler { } private void sendExecuteRequest(LwM2mClient client, TransportProtos.ToDeviceRpcRequestMsg requestMsg, String versionedId) { - TbLwM2MExecuteRequest downlink = TbLwM2MExecuteRequest.builder().versionedId(versionedId).timeout(clientContext.getRequestTimeout(client)).build(); + RpcExecuteRequest requestBody = JacksonUtil.fromString(requestMsg.getParams(), RpcExecuteRequest.class); + Object value = requestBody != null ? requestBody.getValue() : null; + TbLwM2MExecuteRequest downlink = TbLwM2MExecuteRequest.builder() + .versionedId(versionedId) + .params(value) + .timeout(clientContext.getRequestTimeout(client)) + .build(); var mainCallback = new TbLwM2MExecuteCallback(logService, client, versionedId); var rpcCallback = new RpcEmptyResponseCallback<>(transportService, client, requestMsg, mainCallback); downlinkHandler.sendExecuteRequest(client, downlink, rpcCallback); diff --git a/common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/server/rpc/RpcExecuteRequest.java b/common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/server/rpc/RpcExecuteRequest.java new file mode 100644 index 0000000000..e0360c6f71 --- /dev/null +++ b/common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/server/rpc/RpcExecuteRequest.java @@ -0,0 +1,27 @@ +/** + * 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.rpc; + +import lombok.Data; +import lombok.EqualsAndHashCode; + +@Data +@EqualsAndHashCode(callSuper = true) +public class RpcExecuteRequest extends LwM2MRpcRequestHeader { + + private Object value; + +} diff --git a/common/transport/mqtt/pom.xml b/common/transport/mqtt/pom.xml index 2533ec2b0f..985c2a82f6 100644 --- a/common/transport/mqtt/pom.xml +++ b/common/transport/mqtt/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard.common - 4.3.1.3-SNAPSHOT + 4.4.0-SNAPSHOT transport org.thingsboard.common.transport diff --git a/common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/session/AbstractGatewaySessionHandler.java b/common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/session/AbstractGatewaySessionHandler.java index 8ea6480142..99f10eb176 100644 --- a/common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/session/AbstractGatewaySessionHandler.java +++ b/common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/session/AbstractGatewaySessionHandler.java @@ -68,6 +68,7 @@ import org.thingsboard.server.transport.mqtt.adaptors.MqttTransportAdaptor; import org.thingsboard.server.transport.mqtt.adaptors.ProtoMqttAdaptor; import org.thingsboard.server.transport.mqtt.gateway.GatewayMetricsService; import org.thingsboard.server.transport.mqtt.util.sparkplug.SparkplugConnectionState; +import org.thingsboard.server.transport.mqtt.util.sparkplug.SparkplugTopic; import java.util.ArrayList; import java.util.Collections; @@ -277,6 +278,10 @@ public abstract class AbstractGatewaySessionHandler onDeviceConnect(String deviceName, String deviceType) { + return onDeviceConnect(deviceName, deviceType, false); + } + + ListenableFuture onDeviceConnect(String deviceName, String deviceType, boolean isSparkplug) { T result = devices.get(deviceName); if (result == null) { Lock deviceCreationLock = deviceCreationLockMap.computeIfAbsent(deviceName, s -> new ReentrantLock()); @@ -284,7 +289,7 @@ public abstract class AbstractGatewaySessionHandler getDeviceCreationFuture(String deviceName, String deviceType) { + ListenableFuture onDeviceConnectSparkplug(SparkplugTopic topic, String deviceType) { + String fullPath = topic.getNodeDeviceNameAllPath(); + // Primary lookup: try to find the device by its full-path name (standard for new devices) + T result = devices.get(fullPath); + + if (result == null) { + // Secondary lookup (Legacy Fallback): check for the short name if full path is not found. + // This supports devices migrated from older versions. + String shortName = topic.getNodeDeviceName(); + result = devices.get(shortName); + } + + if (result != null) { + return Futures.immediateFuture(result); + } else { + // If not found in cache at all, proceed with connection/creation using full path + return onDeviceConnect(fullPath, deviceType, true); + } + } + + private ListenableFuture getDeviceCreationFuture(String deviceName, String deviceType, boolean isSparkplug) { final SettableFuture futureToSet = SettableFuture.create(); ListenableFuture future = deviceFutures.putIfAbsent(deviceName, futureToSet); if (future != null) { @@ -309,6 +334,7 @@ public abstract class AbstractGatewaySessionHandler() { @Override @@ -844,8 +870,16 @@ public abstract class AbstractGatewaySessionHandler contextListenableFuture; if (topic.isNode()) { if (topic.isType(NBIRTH)) { @@ -118,15 +115,19 @@ public class SparkplugNodeSessionHandler extends AbstractGatewaySessionHandler deviceCtx = onDeviceConnectProto(topic); + deviceName = checkDeviceName(topic.getNodeDeviceNameAllPath()); + ListenableFuture deviceCtx = this.onDeviceConnectProto(topic); + String finalDeviceName = deviceName; + contextListenableFuture = Futures.transform(deviceCtx, ctx -> { if (topic.isType(DBIRTH)) { - sendSparkplugStateOnTelemetry(ctx.getSessionInfo(), deviceName, ONLINE, + sendSparkplugStateOnTelemetry(ctx.getSessionInfo(), finalDeviceName, ONLINE, sparkplugBProto.getTimestamp()); try { ctx.setDeviceBirthMetrics(sparkplugBProto.getMetricsList()); } catch (IllegalArgumentException | DuplicateKeyException e) { - throw new RuntimeException(e); + log.error("[{}] Failed to set birth metrics", finalDeviceName, e); + throw new RuntimeException(e); } } return ctx; @@ -200,7 +201,7 @@ public class SparkplugNodeSessionHandler extends AbstractGatewaySessionHandler= 4 && splitTopic.length <= 5 && splitTopic[0].equals(this.sparkplugTopicNode.getNamespace()) && splitTopic[1].equals(this.sparkplugTopicNode.getGroupId()) && diff --git a/common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/util/sparkplug/SparkplugTopic.java b/common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/util/sparkplug/SparkplugTopic.java index 2988c903f7..9b0cb62ca6 100644 --- a/common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/util/sparkplug/SparkplugTopic.java +++ b/common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/util/sparkplug/SparkplugTopic.java @@ -21,8 +21,9 @@ import org.thingsboard.server.common.data.exception.ThingsboardErrorCode; import org.thingsboard.server.common.data.exception.ThingsboardException; import static org.thingsboard.server.transport.mqtt.util.sparkplug.SparkplugMessageType.parseMessageType; +import static org.thingsboard.server.transport.mqtt.util.sparkplug.SparkplugTopicService.DEVICE_NAME_SPLIT_SEPARATOR; import static org.thingsboard.server.transport.mqtt.util.sparkplug.SparkplugTopicService.TOPIC_ROOT_SPB_V_1_0; -import static org.thingsboard.server.transport.mqtt.util.sparkplug.SparkplugTopicService.TOPIC_SPLIT_REGEXP; +import static org.thingsboard.server.transport.mqtt.util.sparkplug.SparkplugTopicService.TOPIC_SPLIT_SEPARATOR; /** * Created by nickAS21 on 12.12.22 @@ -196,7 +197,7 @@ public class SparkplugTopic { try { if (isValidIdElementToUTF8(topicString)) { SparkplugMessageType messageType; - String[] splitTopic = topicString.split(TOPIC_SPLIT_REGEXP); + String[] splitTopic = topicString.split(TOPIC_SPLIT_SEPARATOR); if (TOPIC_ROOT_SPB_V_1_0.equals(splitTopic[0])) { if (splitTopic.length == 3) { messageType = parseMessageType(splitTopic[1]); @@ -329,6 +330,17 @@ public class SparkplugTopic { return isNode() ? edgeNodeId : deviceId; } + public String getNodeDeviceNameAllPath() { + StringBuilder sb = new StringBuilder(); + if (hostApplicationId == null) { + sb.append(getGroupId()).append(DEVICE_NAME_SPLIT_SEPARATOR).append(getEdgeNodeId()); + if (getDeviceId() != null) { + sb.append(DEVICE_NAME_SPLIT_SEPARATOR).append(getDeviceId()); + } + } + return sb.toString(); + } + public static boolean isValidIdElementToUTF8(String deviceIdElement) { if (deviceIdElement == null) { return false; diff --git a/common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/util/sparkplug/SparkplugTopicService.java b/common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/util/sparkplug/SparkplugTopicService.java index 74ea6858fe..377b759957 100644 --- a/common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/util/sparkplug/SparkplugTopicService.java +++ b/common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/util/sparkplug/SparkplugTopicService.java @@ -34,8 +34,9 @@ public class SparkplugTopicService { private static final Map SPLIT_TOPIC_CACHE = new HashMap<>(); public static final String TOPIC_ROOT_SPB_V_1_0 = "spBv1.0"; public static final String TOPIC_ROOT_CERT_SP = "$sparkplug/certificates/"; - public static final String TOPIC_SPLIT_REGEXP = "/"; - public static final String TOPIC_STATE_REGEXP = TOPIC_ROOT_SPB_V_1_0 + TOPIC_SPLIT_REGEXP + STATE.name() + TOPIC_SPLIT_REGEXP; + public static final String TOPIC_SPLIT_SEPARATOR = "/"; + public static final String DEVICE_NAME_SPLIT_SEPARATOR = ":"; + public static final String TOPIC_STATE_SEPARATOR = TOPIC_ROOT_SPB_V_1_0 + TOPIC_SPLIT_SEPARATOR + STATE.name() + TOPIC_SPLIT_SEPARATOR; public static SparkplugTopic getSplitTopic(String topic) throws ThingsboardException { SparkplugTopic sparkplugTopic = SPLIT_TOPIC_CACHE.get(topic); diff --git a/common/transport/pom.xml b/common/transport/pom.xml index 1629826eae..810b22670a 100644 --- a/common/transport/pom.xml +++ b/common/transport/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard - 4.3.1.3-SNAPSHOT + 4.4.0-SNAPSHOT common org.thingsboard.common diff --git a/common/transport/snmp/pom.xml b/common/transport/snmp/pom.xml index bb607f4b35..f98b2426dc 100644 --- a/common/transport/snmp/pom.xml +++ b/common/transport/snmp/pom.xml @@ -21,7 +21,7 @@ org.thingsboard.common - 4.3.1.3-SNAPSHOT + 4.4.0-SNAPSHOT transport diff --git a/common/transport/transport-api/pom.xml b/common/transport/transport-api/pom.xml index 6019b3b17c..73e0bdeec0 100644 --- a/common/transport/transport-api/pom.xml +++ b/common/transport/transport-api/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard.common - 4.3.1.3-SNAPSHOT + 4.4.0-SNAPSHOT transport org.thingsboard.common.transport 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 d74d73aa1d..65b24ef33a 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 @@ -507,7 +507,9 @@ public class DefaultTransportService extends TransportActivityManager implements @Override public void process(TransportProtos.SessionInfoProto sessionInfo, TransportProtos.SessionEventMsg msg, TransportServiceCallback callback) { if (checkLimits(sessionInfo, msg, callback)) { - recordActivityInternal(sessionInfo); + if (msg.getEvent() != TransportProtos.SessionEvent.CLOSED) { + recordActivityInternal(sessionInfo); + } sendToDeviceActor(sessionInfo, TransportToDeviceActorMsg.newBuilder().setSessionInfo(sessionInfo) .setSessionEvent(msg).build(), callback); } diff --git a/common/transport/transport-api/src/test/java/org/thingsboard/server/common/transport/service/TransportActivityManagerTest.java b/common/transport/transport-api/src/test/java/org/thingsboard/server/common/transport/service/TransportActivityManagerTest.java index 3b3ce6e600..a656b22241 100644 --- a/common/transport/transport-api/src/test/java/org/thingsboard/server/common/transport/service/TransportActivityManagerTest.java +++ b/common/transport/transport-api/src/test/java/org/thingsboard/server/common/transport/service/TransportActivityManagerTest.java @@ -26,12 +26,14 @@ import org.mockito.ArgumentCaptor; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; import org.springframework.test.util.ReflectionTestUtils; +import org.thingsboard.server.common.data.id.DeviceId; import org.thingsboard.server.common.transport.SessionMsgListener; import org.thingsboard.server.common.transport.TransportServiceCallback; import org.thingsboard.server.common.transport.activity.ActivityReportCallback; import org.thingsboard.server.common.transport.activity.ActivityState; import org.thingsboard.server.common.transport.activity.strategy.ActivityStrategy; import org.thingsboard.server.common.transport.activity.strategy.ActivityStrategyType; +import org.thingsboard.server.common.transport.limits.TransportRateLimitService; import org.thingsboard.server.gen.transport.TransportProtos; import java.util.UUID; @@ -41,7 +43,12 @@ import java.util.stream.Stream; import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyBoolean; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.ArgumentMatchers.anyLong; import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.ArgumentMatchers.isNull; +import static org.mockito.ArgumentMatchers.nullable; import static org.mockito.Mockito.doCallRealMethod; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.never; @@ -514,4 +521,55 @@ public class TransportActivityManagerTest { verify(transportServiceMock, never()).process(sessionInfo, SESSION_EVENT_MSG_CLOSED, null); } + @Test + void givenSessionClosedEvent_whenProcessingSessionEvent_thenShouldNotRecordActivity() { + // GIVEN - simulates the session-expiry path: session already removed from sessions map, + // then process(SESSION_CLOSED) is called. Activity must NOT be recorded to avoid active + // status update from false to true + var rateLimitServiceMock = mock(TransportRateLimitService.class); + ReflectionTestUtils.setField(transportServiceMock, "rateLimitService", rateLimitServiceMock); + when(rateLimitServiceMock.checkLimits(any(), nullable(DeviceId.class), any(), anyInt(), anyBoolean())).thenReturn(null); + + TransportProtos.SessionInfoProto sessionInfo = TransportProtos.SessionInfoProto.newBuilder() + .setSessionIdMSB(SESSION_ID.getMostSignificantBits()) + .setSessionIdLSB(SESSION_ID.getLeastSignificantBits()) + .build(); + doCallRealMethod().when(transportServiceMock).process(sessionInfo, SESSION_EVENT_MSG_CLOSED, null); + + // WHEN + transportServiceMock.process(sessionInfo, SESSION_EVENT_MSG_CLOSED, null); + + // THEN + verify(transportServiceMock, never()).onActivity(any(), any(), anyLong()); + verify(transportServiceMock).sendToDeviceActor(eq(sessionInfo), any(), isNull()); + } + + @Test + void givenSessionOpenEvent_whenProcessingSessionEvent_thenShouldRecordActivity() { + // GIVEN + var rateLimitServiceMock = mock(TransportRateLimitService.class); + ReflectionTestUtils.setField(transportServiceMock, "rateLimitService", rateLimitServiceMock); + when(rateLimitServiceMock.checkLimits(any(), nullable(DeviceId.class), any(), anyInt(), anyBoolean())).thenReturn(null); + + TransportProtos.SessionInfoProto sessionInfo = TransportProtos.SessionInfoProto.newBuilder() + .setSessionIdMSB(SESSION_ID.getMostSignificantBits()) + .setSessionIdLSB(SESSION_ID.getLeastSignificantBits()) + .build(); + var sessionOpenMsg = TransportProtos.SessionEventMsg.newBuilder() + .setSessionType(TransportProtos.SessionType.ASYNC) + .setEvent(TransportProtos.SessionEvent.OPEN) + .build(); + when(transportServiceMock.toSessionId(sessionInfo)).thenReturn(SESSION_ID); + long expectedActivityTime = 100L; + when(transportServiceMock.getCurrentTimeMillis()).thenReturn(expectedActivityTime); + doCallRealMethod().when(transportServiceMock).process(sessionInfo, sessionOpenMsg, null); + + // WHEN + transportServiceMock.process(sessionInfo, sessionOpenMsg, null); + + // THEN + verify(transportServiceMock).onActivity(SESSION_ID, sessionInfo, expectedActivityTime); + verify(transportServiceMock).sendToDeviceActor(eq(sessionInfo), any(), isNull()); + } + } diff --git a/common/util/pom.xml b/common/util/pom.xml index ae6dd29420..f3077d49c3 100644 --- a/common/util/pom.xml +++ b/common/util/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard - 4.3.1.3-SNAPSHOT + 4.4.0-SNAPSHOT common org.thingsboard.common 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/version-control/pom.xml b/common/version-control/pom.xml index d955a57537..d9e134128d 100644 --- a/common/version-control/pom.xml +++ b/common/version-control/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard - 4.3.1.3-SNAPSHOT + 4.4.0-SNAPSHOT common org.thingsboard.common diff --git a/dao/pom.xml b/dao/pom.xml index ae9f1a5998..d526eeed4f 100644 --- a/dao/pom.xml +++ b/dao/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard - 4.3.1.3-SNAPSHOT + 4.4.0-SNAPSHOT thingsboard dao diff --git a/dao/src/main/java/org/thingsboard/server/dao/entity/BaseEntityService.java b/dao/src/main/java/org/thingsboard/server/dao/entity/BaseEntityService.java index b20535dbf4..be49e64d10 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/entity/BaseEntityService.java +++ b/dao/src/main/java/org/thingsboard/server/dao/entity/BaseEntityService.java @@ -21,6 +21,7 @@ import com.google.common.util.concurrent.ListenableFuture; import com.google.common.util.concurrent.MoreExecutors; 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.Service; import org.springframework.util.CollectionUtils; @@ -40,6 +41,7 @@ import org.thingsboard.server.common.data.id.HasId; import org.thingsboard.server.common.data.id.NameLabelAndCustomerDetails; import org.thingsboard.server.common.data.id.TenantId; import org.thingsboard.server.common.data.page.PageData; +import org.thingsboard.server.common.data.query.ComplexOperation; import org.thingsboard.server.common.data.query.EntityCountQuery; import org.thingsboard.server.common.data.query.EntityData; import org.thingsboard.server.common.data.query.EntityDataPageLink; @@ -91,6 +93,9 @@ public class BaseEntityService extends AbstractEntityService implements EntitySe private static final Set EXCLUDED_TYPES_FROM_OPTIMIZATION = Set.of( EntityFilterType.ENTITY_LIST, EntityFilterType.SINGLE_ENTITY, EntityFilterType.RELATIONS_QUERY); + @Value("${sql.query.key-filters-or-conditions.enabled:true}") + private boolean keyFiltersOrConditionsEnabled; + @Autowired private EntityQueryDao entityQueryDao; @@ -328,7 +333,7 @@ public class BaseEntityService extends AbstractEntityService implements EntitySe return new NameLabelAndCustomerDetails(getName(entity), getLabel(entity), getCustomerId(entity)); } - private static void validateEntityCountQuery(EntityCountQuery query) { + private void validateEntityCountQuery(EntityCountQuery query) { if (query == null) { throw new IncorrectParameterException("Query must be specified."); } else if (query.getEntityFilter() == null) { @@ -342,9 +347,14 @@ public class BaseEntityService extends AbstractEntityService implements EntitySe } else if (query.getEntityFilter().getType().equals(ENTITY_NAME)) { validateEntityNameQuery((EntityNameFilter) query.getEntityFilter()); } + // Intentionally using the nullable getKeyFiltersOperation() (not getKeyFiltersOperationOrDefault()): + // a null value encodes "classic AND" and must pass this guard even when the OR feature flag is off. + if (!keyFiltersOrConditionsEnabled && query.getKeyFiltersOperation() == ComplexOperation.OR) { + throw new IncorrectParameterException("OR conditions between key filters are disabled by the system administrator."); + } } - private static void validateEntityDataQuery(EntityDataQuery query) { + private void validateEntityDataQuery(EntityDataQuery query) { validateEntityCountQuery(query); validateEntityDataPageLink(query.getPageLink()); } @@ -409,7 +419,7 @@ public class BaseEntityService extends AbstractEntityService implements EntitySe .collect(Collectors.toList()); } } - EntityDataQuery entityQuery = new EntityDataQuery(query.getEntityFilter(), query.getPageLink(), entityFields, latestValues, query.getKeyFilters()); + EntityDataQuery entityQuery = new EntityDataQuery(query.getEntityFilter(), query.getPageLink(), entityFields, latestValues, query.getKeyFilters(), query.getKeyFiltersOperation()); return this.entityQueryDao.findEntityDataByQuery(tenantId, customerId, entityQuery); } diff --git a/dao/src/main/java/org/thingsboard/server/dao/model/ModelConstants.java b/dao/src/main/java/org/thingsboard/server/dao/model/ModelConstants.java index 64ab95ced6..ed34a2e64c 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. 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/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/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/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/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/query/DefaultEntityQueryRepository.java b/dao/src/main/java/org/thingsboard/server/dao/sql/query/DefaultEntityQueryRepository.java index 59964d1949..28a144459d 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/sql/query/DefaultEntityQueryRepository.java +++ b/dao/src/main/java/org/thingsboard/server/dao/sql/query/DefaultEntityQueryRepository.java @@ -31,6 +31,7 @@ import org.thingsboard.server.common.data.page.PageData; import org.thingsboard.server.common.data.permission.QueryContext; import org.thingsboard.server.common.data.query.ApiUsageStateFilter; import org.thingsboard.server.common.data.query.AssetSearchQueryFilter; +import org.thingsboard.server.common.data.query.ComplexOperation; import org.thingsboard.server.common.data.query.AssetTypeFilter; import org.thingsboard.server.common.data.query.DeviceSearchQueryFilter; import org.thingsboard.server.common.data.query.DeviceTypeFilter; @@ -55,6 +56,7 @@ import org.thingsboard.server.common.data.query.SingleEntityFilter; import org.thingsboard.server.common.data.relation.EntitySearchDirection; import org.thingsboard.server.common.data.relation.RelationEntityTypeFilter; +import java.util.ArrayList; import java.util.Arrays; import java.util.Collections; import java.util.HashMap; @@ -371,25 +373,56 @@ public class DefaultEntityQueryRepository implements EntityQueryRepository { } }); } else { - List mappings = EntityKeyMapping.prepareEntityCountKeyMapping(query); + ComplexOperation operation = query.getKeyFiltersOperationOrDefault(); + boolean isOr = operation == ComplexOperation.OR; - List selectionMapping = mappings.stream().filter(EntityKeyMapping::isSelection) - .collect(Collectors.toList()); - List entityFieldsSelectionMapping = selectionMapping.stream().filter(mapping -> !mapping.isLatest()) - .collect(Collectors.toList()); + List mappings = EntityKeyMapping.prepareEntityCountKeyMapping(query); + List selectionMapping = new ArrayList<>(mappings.stream().filter(EntityKeyMapping::isSelection) + .collect(Collectors.toList())); List filterMapping = mappings.stream().filter(EntityKeyMapping::hasFilter) .collect(Collectors.toList()); List entityFieldsFiltersMapping = filterMapping.stream().filter(mapping -> !mapping.isLatest() && mapping.getEntityKeyColumn() != null) .collect(Collectors.toList()); + // Under OR: entity field filter columns must be in inner SELECT for outer WHERE reference. + // Mirror the ignore=true fix from findEntityDataByQuery so the inner subquery still emits the + // extra column but downstream response shape (benign for count) stays symmetric with the data path. + if (isOr) { + for (EntityKeyMapping m : entityFieldsFiltersMapping) { + if (!selectionMapping.contains(m)) { + m.setIgnore(true); + selectionMapping.add(m); + } + } + } + + List entityFieldsSelectionMapping = selectionMapping.stream().filter(mapping -> !mapping.isLatest()) + .collect(Collectors.toList()); + List allLatestMappings = mappings.stream().filter(EntityKeyMapping::isLatest) .collect(Collectors.toList()); + // Under OR: entity field filters move to outer WHERE (not inner WHERE) + List innerEntityFieldsFilters = isOr ? Collections.emptyList() : entityFieldsFiltersMapping; + + String entityWhereClause = DefaultEntityQueryRepository.this.buildEntityWhere(ctx, query.getEntityFilter(), innerEntityFieldsFilters); + String latestJoinsCnt = EntityKeyMapping.buildLatestJoins(ctx, query.getEntityFilter(), entityType, allLatestMappings, true, isOr); + + // Under OR: combine ALL filter mappings (entity fields + aliases) with OR joiner + // Place at the middle layer (after JOINs) where alias table references are visible. + // Under AND: place at the outer result layer (existing behavior). + String aliasWhereQuery; + if (isOr) { + String combinedFilterQuery = EntityKeyMapping.buildQuery(ctx, filterMapping, query.getEntityFilter().getType(), ComplexOperation.OR); + if (!combinedFilterQuery.isEmpty()) { + latestJoinsCnt += " where (" + combinedFilterQuery + ")"; + } + aliasWhereQuery = ""; + } else { + aliasWhereQuery = DefaultEntityQueryRepository.this.buildAliasWhereQuery(ctx, query.getEntityFilter(), selectionMapping, ""); + } - String entityWhereClause = DefaultEntityQueryRepository.this.buildEntityWhere(ctx, query.getEntityFilter(), entityFieldsFiltersMapping); - String aliasWhereQuery = DefaultEntityQueryRepository.this.buildAliasWhereQuery(ctx, query.getEntityFilter(), selectionMapping, ""); - String latestJoinsCnt = EntityKeyMapping.buildLatestJoins(ctx, query.getEntityFilter(), entityType, allLatestMappings, true); String entityFieldsSelection = EntityKeyMapping.buildSelections(entityFieldsSelectionMapping, query.getEntityFilter().getType(), entityType); String entityTypeStr; if (query.getEntityFilter().getType().equals(EntityFilterType.RELATIONS_QUERY)) { @@ -439,13 +472,13 @@ public class DefaultEntityQueryRepository implements EntityQueryRepository { EntityType entityType = resolveEntityType(query.getEntityFilter()); SqlQueryContext ctx = new SqlQueryContext(new QueryContext(tenantId, customerId, entityType, ignorePermissionCheck)); EntityDataPageLink pageLink = query.getPageLink(); + ComplexOperation operation = query.getKeyFiltersOperationOrDefault(); + boolean isOr = operation == ComplexOperation.OR; List mappings = EntityKeyMapping.prepareKeyMapping(entityType, query); - List selectionMapping = mappings.stream().filter(EntityKeyMapping::isSelection) - .collect(Collectors.toList()); - List entityFieldsSelectionMapping = selectionMapping.stream().filter(mapping -> !mapping.isLatest()) - .collect(Collectors.toList()); + List selectionMapping = new ArrayList<>(mappings.stream().filter(EntityKeyMapping::isSelection) + .collect(Collectors.toList())); List latestSelectionMapping = selectionMapping.stream().filter(EntityKeyMapping::isLatest) .collect(Collectors.toList()); @@ -454,14 +487,52 @@ public class DefaultEntityQueryRepository implements EntityQueryRepository { List entityFieldsFiltersMapping = filterMapping.stream().filter(mapping -> !mapping.isLatest() && mapping.getEntityKeyColumn() != null) .collect(Collectors.toList()); + // Under OR: entity field filter columns must be in inner SELECT for outer WHERE reference. + // Mark force-added filter-only mappings as ignored so EntityDataAdapter does not expose + // them in EntityData.latest — keeps the response shape identical to AND. + if (isOr) { + for (EntityKeyMapping m : entityFieldsFiltersMapping) { + if (!selectionMapping.contains(m)) { + m.setIgnore(true); + selectionMapping.add(m); + } + } + } + + List entityFieldsSelectionMapping = selectionMapping.stream().filter(mapping -> !mapping.isLatest()) + .collect(Collectors.toList()); + List allLatestMappings = mappings.stream().filter(EntityKeyMapping::isLatest) .collect(Collectors.toList()); + // Under OR: entity field filters move to outer WHERE (not inner WHERE) + List innerEntityFieldsFilters = isOr ? Collections.emptyList() : entityFieldsFiltersMapping; + + String entityWhereClause = DefaultEntityQueryRepository.this.buildEntityWhere(ctx, query.getEntityFilter(), innerEntityFieldsFilters); + String latestJoinsCnt = EntityKeyMapping.buildLatestJoins(ctx, query.getEntityFilter(), entityType, allLatestMappings, true, isOr); + String latestJoinsData = EntityKeyMapping.buildLatestJoins(ctx, query.getEntityFilter(), entityType, allLatestMappings, false, isOr); + + // Under OR: combine ALL filter mappings (entity fields + aliases) with OR joiner + // Place at the middle layer (after JOINs) where alias table references are visible. + // Under AND: place at the outer result layer (existing behavior). + String aliasWhereQuery; + if (isOr) { + String combinedFilterQuery = EntityKeyMapping.buildQuery(ctx, filterMapping, query.getEntityFilter().getType(), ComplexOperation.OR); + String middleWhere = ""; + if (!combinedFilterQuery.isEmpty()) { + middleWhere = " where (" + combinedFilterQuery + ")"; + } + String searchTextQuery = buildTextSearchQuery(ctx, selectionMapping, pageLink.getTextSearch()); + if (!searchTextQuery.isEmpty()) { + middleWhere += (middleWhere.isEmpty() ? " where " : " and ") + "(" + searchTextQuery + ") "; + } + latestJoinsCnt += middleWhere; + latestJoinsData += middleWhere; + aliasWhereQuery = ""; + } else { + aliasWhereQuery = DefaultEntityQueryRepository.this.buildAliasWhereQuery(ctx, query.getEntityFilter(), selectionMapping, pageLink.getTextSearch()); + } - String entityWhereClause = DefaultEntityQueryRepository.this.buildEntityWhere(ctx, query.getEntityFilter(), entityFieldsFiltersMapping); - String latestJoinsCnt = EntityKeyMapping.buildLatestJoins(ctx, query.getEntityFilter(), entityType, allLatestMappings, true); - String latestJoinsData = EntityKeyMapping.buildLatestJoins(ctx, query.getEntityFilter(), entityType, allLatestMappings, false); - String aliasWhereQuery = DefaultEntityQueryRepository.this.buildAliasWhereQuery(ctx, query.getEntityFilter(), selectionMapping, pageLink.getTextSearch()); String entityFieldsSelection = EntityKeyMapping.buildSelections(entityFieldsSelectionMapping, query.getEntityFilter().getType(), entityType); String entityTypeStr; if (query.getEntityFilter().getType().equals(EntityFilterType.RELATIONS_QUERY)) { diff --git a/dao/src/main/java/org/thingsboard/server/dao/sql/query/EntityKeyMapping.java b/dao/src/main/java/org/thingsboard/server/dao/sql/query/EntityKeyMapping.java index 51c926263f..7fe002e156 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/sql/query/EntityKeyMapping.java +++ b/dao/src/main/java/org/thingsboard/server/dao/sql/query/EntityKeyMapping.java @@ -21,6 +21,7 @@ import org.thingsboard.server.common.data.EntityType; import org.thingsboard.server.common.data.StringUtils; import org.thingsboard.server.common.data.query.BooleanFilterPredicate; import org.thingsboard.server.common.data.query.ComplexFilterPredicate; +import org.thingsboard.server.common.data.query.ComplexOperation; import org.thingsboard.server.common.data.query.EntityCountQuery; import org.thingsboard.server.common.data.query.EntityDataQuery; import org.thingsboard.server.common.data.query.EntityDataSortOrder; @@ -283,16 +284,39 @@ public class EntityKeyMapping { } public Stream toQueries(SqlQueryContext ctx, EntityFilterType filterType) { - if (hasFilter()) { - String keyAlias = (entityKey.getType().equals(EntityKeyType.ENTITY_FIELD) && getEntityKeyColumn() != null) ? "e" : alias; - return keyFilters.stream().map(keyFilter -> - this.buildKeyQuery(ctx, keyAlias, keyFilter, filterType)); - } else { + return toQueries(ctx, filterType, false); + } + + public Stream toQueries(SqlQueryContext ctx, EntityFilterType filterType, boolean outerContext) { + if (!hasFilter()) { return Stream.empty(); } + String keyAlias; + boolean useAliasDirectly = false; + if (entityKey.getType().equals(EntityKeyType.ENTITY_FIELD) && getEntityKeyColumn() != null) { + if (outerContext) { + // In the middle layer (OR relocation), entity field columns are exposed + // by their alias name from the inner subquery SELECT (e.g., "alias2" from + // "cast(e.name as varchar) as alias2"), so buildSimplePredicateQuery must + // use the alias directly as the field instead of appending entityKeyColumn. + keyAlias = alias; + useAliasDirectly = true; + } else { + keyAlias = "e"; + } + } else { + keyAlias = alias; + } + final boolean aliasAsField = useAliasDirectly; + return keyFilters.stream().map(keyFilter -> + this.buildKeyQuery(ctx, keyAlias, keyFilter, filterType, aliasAsField)); } public String toLatestJoin(SqlQueryContext ctx, EntityFilter entityFilter, EntityType entityType) { + return toLatestJoin(ctx, entityFilter, entityType, false); + } + + public String toLatestJoin(SqlQueryContext ctx, EntityFilter entityFilter, EntityType entityType, boolean forceLeftJoin) { String entityTypeStr; if (entityFilter.getType().equals(EntityFilterType.RELATIONS_QUERY)) { entityTypeStr = "entities.entity_type"; @@ -300,20 +324,23 @@ public class EntityKeyMapping { entityTypeStr = "'" + entityType.name() + "'"; } ctx.addStringParameter(getKeyId(), entityKey.getKey()); - String filterQuery = toQueries(ctx, entityFilter.getType()) + // Under OR (forceLeftJoin=true) the filter predicate is re-emitted by buildQuery at the middle + // layer with disjunction semantics. Inlining it in the ON clause here would duplicate the + // predicate with a different bound parameter name and double-filter the LEFT JOIN. + String filterQuery = forceLeftJoin ? "" : toQueries(ctx, entityFilter.getType()) .filter(StringUtils::isNotEmpty) .collect(Collectors.joining(" and ")); if (StringUtils.isNotEmpty(filterQuery)) { filterQuery = " AND (" + filterQuery + ")"; } if (entityKey.getType().equals(EntityKeyType.TIME_SERIES)) { - String join = (hasFilter() && hasFilterValues(ctx)) ? "inner join" : "left join"; + String join = (!forceLeftJoin && hasFilter() && hasFilterValues(ctx)) ? "inner join" : "left join"; return String.format("%s ts_kv_latest %s ON %s.entity_id=entities.id AND %s.key = (select key_id from key_dictionary where key = :%s_key_id) %s", join, alias, alias, alias, alias, filterQuery); } else { String query; if (!entityKey.getType().equals(EntityKeyType.ATTRIBUTE)) { - String join = (hasFilter() && hasFilterValues(ctx)) ? "inner join" : "left join"; + String join = (!forceLeftJoin && hasFilter() && hasFilterValues(ctx)) ? "inner join" : "left join"; query = String.format("%s attribute_kv %s ON %s.entity_id=entities.id AND %s.attribute_key=(select key_id from key_dictionary where key = :%s_key_id) ", join, alias, alias, alias, alias); int scope; @@ -326,7 +353,7 @@ public class EntityKeyMapping { } query = String.format("%s AND %s.attribute_type=%s %s", query, alias, scope, filterQuery); } else { - String join = (hasFilter() && hasFilterValues(ctx)) ? "join LATERAL" : "left join LATERAL"; + String join = (!forceLeftJoin && hasFilter() && hasFilterValues(ctx)) ? "join LATERAL" : "left join LATERAL"; query = String.format("%s (select * from attribute_kv %s WHERE %s.entity_id=entities.id AND %s.attribute_key=(select key_id from key_dictionary where key = :%s_key_id) %s " + "ORDER BY %s.last_update_ts DESC limit 1) as %s ON true", join, alias, alias, alias, alias, filterQuery, alias, alias); @@ -351,27 +378,47 @@ public class EntityKeyMapping { } public static String buildLatestJoins(SqlQueryContext ctx, EntityFilter entityFilter, EntityType entityType, List latestMappings, boolean countQuery) { + return buildLatestJoins(ctx, entityFilter, entityType, latestMappings, countQuery, false); + } + + public static String buildLatestJoins(SqlQueryContext ctx, EntityFilter entityFilter, EntityType entityType, + List latestMappings, boolean countQuery, boolean forceLeftJoin) { return latestMappings.stream() .filter(mapping -> !countQuery || mapping.hasFilter()) - .map(mapping -> mapping.toLatestJoin(ctx, entityFilter, entityType)) + .map(mapping -> mapping.toLatestJoin(ctx, entityFilter, entityType, forceLeftJoin)) .collect(Collectors.joining(" ")); } public static String buildQuery(SqlQueryContext ctx, List mappings, EntityFilterType filterType) { + return buildQuery(ctx, mappings, filterType, ComplexOperation.AND); + } + + public static String buildQuery(SqlQueryContext ctx, List mappings, + EntityFilterType filterType, ComplexOperation operation) { + String joiner = (operation == ComplexOperation.OR) ? " OR " : " AND "; + // Vacuously-true predicates (e.g. a ComplexFilterPredicate with zero nested predicates producing + // an empty string) are dropped here. Under AND this is safe — TRUE is the identity for AND — but + // under OR it silently narrows the disjunction. Callers must not emit meaningful empty predicates. return mappings.stream() - .flatMap(mapping -> mapping.toQueries(ctx, filterType)) + .flatMap(mapping -> mapping.toQueries(ctx, filterType, operation == ComplexOperation.OR)) .filter(StringUtils::isNotEmpty) - .collect(Collectors.joining(" AND ")); + .collect(Collectors.joining(joiner)); } public static List prepareKeyMapping(EntityType entityType, EntityDataQuery query) { EntityFilterType entityFilterType = query.getEntityFilter().getType(); + ComplexOperation operation = query.getKeyFiltersOperationOrDefault(); List entityFields = query.getEntityFields() != null ? query.getEntityFields() : Collections.emptyList(); List latestValues = query.getLatestValues() != null ? query.getLatestValues() : Collections.emptyList(); - Map> filters = - query.getKeyFilters() != null ? - query.getKeyFilters().stream().collect(Collectors.groupingBy(KeyFilter::getKey)) : Collections.emptyMap(); + Map> filters; + if (operation == ComplexOperation.OR) { + // Under OR, don't group same-key filters; handle individually below + filters = Collections.emptyMap(); + } else { + filters = query.getKeyFilters() != null ? + query.getKeyFilters().stream().collect(Collectors.groupingBy(KeyFilter::getKey)) : Collections.emptyMap(); + } EntityDataSortOrder sortOrder = query.getPageLink().getSortOrder(); EntityKey sortOrderKey = sortOrder != null ? sortOrder.getKey() : null; int index = 2; @@ -428,14 +475,37 @@ public class EntityKeyMapping { for (EntityKeyMapping mapping : mappings) { mapping.setIndex(index); mapping.setAlias(String.format("alias%s", index)); - mapping.setKeyFilters(filters.remove(mapping.entityKey)); + if (operation != ComplexOperation.OR) { + // Under AND: assign grouped filters to matching selection mappings + mapping.setKeyFilters(filters.remove(mapping.entityKey)); + } if (mapping.getEntityKey().getType().equals(EntityKeyType.ENTITY_FIELD)) { index++; } else { index += 2; } } - if (!filters.isEmpty()) { + if (operation == ComplexOperation.OR && query.getKeyFilters() != null) { + // Under OR: each KeyFilter gets its own EntityKeyMapping (no same-key grouping) + for (KeyFilter keyFilter : query.getKeyFilters()) { + EntityKeyMapping mapping = new EntityKeyMapping(); + mapping.setIndex(index); + mapping.setAlias(String.format("alias%s", index)); + mapping.setKeyFilters(Collections.singletonList(keyFilter)); + EntityKey filterKey = keyFilter.getKey(); + mapping.setLatest(!filterKey.getType().equals(EntityKeyType.ENTITY_FIELD)); + mapping.setEntityKey(filterKey); + mapping.setEntityKeyColumn(entityType, entityFilterType); + mapping.setSelection(mapping.getEntityKeyColumn() == null); + mappings.add(mapping); + if (filterKey.getType().equals(EntityKeyType.ENTITY_FIELD)) { + index += 1; + } else { + index += 2; + } + } + } else if (!filters.isEmpty()) { + // Under AND: create mappings for filter-only keys not in selection for (EntityKey filterField : filters.keySet()) { EntityKeyMapping mapping = new EntityKeyMapping(); mapping.setIndex(index); @@ -464,25 +534,45 @@ public class EntityKeyMapping { public static List prepareEntityCountKeyMapping(EntityCountQuery query) { EntityType entityType = resolveEntityType(query.getEntityFilter()); EntityFilterType entityFilterType = query.getEntityFilter().getType(); + ComplexOperation operation = query.getKeyFiltersOperationOrDefault(); - Map> filters = - query.getKeyFilters() != null ? - query.getKeyFilters().stream().collect(Collectors.groupingBy(KeyFilter::getKey)) : Collections.emptyMap(); int index = 2; List mappings = new ArrayList<>(); - if (!filters.isEmpty()) { - for (EntityKey filterField : filters.keySet()) { + + if (operation == ComplexOperation.OR && query.getKeyFilters() != null && !query.getKeyFilters().isEmpty()) { + // Under OR: each KeyFilter gets its own mapping (no same-key grouping) + for (KeyFilter keyFilter : query.getKeyFilters()) { EntityKeyMapping mapping = new EntityKeyMapping(); mapping.setIndex(index); mapping.setAlias(String.format("alias%s", index)); - mapping.setKeyFilters(filters.get(filterField)); - mapping.setLatest(!filterField.getType().equals(EntityKeyType.ENTITY_FIELD)); - mapping.setEntityKey(filterField); + mapping.setKeyFilters(Collections.singletonList(keyFilter)); + EntityKey filterKey = keyFilter.getKey(); + mapping.setLatest(!filterKey.getType().equals(EntityKeyType.ENTITY_FIELD)); + mapping.setEntityKey(filterKey); mapping.setEntityKeyColumn(entityType, entityFilterType); mapping.setSelection(mapping.getEntityKeyColumn() == null); mappings.add(mapping); index += 1; } + } else { + // Under AND (or null/default): group same-key filters together + Map> filters = + query.getKeyFilters() != null ? + query.getKeyFilters().stream().collect(Collectors.groupingBy(KeyFilter::getKey)) : Collections.emptyMap(); + if (!filters.isEmpty()) { + for (EntityKey filterField : filters.keySet()) { + EntityKeyMapping mapping = new EntityKeyMapping(); + mapping.setIndex(index); + mapping.setAlias(String.format("alias%s", index)); + mapping.setKeyFilters(filters.get(filterField)); + mapping.setLatest(!filterField.getType().equals(EntityKeyType.ENTITY_FIELD)); + mapping.setEntityKey(filterField); + mapping.setEntityKeyColumn(entityType, entityFilterType); + mapping.setSelection(mapping.getEntityKeyColumn() == null); + mappings.add(mapping); + index += 1; + } + } } return mappings; @@ -529,22 +619,27 @@ public class EntityKeyMapping { private String buildKeyQuery(SqlQueryContext ctx, String alias, KeyFilter keyFilter, EntityFilterType filterType) { - return this.buildPredicateQuery(ctx, alias, keyFilter.getKey(), keyFilter.getPredicate(), filterType); + return this.buildKeyQuery(ctx, alias, keyFilter, filterType, false); + } + + private String buildKeyQuery(SqlQueryContext ctx, String alias, KeyFilter keyFilter, + EntityFilterType filterType, boolean useAliasAsField) { + return this.buildPredicateQuery(ctx, alias, keyFilter.getKey(), keyFilter.getPredicate(), filterType, useAliasAsField); } private String buildPredicateQuery(SqlQueryContext ctx, String alias, EntityKey key, - KeyFilterPredicate predicate, EntityFilterType filterType) { + KeyFilterPredicate predicate, EntityFilterType filterType, boolean useAliasAsField) { if (predicate.getType().equals(FilterPredicateType.COMPLEX)) { - return this.buildComplexPredicateQuery(ctx, alias, key, (ComplexFilterPredicate) predicate, filterType); + return this.buildComplexPredicateQuery(ctx, alias, key, (ComplexFilterPredicate) predicate, filterType, useAliasAsField); } else { - return this.buildSimplePredicateQuery(ctx, alias, key, predicate, filterType); + return this.buildSimplePredicateQuery(ctx, alias, key, predicate, filterType, useAliasAsField); } } private String buildComplexPredicateQuery(SqlQueryContext ctx, String alias, EntityKey key, - ComplexFilterPredicate predicate, EntityFilterType filterType) { + ComplexFilterPredicate predicate, EntityFilterType filterType, boolean useAliasAsField) { String result = predicate.getPredicates().stream() - .map(keyFilterPredicate -> this.buildPredicateQuery(ctx, alias, key, keyFilterPredicate, filterType)) + .map(keyFilterPredicate -> this.buildPredicateQuery(ctx, alias, key, keyFilterPredicate, filterType, useAliasAsField)) .filter(StringUtils::isNotEmpty) .collect(Collectors.joining(" " + predicate.getOperation().name() + " ")); if (!result.trim().isEmpty()) { @@ -554,9 +649,9 @@ public class EntityKeyMapping { } private String buildSimplePredicateQuery(SqlQueryContext ctx, String alias, EntityKey key, - KeyFilterPredicate predicate, EntityFilterType filterType) { + KeyFilterPredicate predicate, EntityFilterType filterType, boolean useAliasAsField) { if (key.getType().equals(EntityKeyType.ENTITY_FIELD)) { - String field = (getEntityKeyColumn() != null) ? alias + "." + getEntityKeyColumn() : alias; + String field = useAliasAsField || getEntityKeyColumn() == null ? alias : alias + "." + getEntityKeyColumn(); if (predicate.getType().equals(FilterPredicateType.NUMERIC)) { return this.buildNumericPredicateQuery(ctx, field, (NumericFilterPredicate) predicate); } else if (predicate.getType().equals(FilterPredicateType.STRING)) { diff --git a/dao/src/main/java/org/thingsboard/server/dao/sql/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/resources/sql/schema-entities.sql b/dao/src/main/resources/sql/schema-entities.sql index 2b9096188f..c6d9546bad 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, diff --git a/edqs/pom.xml b/edqs/pom.xml index 4d13edbc22..abb4fbe68b 100644 --- a/edqs/pom.xml +++ b/edqs/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard - 4.3.1.3-SNAPSHOT + 4.4.0-SNAPSHOT thingsboard edqs diff --git a/edqs/src/main/resources/edqs.yml b/edqs/src/main/resources/edqs.yml index 4dc404e2ca..ac1b324eaa 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. @@ -134,11 +134,23 @@ queue: # The endpoint identification algorithm used by clients to validate server hostname. The default value is https ssl.algorithm: "${TB_QUEUE_KAFKA_CONFLUENT_SSL_ALGORITHM:https}" # The mechanism used to authenticate Schema Registry requests. SASL/PLAIN should only be used with TLS/SSL as a transport layer to ensure that clear passwords are not transmitted on the wire without encryption + # Set to OAUTHBEARER to authenticate via OAuth2/OIDC client-credentials (configure the oauth.* settings below) sasl.mechanism: "${TB_QUEUE_KAFKA_CONFLUENT_SASL_MECHANISM:PLAIN}" # Using JAAS Configuration for specifying multiple SASL mechanisms on a broker sasl.config: "${TB_QUEUE_KAFKA_CONFLUENT_SASL_JAAS_CONFIG:org.apache.kafka.common.security.plain.PlainLoginModule required username=\"CLUSTER_API_KEY\" password=\"CLUSTER_API_SECRET\";}" # Protocol used to communicate with brokers. Valid values are: PLAINTEXT, SSL, SASL_PLAINTEXT, SASL_SSL security.protocol: "${TB_QUEUE_KAFKA_CONFLUENT_SECURITY_PROTOCOL:SASL_SSL}" + # Optional: OAuth2 (OIDC) client-credentials settings. Applied only when sasl.mechanism is OAUTHBEARER. + # ThingsBoard wires Kafka's built-in OAuthBearerLoginCallbackHandler; the token is fetched and refreshed automatically. + oauth: + # Optional: OAuth2 client id + client-id: "${TB_QUEUE_KAFKA_CONFLUENT_OAUTH_CLIENT_ID:}" + # Optional: OAuth2 client secret + client-secret: "${TB_QUEUE_KAFKA_CONFLUENT_OAUTH_CLIENT_SECRET:}" + # Optional: OAuth2/OIDC token endpoint URL to request the bearer token from + endpoint-url: "${TB_QUEUE_KAFKA_CONFLUENT_OAUTH_ENDPOINT_URL:}" + # Optional: OAuth2 scope/audience requested for the token. Required by some IdPs for client-credentials (e.g. Azure AD's "api:///.default") + scope: "${TB_QUEUE_KAFKA_CONFLUENT_OAUTH_SCOPE:}" # Key-value properties for Kafka consumer per specific topic, e.g. tb_ota_package is a topic name for ota, tb_rule_engine.sq is a topic name for default SequentialByOriginator queue. # Check TB_QUEUE_CORE_OTA_TOPIC and TB_QUEUE_RE_SQ_TOPIC params consumer-properties-per-topic: diff --git a/edqs/src/test/java/org/thingsboard/server/edqs/repo/RepositoryUtilsTest.java b/edqs/src/test/java/org/thingsboard/server/edqs/repo/RepositoryUtilsTest.java index a3691a0889..ab1b6c4060 100644 --- a/edqs/src/test/java/org/thingsboard/server/edqs/repo/RepositoryUtilsTest.java +++ b/edqs/src/test/java/org/thingsboard/server/edqs/repo/RepositoryUtilsTest.java @@ -15,6 +15,7 @@ */ package org.thingsboard.server.edqs.repo; +import org.junit.jupiter.api.Test; import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.Arguments; import org.junit.jupiter.params.provider.MethodSource; @@ -26,7 +27,7 @@ import org.thingsboard.server.common.data.edqs.fields.DeviceProfileFields; import org.thingsboard.server.common.data.query.BooleanFilterPredicate; import org.thingsboard.server.common.data.query.BooleanFilterPredicate.BooleanOperation; import org.thingsboard.server.common.data.query.ComplexFilterPredicate; -import org.thingsboard.server.common.data.query.ComplexFilterPredicate.ComplexOperation; +import org.thingsboard.server.common.data.query.ComplexOperation; import org.thingsboard.server.common.data.query.EntityKeyType; import org.thingsboard.server.common.data.query.EntityKeyValueType; import org.thingsboard.server.common.data.query.FilterPredicateValue; @@ -44,6 +45,7 @@ import org.thingsboard.server.edqs.query.DataKey; import org.thingsboard.server.edqs.query.EdqsFilter; import org.thingsboard.server.edqs.util.RepositoryUtils; +import java.util.Collections; import java.util.List; import java.util.UUID; import java.util.stream.Stream; @@ -469,4 +471,163 @@ public class RepositoryUtilsTest { return complexFilterPredicate; } + // --- OR operation tests for checkKeyFilters --- + + @Test + public void testCheckKeyFiltersWithOrOperation() { + // Entity with temperature=60; filters: temperature>50 (match) and humidity>80 (no data) + DeviceData deviceData = new DeviceData(UUID.randomUUID()); + deviceData.setCustomerId(UUID.randomUUID()); + deviceData.setFields(DeviceFields.builder().name("orDevice1").build()); + deviceData.putTs(5, new DoubleDataPoint(System.currentTimeMillis(), 60.0)); + + EdqsFilter tempGt50 = getTemperatureFilter(NumericOperation.GREATER, 50); + EdqsFilter humidityGt80 = getHumidityFilter(NumericOperation.GREATER, 80); + + // OR: temperature>50 matches, short-circuits to true + assertThat(RepositoryUtils.checkKeyFilters(deviceData, List.of(tempGt50, humidityGt80), ComplexOperation.OR)).isTrue(); + } + + @Test + public void testCheckKeyFiltersWithOrOperationNoMatch() { + // Entity with temperature=30; filters: temperature>50 (no match) and temperature<10 (no match) + DeviceData deviceData = new DeviceData(UUID.randomUUID()); + deviceData.setCustomerId(UUID.randomUUID()); + deviceData.setFields(DeviceFields.builder().name("orDevice2").build()); + deviceData.putTs(5, new DoubleDataPoint(System.currentTimeMillis(), 30.0)); + + EdqsFilter tempGt50 = getTemperatureFilter(NumericOperation.GREATER, 50); + EdqsFilter tempLt10 = getTemperatureFilter(NumericOperation.LESS, 10); + + // OR: neither filter matches + assertThat(RepositoryUtils.checkKeyFilters(deviceData, List.of(tempGt50, tempLt10), ComplexOperation.OR)).isFalse(); + } + + @Test + public void testCheckKeyFiltersWithOrOperationSameKey() { + // Entity with temperature=5; filters: temperature>50 (no match) and temperature<10 (match) + DeviceData deviceData = new DeviceData(UUID.randomUUID()); + deviceData.setCustomerId(UUID.randomUUID()); + deviceData.setFields(DeviceFields.builder().name("orDevice3").build()); + deviceData.putTs(5, new DoubleDataPoint(System.currentTimeMillis(), 5.0)); + + EdqsFilter tempGt50 = getTemperatureFilter(NumericOperation.GREATER, 50); + EdqsFilter tempLt10 = getTemperatureFilter(NumericOperation.LESS, 10); + + // OR on same key: second filter matches + assertThat(RepositoryUtils.checkKeyFilters(deviceData, List.of(tempGt50, tempLt10), ComplexOperation.OR)).isTrue(); + } + + @Test + public void testCheckKeyFiltersWithAndOperationUnchanged() { + // Entity with temperature=60 and humidity=90 + DeviceData deviceData = new DeviceData(UUID.randomUUID()); + deviceData.setCustomerId(UUID.randomUUID()); + deviceData.setFields(DeviceFields.builder().name("andDevice1").build()); + deviceData.putTs(5, new DoubleDataPoint(System.currentTimeMillis(), 60.0)); + deviceData.putTs(6, new LongDataPoint(System.currentTimeMillis(), 90)); + + EdqsFilter tempGt50 = getTemperatureFilter(NumericOperation.GREATER, 50); + EdqsFilter humidityGt80 = getHumidityFilter(NumericOperation.GREATER, 80); + + // AND: both match + assertThat(RepositoryUtils.checkKeyFilters(deviceData, List.of(tempGt50, humidityGt80), ComplexOperation.AND)).isTrue(); + + // Now with humidity=70 -- humidity>80 fails + DeviceData deviceData2 = new DeviceData(UUID.randomUUID()); + deviceData2.setCustomerId(UUID.randomUUID()); + deviceData2.setFields(DeviceFields.builder().name("andDevice2").build()); + deviceData2.putTs(5, new DoubleDataPoint(System.currentTimeMillis(), 60.0)); + deviceData2.putTs(6, new LongDataPoint(System.currentTimeMillis(), 70)); + + // AND: humidity>80 fails + assertThat(RepositoryUtils.checkKeyFilters(deviceData2, List.of(tempGt50, humidityGt80), ComplexOperation.AND)).isFalse(); + } + + @Test + public void testCheckKeyFiltersWithNullOperationDefaultsToAnd() { + // Entity with temperature=60 and humidity=90 + DeviceData deviceData = new DeviceData(UUID.randomUUID()); + deviceData.setCustomerId(UUID.randomUUID()); + deviceData.setFields(DeviceFields.builder().name("nullOpDevice").build()); + deviceData.putTs(5, new DoubleDataPoint(System.currentTimeMillis(), 60.0)); + deviceData.putTs(6, new LongDataPoint(System.currentTimeMillis(), 90)); + + EdqsFilter tempGt50 = getTemperatureFilter(NumericOperation.GREATER, 50); + EdqsFilter humidityGt80 = getHumidityFilter(NumericOperation.GREATER, 80); + + // null operation should behave like AND + boolean nullResult = RepositoryUtils.checkKeyFilters(deviceData, List.of(tempGt50, humidityGt80), null); + boolean andResult = RepositoryUtils.checkKeyFilters(deviceData, List.of(tempGt50, humidityGt80), ComplexOperation.AND); + assertThat(nullResult).isEqualTo(andResult); + assertThat(nullResult).isTrue(); + + // Entity with temperature=60 and humidity=70 -- humidity>80 fails + DeviceData deviceData2 = new DeviceData(UUID.randomUUID()); + deviceData2.setCustomerId(UUID.randomUUID()); + deviceData2.setFields(DeviceFields.builder().name("nullOpDevice2").build()); + deviceData2.putTs(5, new DoubleDataPoint(System.currentTimeMillis(), 60.0)); + deviceData2.putTs(6, new LongDataPoint(System.currentTimeMillis(), 70)); + + boolean nullResult2 = RepositoryUtils.checkKeyFilters(deviceData2, List.of(tempGt50, humidityGt80), null); + boolean andResult2 = RepositoryUtils.checkKeyFilters(deviceData2, List.of(tempGt50, humidityGt80), ComplexOperation.AND); + assertThat(nullResult2).isEqualTo(andResult2); + assertThat(nullResult2).isFalse(); + } + + @Test + public void testCheckKeyFiltersWithOrEmptyFilters() { + DeviceData deviceData = new DeviceData(UUID.randomUUID()); + deviceData.setCustomerId(UUID.randomUUID()); + deviceData.setFields(DeviceFields.builder().name("emptyFilterDevice").build()); + + // OR with empty filters = include all (matches SQL behavior) + assertThat(RepositoryUtils.checkKeyFilters(deviceData, Collections.emptyList(), ComplexOperation.OR)).isTrue(); + } + + @Test + public void testCheckKeyFiltersWithOrStringEntityFieldFilters() { + // Entity with name="loranet device 123" + DeviceData deviceData = new DeviceData(UUID.randomUUID()); + deviceData.setCustomerId(UUID.randomUUID()); + deviceData.setFields(DeviceFields.builder().name("loranet device 123").build()); + + // Filter 1: name STARTS_WITH "mqtt" (no match) + EdqsFilter nameStartsMqtt = getStringEntityFieldFilter("name", StringOperation.STARTS_WITH, "mqtt"); + // Filter 2: name CONTAINS "lora" (match) + EdqsFilter nameContainsLora = getStringEntityFieldFilter("name", StringOperation.CONTAINS, "lora"); + + // OR: second filter matches + assertThat(RepositoryUtils.checkKeyFilters(deviceData, List.of(nameStartsMqtt, nameContainsLora), ComplexOperation.OR)).isTrue(); + + // AND: first filter fails + assertThat(RepositoryUtils.checkKeyFilters(deviceData, List.of(nameStartsMqtt, nameContainsLora), ComplexOperation.AND)).isFalse(); + } + + @Test + public void testCheckKeyFiltersWithOrEntityFieldNullStringSpecialCase() { + // Entity with name=null — tests the special case where null string + entity field = match + DeviceData deviceData = new DeviceData(UUID.randomUUID()); + deviceData.setCustomerId(UUID.randomUUID()); + deviceData.setFields(DeviceFields.builder().name(null).build()); + + // Filter on entity field "label" which is not set (null) + EdqsFilter labelFilter = getStringEntityFieldFilter("label", StringOperation.STARTS_WITH, "test"); + // Filter on timeseries (non-entity-field) that doesn't exist + EdqsFilter tempFilter = getTemperatureFilter(NumericOperation.GREATER, 50); + + // Entity field with null string = match (special case in evaluateSingleFilter) + assertThat(RepositoryUtils.checkKeyFilters(deviceData, List.of(labelFilter), ComplexOperation.OR)).isTrue(); + assertThat(RepositoryUtils.checkKeyFilters(deviceData, List.of(labelFilter), ComplexOperation.AND)).isTrue(); + + // Non-entity-field with missing data = no match + assertThat(RepositoryUtils.checkKeyFilters(deviceData, List.of(tempFilter), ComplexOperation.OR)).isFalse(); + + // OR: entity field null match + missing timeseries = true (first matches) + assertThat(RepositoryUtils.checkKeyFilters(deviceData, List.of(labelFilter, tempFilter), ComplexOperation.OR)).isTrue(); + + // AND: entity field null match + missing timeseries = false (second fails) + assertThat(RepositoryUtils.checkKeyFilters(deviceData, List.of(labelFilter, tempFilter), ComplexOperation.AND)).isFalse(); + } + } diff --git a/monitoring/pom.xml b/monitoring/pom.xml index 2786e1b2c8..c3dfaf00c7 100644 --- a/monitoring/pom.xml +++ b/monitoring/pom.xml @@ -21,7 +21,7 @@ 4.0.0 org.thingsboard - 4.3.1.3-SNAPSHOT + 4.4.0-SNAPSHOT thingsboard @@ -79,6 +79,10 @@ org.apache.httpcomponents httpclient + + com.slack.api + slack-api-client + org.eclipse.leshan leshan-client-cf @@ -118,6 +122,11 @@ ch.qos.logback logback-classic + + org.springframework.boot + spring-boot-starter-test + test + diff --git a/monitoring/src/main/java/org/thingsboard/monitoring/config/transport/TransportInfo.java b/monitoring/src/main/java/org/thingsboard/monitoring/config/transport/TransportInfo.java index 251b5e1d85..cc9801dcad 100644 --- a/monitoring/src/main/java/org/thingsboard/monitoring/config/transport/TransportInfo.java +++ b/monitoring/src/main/java/org/thingsboard/monitoring/config/transport/TransportInfo.java @@ -16,13 +16,21 @@ package org.thingsboard.monitoring.config.transport; import lombok.Data; +import org.thingsboard.monitoring.data.notification.ShortNameProvider; @Data -public class TransportInfo { +public class TransportInfo implements ShortNameProvider { private final TransportType type; private final TransportMonitoringTarget target; + public String getShortName() { + if (target.getQueue().equals("Main")) { + return type.getName(); + } + return type.getName() + " " + target.getQueue(); + } + @Override public String toString() { if (target.getQueue().equals("Main")) { diff --git a/monitoring/src/main/java/org/thingsboard/monitoring/data/MonitoredServiceKey.java b/monitoring/src/main/java/org/thingsboard/monitoring/data/MonitoredServiceKey.java index 28fa6a9e18..c566e8f896 100644 --- a/monitoring/src/main/java/org/thingsboard/monitoring/data/MonitoredServiceKey.java +++ b/monitoring/src/main/java/org/thingsboard/monitoring/data/MonitoredServiceKey.java @@ -18,6 +18,9 @@ package org.thingsboard.monitoring.data; public class MonitoredServiceKey { public static final String GENERAL = "Monitoring"; + public static final String LOGIN = "Login"; + public static final String WS_CONNECT = "WS Connect"; + public static final String WS_SUBSCRIBE = "WS Subscribe"; public static final String EDQS = "*EDQS*"; } diff --git a/monitoring/src/main/java/org/thingsboard/monitoring/data/notification/AffectedService.java b/monitoring/src/main/java/org/thingsboard/monitoring/data/notification/AffectedService.java new file mode 100644 index 0000000000..b30a398223 --- /dev/null +++ b/monitoring/src/main/java/org/thingsboard/monitoring/data/notification/AffectedService.java @@ -0,0 +1,34 @@ +/** + * Copyright © 2016-2026 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.monitoring.data.notification; + +public record AffectedService(String name, Status status, int failureCount) { + + public enum Status { FAILING, RECOVERED, HIGH_LATENCY } + + public static AffectedService failing(String name, int failureCount) { + return new AffectedService(name, Status.FAILING, failureCount); + } + + public static AffectedService recovered(String name) { + return new AffectedService(name, Status.RECOVERED, 0); + } + + public static AffectedService highLatency(String name) { + return new AffectedService(name, Status.HIGH_LATENCY, 0); + } + +} diff --git a/monitoring/src/main/java/org/thingsboard/monitoring/data/notification/HighLatencyNotification.java b/monitoring/src/main/java/org/thingsboard/monitoring/data/notification/HighLatencyNotification.java index 9a319eb6c9..3e739ec4dc 100644 --- a/monitoring/src/main/java/org/thingsboard/monitoring/data/notification/HighLatencyNotification.java +++ b/monitoring/src/main/java/org/thingsboard/monitoring/data/notification/HighLatencyNotification.java @@ -18,6 +18,8 @@ package org.thingsboard.monitoring.data.notification; import org.thingsboard.monitoring.data.Latency; import java.util.Collection; +import java.util.List; +import java.util.stream.Collectors; public class HighLatencyNotification implements Notification { @@ -39,4 +41,11 @@ public class HighLatencyNotification implements Notification { return text.toString(); } + @Override + public List getAffectedServices() { + return highLatencies.stream() + .map(latency -> AffectedService.highLatency(latency.getKey())) + .collect(Collectors.toList()); + } + } diff --git a/monitoring/src/main/java/org/thingsboard/monitoring/data/notification/InfoNotification.java b/monitoring/src/main/java/org/thingsboard/monitoring/data/notification/InfoNotification.java index b2b73414de..6906e8c732 100644 --- a/monitoring/src/main/java/org/thingsboard/monitoring/data/notification/InfoNotification.java +++ b/monitoring/src/main/java/org/thingsboard/monitoring/data/notification/InfoNotification.java @@ -24,4 +24,9 @@ public class InfoNotification implements Notification { public String getText() { return message; } + + @Override + public boolean isIncident() { + return false; + } } diff --git a/monitoring/src/main/java/org/thingsboard/monitoring/data/notification/Notification.java b/monitoring/src/main/java/org/thingsboard/monitoring/data/notification/Notification.java index 33ed7f8328..7734fc62ee 100644 --- a/monitoring/src/main/java/org/thingsboard/monitoring/data/notification/Notification.java +++ b/monitoring/src/main/java/org/thingsboard/monitoring/data/notification/Notification.java @@ -15,8 +15,18 @@ */ package org.thingsboard.monitoring.data.notification; +import java.util.List; + public interface Notification { String getText(); + default boolean isIncident() { + return true; + } + + default List getAffectedServices() { + return List.of(); + } + } diff --git a/monitoring/src/main/java/org/thingsboard/monitoring/data/notification/ServiceFailureNotification.java b/monitoring/src/main/java/org/thingsboard/monitoring/data/notification/ServiceFailureNotification.java index dd93f232a6..69e336f334 100644 --- a/monitoring/src/main/java/org/thingsboard/monitoring/data/notification/ServiceFailureNotification.java +++ b/monitoring/src/main/java/org/thingsboard/monitoring/data/notification/ServiceFailureNotification.java @@ -18,6 +18,10 @@ package org.thingsboard.monitoring.data.notification; import lombok.Getter; import org.apache.commons.lang3.exception.ExceptionUtils; +import java.util.List; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + @Getter public class ServiceFailureNotification implements Notification { @@ -43,7 +47,65 @@ public class ServiceFailureNotification implements Notification { if (errorMsg == null) { errorMsg = error.getClass().getSimpleName(); } + errorMsg = stripResponseBody(errorMsg); + errorMsg = linkifyRequestUrl(errorMsg); return String.format("%s - Failure: %s (number of subsequent failures: %s)", serviceKey, errorMsg, failuresCount); } + // Spring RestClient: '... request for ""' + private static final Pattern REQUEST_FOR_URL_PATTERN = Pattern.compile("request for \"(https?://[^\"\\s]+)\""); + // Apache HttpClient wrapped by Spring: 'I/O error on POST request: Connect to failed: ' + private static final Pattern REQUEST_CONNECT_PATTERN = Pattern.compile("request: Connect to (https?://\\S+?) failed:"); + + static String linkifyRequestUrl(String msg) { + if (msg == null) { + return null; + } + // Slack mrkdwn link: + Matcher m = REQUEST_FOR_URL_PATTERN.matcher(msg); + if (m.find()) { + return m.replaceAll("<$1|request>"); + } + Matcher m2 = REQUEST_CONNECT_PATTERN.matcher(msg); + if (m2.find()) { + return m2.replaceAll("<$1|request>:"); + } + return msg; + } + + static String stripResponseBody(String msg) { + if (msg == null) { + return null; + } + int htmlIdx = -1; + for (String marker : new String[]{"= 0 && (htmlIdx < 0 || idx < htmlIdx)) { + htmlIdx = idx; + } + } + if (htmlIdx > 0) { + msg = msg.substring(0, htmlIdx).stripTrailing(); + if (msg.endsWith("\"")) { + msg = msg.substring(0, msg.length() - 1).stripTrailing(); + } + if (msg.endsWith(":")) { + msg = msg.substring(0, msg.length() - 1).stripTrailing(); + } + } + return msg; + } + + @Override + public List getAffectedServices() { + return List.of(AffectedService.failing(shortName(serviceKey), failuresCount)); + } + + static String shortName(Object serviceKey) { + if (serviceKey instanceof ShortNameProvider provider) { + return provider.getShortName(); + } + return serviceKey.toString(); + } + } diff --git a/monitoring/src/main/java/org/thingsboard/monitoring/data/notification/ServiceRecoveryNotification.java b/monitoring/src/main/java/org/thingsboard/monitoring/data/notification/ServiceRecoveryNotification.java index b6e0c7c695..31b32422a1 100644 --- a/monitoring/src/main/java/org/thingsboard/monitoring/data/notification/ServiceRecoveryNotification.java +++ b/monitoring/src/main/java/org/thingsboard/monitoring/data/notification/ServiceRecoveryNotification.java @@ -15,6 +15,8 @@ */ package org.thingsboard.monitoring.data.notification; +import java.util.List; + public class ServiceRecoveryNotification implements Notification { private final Object serviceKey; @@ -28,4 +30,9 @@ public class ServiceRecoveryNotification implements Notification { return String.format("%s is OK", serviceKey); } + @Override + public List getAffectedServices() { + return List.of(AffectedService.recovered(ServiceFailureNotification.shortName(serviceKey))); + } + } diff --git a/monitoring/src/main/java/org/thingsboard/monitoring/data/notification/ShortNameProvider.java b/monitoring/src/main/java/org/thingsboard/monitoring/data/notification/ShortNameProvider.java new file mode 100644 index 0000000000..96c63023af --- /dev/null +++ b/monitoring/src/main/java/org/thingsboard/monitoring/data/notification/ShortNameProvider.java @@ -0,0 +1,22 @@ +/** + * Copyright © 2016-2026 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.monitoring.data.notification; + +public interface ShortNameProvider { + + String getShortName(); + +} diff --git a/monitoring/src/main/java/org/thingsboard/monitoring/notification/NotificationService.java b/monitoring/src/main/java/org/thingsboard/monitoring/notification/NotificationService.java index e813b3205d..091a331438 100644 --- a/monitoring/src/main/java/org/thingsboard/monitoring/notification/NotificationService.java +++ b/monitoring/src/main/java/org/thingsboard/monitoring/notification/NotificationService.java @@ -51,7 +51,7 @@ public class NotificationService { return notificationChannels.stream().map(notificationChannel -> notificationExecutor.submit(() -> { try { - notificationChannel.sendNotification(message); + notificationChannel.sendNotification(message, notification); } catch (Exception e) { log.error("Failed to send notification to {}", notificationChannel.getClass().getSimpleName(), e); } diff --git a/monitoring/src/main/java/org/thingsboard/monitoring/notification/channels/NotificationChannel.java b/monitoring/src/main/java/org/thingsboard/monitoring/notification/channels/NotificationChannel.java index 126942cb3f..614fddda31 100644 --- a/monitoring/src/main/java/org/thingsboard/monitoring/notification/channels/NotificationChannel.java +++ b/monitoring/src/main/java/org/thingsboard/monitoring/notification/channels/NotificationChannel.java @@ -15,8 +15,10 @@ */ package org.thingsboard.monitoring.notification.channels; +import org.thingsboard.monitoring.data.notification.Notification; + public interface NotificationChannel { - void sendNotification(String message); + void sendNotification(String message, Notification notification); } diff --git a/monitoring/src/main/java/org/thingsboard/monitoring/notification/channels/impl/SlackApiClient.java b/monitoring/src/main/java/org/thingsboard/monitoring/notification/channels/impl/SlackApiClient.java new file mode 100644 index 0000000000..4ad74d7811 --- /dev/null +++ b/monitoring/src/main/java/org/thingsboard/monitoring/notification/channels/impl/SlackApiClient.java @@ -0,0 +1,125 @@ +/** + * Copyright © 2016-2026 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.monitoring.notification.channels.impl; + +import com.slack.api.Slack; +import com.slack.api.SlackConfig; +import com.slack.api.methods.MethodsClient; +import com.slack.api.methods.SlackApiTextResponse; +import com.slack.api.methods.request.chat.ChatPostMessageRequest; +import com.slack.api.methods.request.chat.ChatUpdateRequest; +import com.slack.api.methods.response.chat.ChatPostMessageResponse; +import com.slack.api.methods.response.chat.ChatUpdateResponse; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +public class SlackApiClient { + + private static final int DEFAULT_CALL_TIMEOUT_MS = 5000; + + private final Slack slack; + private final String botToken; + + public SlackApiClient(String botToken) { + this(botToken, DEFAULT_CALL_TIMEOUT_MS); + } + + public SlackApiClient(String botToken, int callTimeoutMs) { + this.botToken = botToken; + SlackConfig config = new SlackConfig(); + config.setHttpClientCallTimeoutMillis(callTimeoutMs); + config.setHttpClientReadTimeoutMillis(callTimeoutMs); + config.setHttpClientWriteTimeoutMillis(callTimeoutMs); + this.slack = Slack.getInstance(config); + } + + public String postMessage(String channelId, String text) { + ChatPostMessageRequest request = ChatPostMessageRequest.builder() + .channel(channelId) + .text(text) + .build(); + ChatPostMessageResponse response = sendRequest(request); + return response.getTs(); + } + + public String postThreadReply(String channelId, String threadTs, String text) { + ChatPostMessageRequest request = ChatPostMessageRequest.builder() + .channel(channelId) + .text(text) + .threadTs(threadTs) + .build(); + ChatPostMessageResponse response = sendRequest(request); + return response.getTs(); + } + + public void close() { + try { + slack.close(); + } catch (Exception e) { + log.warn("Failed to close Slack client", e); + } + } + + public void updateMessage(String channelId, String ts, String text) { + ChatUpdateRequest request = ChatUpdateRequest.builder() + .channel(channelId) + .ts(ts) + .text(text) + .build(); + MethodsClient client = slack.methods(botToken); + ChatUpdateResponse response; + try { + response = client.chatUpdate(request); + } catch (Exception e) { + throw new RuntimeException("Failed to update Slack message: " + e.getMessage(), e); + } + checkResponse(response); + } + + private ChatPostMessageResponse sendRequest(ChatPostMessageRequest request) { + MethodsClient client = slack.methods(botToken); + ChatPostMessageResponse response; + try { + response = client.chatPostMessage(request); + } catch (Exception e) { + throw new RuntimeException("Failed to send Slack message: " + e.getMessage(), e); + } + checkResponse(response); + return response; + } + + private void checkResponse(SlackApiTextResponse response) { + if (response.isOk()) { + return; + } + String error = response.getError(); + if (error != null) { + switch (error) { + case "missing_scope" -> { + String neededScope = response.getNeeded(); + error = "bot token scope '" + neededScope + "' is needed"; + } + case "not_in_channel" -> error = "app needs to be added to the channel"; + } + } else if (response.getWarning() != null) { + error = "warning: " + response.getWarning(); + } else { + error = "unknown error"; + } + throw new RuntimeException("Slack API error: " + error); + } + +} diff --git a/monitoring/src/main/java/org/thingsboard/monitoring/notification/channels/impl/SlackIncidentTransport.java b/monitoring/src/main/java/org/thingsboard/monitoring/notification/channels/impl/SlackIncidentTransport.java new file mode 100644 index 0000000000..8812714d62 --- /dev/null +++ b/monitoring/src/main/java/org/thingsboard/monitoring/notification/channels/impl/SlackIncidentTransport.java @@ -0,0 +1,45 @@ +/** + * Copyright © 2016-2026 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.monitoring.notification.channels.impl; + +import org.thingsboard.monitoring.notification.incident.IncidentTransport; + +public class SlackIncidentTransport implements IncidentTransport { + + private final SlackApiClient slackApiClient; + private final String channelId; + + public SlackIncidentTransport(SlackApiClient slackApiClient, String channelId) { + this.slackApiClient = slackApiClient; + this.channelId = channelId; + } + + @Override + public String postIncident(String text) { + return slackApiClient.postMessage(channelId, text); + } + + @Override + public void postThreadReply(String threadId, String text) { + slackApiClient.postThreadReply(channelId, threadId, text); + } + + @Override + public void updateIncident(String threadId, String text) { + slackApiClient.updateMessage(channelId, threadId, text); + } + +} diff --git a/monitoring/src/main/java/org/thingsboard/monitoring/notification/channels/impl/SlackNotificationChannel.java b/monitoring/src/main/java/org/thingsboard/monitoring/notification/channels/impl/SlackNotificationChannel.java index c1d25c43c0..06990c5a51 100644 --- a/monitoring/src/main/java/org/thingsboard/monitoring/notification/channels/impl/SlackNotificationChannel.java +++ b/monitoring/src/main/java/org/thingsboard/monitoring/notification/channels/impl/SlackNotificationChannel.java @@ -16,13 +16,16 @@ package org.thingsboard.monitoring.notification.channels.impl; import jakarta.annotation.PostConstruct; +import jakarta.annotation.PreDestroy; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Value; import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.boot.web.client.RestTemplateBuilder; import org.springframework.stereotype.Component; import org.springframework.web.client.RestTemplate; +import org.thingsboard.monitoring.data.notification.Notification; import org.thingsboard.monitoring.notification.channels.NotificationChannel; +import org.thingsboard.monitoring.notification.incident.IncidentManager; import java.time.Duration; import java.util.Map; @@ -35,19 +38,73 @@ public class SlackNotificationChannel implements NotificationChannel { @Value("${monitoring.notifications.slack.webhook_url}") private String webhookUrl; + @Value("${monitoring.notifications.slack.bot_token:}") + private String botToken; + + @Value("${monitoring.notifications.slack.channel_id:}") + private String channelId; + + @Value("${monitoring.notifications.incident.enabled:}") + private boolean incidentEnabled; + + @Value("${monitoring.notifications.incident.resolution_timeout_s:}") + private long resolutionTimeoutSeconds; + + @Value("${monitoring.notifications.incident.tag_channel:}") + private boolean tagChannel; + + @Value("${monitoring.notifications.message_prefix:}") + private String messagePrefix; + private RestTemplate restTemplate; + private SlackApiClient slackApiClient; + private IncidentManager incidentManager; @PostConstruct private void init() { - restTemplate = new RestTemplateBuilder() - .setConnectTimeout(Duration.ofSeconds(5)) - .setReadTimeout(Duration.ofSeconds(2)) - .build(); + boolean hasBotConfig = botToken != null && !botToken.isEmpty() && channelId != null && !channelId.isEmpty(); + if (hasBotConfig) { + slackApiClient = new SlackApiClient(botToken); + log.info("Slack API mode enabled (channel: {})", channelId); + if (incidentEnabled) { + incidentManager = new IncidentManager(new SlackIncidentTransport(slackApiClient, channelId), + resolutionTimeoutSeconds, messagePrefix, tagChannel); + log.info("Incident grouping enabled via Slack (resolution timeout: {}s)", resolutionTimeoutSeconds); + } + } else { + if (incidentEnabled) { + log.warn("Incident grouping is enabled but Slack bot_token/channel_id are not set; " + + "falling back to plain webhook mode without incident support"); + } + restTemplate = new RestTemplateBuilder() + .setConnectTimeout(Duration.ofSeconds(5)) + .setReadTimeout(Duration.ofSeconds(2)) + .build(); + log.info("Slack webhook mode enabled"); + } } @Override - public void sendNotification(String message) { - restTemplate.postForObject(webhookUrl, Map.of("text", message), String.class); + public void sendNotification(String message, Notification notification) { + if (incidentManager != null && notification.isIncident()) { + // Pass the raw notification text: IncidentManager already puts the prefix into the + // incident header, so pre-prefixing the thread reply would double it up. + incidentManager.sendAlert(notification.getText(), notification.getAffectedServices()); + } else if (slackApiClient != null) { + slackApiClient.postMessage(channelId, message); + } else { + restTemplate.postForObject(webhookUrl, Map.of("text", message), String.class); + } + } + + @PreDestroy + private void destroy() { + if (incidentManager != null) { + incidentManager.shutdown(); + } + if (slackApiClient != null) { + slackApiClient.close(); + } } } diff --git a/monitoring/src/main/java/org/thingsboard/monitoring/notification/incident/IncidentManager.java b/monitoring/src/main/java/org/thingsboard/monitoring/notification/incident/IncidentManager.java new file mode 100644 index 0000000000..c25abfc081 --- /dev/null +++ b/monitoring/src/main/java/org/thingsboard/monitoring/notification/incident/IncidentManager.java @@ -0,0 +1,292 @@ +/** + * Copyright © 2016-2026 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.monitoring.notification.incident; + +import lombok.extern.slf4j.Slf4j; +import org.thingsboard.monitoring.data.notification.AffectedService; + +import java.time.Duration; +import java.time.Instant; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.Executors; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.ScheduledFuture; +import java.util.concurrent.TimeUnit; + +/** + * Thread-safety: all public entry points and scheduled callbacks are {@code synchronized} on the + * manager instance. Transport I/O is performed while the monitor is held. This is safe under the + * assumptions that (a) the transport enforces a short per-call timeout, and (b) notification + * producers are single-threaded; see the Slack client (default 5s) for the Slack-based transport. + */ +@Slf4j +public class IncidentManager { + + private final IncidentTransport transport; + private final long resolutionTimeoutSeconds; + private final String messagePrefix; + private final boolean tagChannel; + private final ScheduledExecutorService scheduler; + + private String activeIncidentThreadId; + private ScheduledFuture resolutionTask; + private ScheduledFuture durationUpdateTask; + private Instant incidentStartTime; + private Instant lastAlertTime; + private final Map failingServices = new LinkedHashMap<>(); + private final Map recoveredServices = new LinkedHashMap<>(); + private final Set highLatencyServices = new LinkedHashSet<>(); + + public IncidentManager(IncidentTransport transport, long resolutionTimeoutSeconds, + String messagePrefix, boolean tagChannel) { + this.transport = transport; + this.resolutionTimeoutSeconds = resolutionTimeoutSeconds; + this.messagePrefix = messagePrefix; + this.tagChannel = tagChannel; + this.scheduler = Executors.newSingleThreadScheduledExecutor(r -> { + Thread t = new Thread(r, "incident-manager"); + t.setDaemon(true); + return t; + }); + } + + public synchronized void sendAlert(String message, List affectedServices) { + try { + if (activeIncidentThreadId == null) { + if (affectedServices.stream().allMatch(s -> s.status() == AffectedService.Status.RECOVERED)) { + return; + } + incidentStartTime = Instant.now(); + failingServices.clear(); + recoveredServices.clear(); + highLatencyServices.clear(); + applyAffectedServices(affectedServices); + activeIncidentThreadId = transport.postIncident(buildOngoingMessageText()); + startDurationUpdater(); + log.info("New incident created, thread id: {}", activeIncidentThreadId); + } else if (applyAffectedServices(affectedServices)) { + safeUpdateHeader(); + } + + try { + transport.postThreadReply(activeIncidentThreadId, message); + log.debug("Alert added to incident thread {}", activeIncidentThreadId); + } catch (Exception e) { + log.error("Failed to post alert to incident thread {}", activeIncidentThreadId, e); + } + } finally { + if (activeIncidentThreadId != null) { + lastAlertTime = Instant.now(); + // High latency is a warning only — it has no explicit recovery signal + // (HighLatencyNotification fires only when something is above threshold), + // so resolution hinges on failing services alone. + if (failingServices.isEmpty()) { + resetResolutionTimer(); + } else { + cancelResolutionTimer(); + } + } + } + } + + private boolean applyAffectedServices(List affectedServices) { + boolean changed = false; + Set latencySnapshot = null; + for (AffectedService service : affectedServices) { + String name = service.name(); + switch (service.status()) { + case FAILING -> { + Integer prev = failingServices.put(name, service.failureCount()); + if (prev == null || prev.intValue() != service.failureCount()) { + changed = true; + } + if (recoveredServices.remove(name) != null) { + changed = true; + } + } + case RECOVERED -> { + Integer lastFailureCount = failingServices.remove(name); + if (lastFailureCount != null) { + recoveredServices.put(name, lastFailureCount); + changed = true; + } + } + case HIGH_LATENCY -> { + if (latencySnapshot == null) { + latencySnapshot = new LinkedHashSet<>(); + } + latencySnapshot.add(name); + } + } + } + // HighLatencyNotification carries the full current set of high latencies, so treat it as a + // snapshot: replace highLatencyServices entirely. Without this, a brief spike would stay + // yellow in the header until the incident resolves. + if (latencySnapshot != null && !latencySnapshot.equals(highLatencyServices)) { + highLatencyServices.clear(); + highLatencyServices.addAll(latencySnapshot); + changed = true; + } + return changed; + } + + private String buildOngoingMessageText() { + StringBuilder sb = new StringBuilder(); + if (tagChannel) { + sb.append(" "); + } + if (messagePrefix != null && !messagePrefix.isEmpty()) { + sb.append("*").append(messagePrefix).append("*"); + } + sb.append(" :rotating_light:"); + Duration elapsed = Duration.between(incidentStartTime, Instant.now()); + if (elapsed.toMinutes() >= 1) { + sb.append(" (").append(formatDuration(elapsed)).append(")"); + } + if (hasAffected()) { + sb.append(" | ").append(formatAffectedServices()); + } + return sb.toString(); + } + + private boolean hasAffected() { + return !failingServices.isEmpty() || !recoveredServices.isEmpty() || !highLatencyServices.isEmpty(); + } + + private void safeUpdateHeader() { + try { + transport.updateIncident(activeIncidentThreadId, buildOngoingMessageText()); + } catch (Exception e) { + log.error("Failed to update incident message", e); + } + } + + private void resetResolutionTimer() { + cancelResolutionTimer(); + resolutionTask = scheduler.schedule(this::resolveIncident, resolutionTimeoutSeconds, TimeUnit.SECONDS); + } + + private void cancelResolutionTimer() { + if (resolutionTask != null) { + resolutionTask.cancel(false); + resolutionTask = null; + } + } + + private void startDurationUpdater() { + if (durationUpdateTask != null) { + durationUpdateTask.cancel(false); + } + durationUpdateTask = scheduler.scheduleAtFixedRate(this::updateDuration, 60, 60, TimeUnit.SECONDS); + } + + private synchronized void updateDuration() { + if (activeIncidentThreadId == null) { + return; + } + safeUpdateHeader(); + } + + private void stopDurationUpdater() { + if (durationUpdateTask != null) { + durationUpdateTask.cancel(false); + durationUpdateTask = null; + } + } + + static String formatDuration(Duration duration) { + long totalMinutes = duration.toMinutes(); + if (totalMinutes < 60) { + return totalMinutes + "m"; + } + long hours = totalMinutes / 60; + long minutes = totalMinutes % 60; + return minutes > 0 ? hours + "h" + minutes + "m" : hours + "h"; + } + + synchronized void resolveIncident() { + if (activeIncidentThreadId == null) { + return; + } + String threadId = activeIncidentThreadId; + stopDurationUpdater(); + String resolutionMessage = buildResolutionMessage(); + activeIncidentThreadId = null; + resolutionTask = null; + failingServices.clear(); + recoveredServices.clear(); + highLatencyServices.clear(); + try { + transport.updateIncident(threadId, resolutionMessage); + log.info("Incident resolved (thread was {})", threadId); + } catch (Exception e) { + log.error("Failed to send incident resolution message", e); + } + } + + private String buildResolutionMessage() { + Duration totalDuration = lastAlertTime != null + ? Duration.between(incidentStartTime, lastAlertTime) + : Duration.between(incidentStartTime, Instant.now()); + StringBuilder sb = new StringBuilder(); + if (messagePrefix != null && !messagePrefix.isEmpty()) { + sb.append("*").append(messagePrefix).append("*"); + } + sb.append(" :white_check_mark:"); + sb.append(" (").append(formatDuration(totalDuration)).append(")"); + if (hasAffected()) { + sb.append(" | ").append(formatAffectedServices()).append("\n"); + } + return sb.toString(); + } + + private String formatAffectedServices() { + StringBuilder sb = new StringBuilder(); + boolean first = true; + for (Map.Entry entry : failingServices.entrySet()) { + if (!first) sb.append(", "); + sb.append(":red_circle: ").append(entry.getKey()).append(" (").append(entry.getValue()).append(")"); + first = false; + } + for (String name : highLatencyServices) { + if (!first) sb.append(", "); + sb.append(":large_yellow_circle: ").append(name); + first = false; + } + for (Map.Entry entry : recoveredServices.entrySet()) { + if (!first) sb.append(", "); + sb.append(":large_green_circle: ").append(entry.getKey()).append(" (").append(entry.getValue()).append(")"); + first = false; + } + return sb.toString(); + } + + public void shutdown() { + scheduler.shutdownNow(); + try { + if (!scheduler.awaitTermination(5, TimeUnit.SECONDS)) { + log.warn("Incident scheduler did not terminate in time"); + } + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + } + +} diff --git a/monitoring/src/main/java/org/thingsboard/monitoring/notification/incident/IncidentTransport.java b/monitoring/src/main/java/org/thingsboard/monitoring/notification/incident/IncidentTransport.java new file mode 100644 index 0000000000..0d6b4cac2a --- /dev/null +++ b/monitoring/src/main/java/org/thingsboard/monitoring/notification/incident/IncidentTransport.java @@ -0,0 +1,26 @@ +/** + * Copyright © 2016-2026 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.monitoring.notification.incident; + +public interface IncidentTransport { + + String postIncident(String text); + + void postThreadReply(String threadId, String text); + + void updateIncident(String threadId, String text); + +} diff --git a/monitoring/src/main/java/org/thingsboard/monitoring/service/BaseHealthChecker.java b/monitoring/src/main/java/org/thingsboard/monitoring/service/BaseHealthChecker.java index b73194469f..c0e85da1ac 100644 --- a/monitoring/src/main/java/org/thingsboard/monitoring/service/BaseHealthChecker.java +++ b/monitoring/src/main/java/org/thingsboard/monitoring/service/BaseHealthChecker.java @@ -89,11 +89,10 @@ public abstract class BaseHealthChecker { diff --git a/monitoring/src/main/java/org/thingsboard/monitoring/service/BaseMonitoringService.java b/monitoring/src/main/java/org/thingsboard/monitoring/service/BaseMonitoringService.java index ea154e49c0..9c8c8fc533 100644 --- a/monitoring/src/main/java/org/thingsboard/monitoring/service/BaseMonitoringService.java +++ b/monitoring/src/main/java/org/thingsboard/monitoring/service/BaseMonitoringService.java @@ -117,28 +117,60 @@ public abstract class BaseMonitoringService, T ext } try { log.info("Starting {}", getName()); - stopWatch.start(); - String accessToken = tbClient.logIn(); - reporter.reportLatency(Latencies.LOG_IN, stopWatch.getTime()); - try (WsClient wsClient = wsClientFactory.createClient(accessToken)) { + String accessToken; + try { stopWatch.start(); - wsClient.subscribeForTelemetry(devices, getTestTelemetryKeys()).waitForReply(); - reporter.reportLatency(Latencies.WS_SUBSCRIBE, stopWatch.getTime()); + accessToken = tbClient.logIn(); + reporter.reportLatency(Latencies.LOG_IN, stopWatch.getTime()); + reporter.serviceIsOk(MonitoredServiceKey.LOGIN); + } catch (Exception e) { + reporter.serviceFailure(MonitoredServiceKey.LOGIN, e); + return; + } + + WsClient wsClient; + try { + wsClient = wsClientFactory.createClient(accessToken); + reporter.serviceIsOk(MonitoredServiceKey.WS_CONNECT); + } catch (Exception e) { + reporter.serviceFailure(MonitoredServiceKey.WS_CONNECT, e); + return; + } + + try (WsClient ws = wsClient) { + try { + stopWatch.start(); + ws.subscribeForTelemetry(devices, getTestTelemetryKeys()).waitForReply(); + reporter.reportLatency(Latencies.WS_SUBSCRIBE, stopWatch.getTime()); + reporter.serviceIsOk(MonitoredServiceKey.WS_SUBSCRIBE); + } catch (Exception e) { + reporter.serviceFailure(MonitoredServiceKey.WS_SUBSCRIBE, e); + return; + } for (BaseHealthChecker healthChecker : healthCheckers) { - check(healthChecker, wsClient); + check(healthChecker, ws); } } if (checkEdqs) { - stopWatch.start(); - checkEdqs(); - reporter.reportLatency(Latencies.EDQS_QUERY, stopWatch.getTime()); - reporter.serviceIsOk(MonitoredServiceKey.EDQS); + try { + stopWatch.start(); + checkEdqs(); + reporter.reportLatency(Latencies.EDQS_QUERY, stopWatch.getTime()); + reporter.serviceIsOk(MonitoredServiceKey.EDQS); + } catch (ServiceFailureException e) { + reporter.serviceFailure(e.getServiceKey(), e); + return; + } catch (Exception e) { + reporter.serviceFailure(MonitoredServiceKey.EDQS, e); + return; + } } reporter.reportLatencies(); + reporter.serviceIsOk(MonitoredServiceKey.GENERAL); log.debug("Finished {}", getName()); } catch (ServiceFailureException e) { reporter.serviceFailure(e.getServiceKey(), e); diff --git a/monitoring/src/main/java/org/thingsboard/monitoring/service/MonitoringReporter.java b/monitoring/src/main/java/org/thingsboard/monitoring/service/MonitoringReporter.java index b13394dbdc..75a8a819c0 100644 --- a/monitoring/src/main/java/org/thingsboard/monitoring/service/MonitoringReporter.java +++ b/monitoring/src/main/java/org/thingsboard/monitoring/service/MonitoringReporter.java @@ -25,7 +25,6 @@ import org.springframework.stereotype.Component; import org.thingsboard.common.util.JacksonUtil; import org.thingsboard.monitoring.client.TbClient; import org.thingsboard.monitoring.data.Latency; -import org.thingsboard.monitoring.data.MonitoredServiceKey; import org.thingsboard.monitoring.data.notification.HighLatencyNotification; import org.thingsboard.monitoring.data.notification.ServiceFailureNotification; import org.thingsboard.monitoring.data.notification.ServiceRecoveryNotification; @@ -119,9 +118,7 @@ public class MonitoringReporter { public void serviceIsOk(Object serviceKey) { ServiceRecoveryNotification notification = new ServiceRecoveryNotification(serviceKey); - if (!serviceKey.equals(MonitoredServiceKey.GENERAL)) { - log.info(notification.getText()); - } + log.info(notification.getText()); AtomicInteger failuresCounter = failuresCounters.get(serviceKey); if (failuresCounter != null) { if (failuresCounter.get() >= failuresThreshold) { diff --git a/monitoring/src/main/resources/tb-monitoring.yml b/monitoring/src/main/resources/tb-monitoring.yml index 053e42bb9d..768fd2e4c7 100644 --- a/monitoring/src/main/resources/tb-monitoring.yml +++ b/monitoring/src/main/resources/tb-monitoring.yml @@ -121,11 +121,24 @@ monitoring: notifications: message_prefix: '${NOTIFICATION_MESSAGE_PREFIX:}' + # Incident grouping (threads alerts into incidents, auto-resolves after timeout). + # Requires a channel that supports it — currently only Slack API (bot_token + channel_id). + incident: + # Enable incident grouping + enabled: '${INCIDENT_ENABLED:false}' + # Incident resolution timeout in seconds + resolution_timeout_s: '${INCIDENT_RESOLUTION_TIMEOUT_S:90}' + # Tag @channel in incident messages + tag_channel: '${INCIDENT_TAG_CHANNEL:false}' slack: # Enable notifying via Slack enabled: '${SLACK_NOTIFICATION_CHANNEL_ENABLED:false}' # Slack webhook url webhook_url: '${SLACK_WEBHOOK_URL:}' + # Slack Bot OAuth token (xoxb-...) for API-based messaging with incident support, requires chat:write:bot scope + bot_token: '${SLACK_BOT_TOKEN:}' + # Slack channel ID (e.g. C01234ABCDE) - required when incident feature is enabled + channel_id: '${SLACK_CHANNEL_ID:}' latency: # Enable latencies reporting diff --git a/monitoring/src/test/java/org/thingsboard/monitoring/data/notification/ServiceFailureNotificationTest.java b/monitoring/src/test/java/org/thingsboard/monitoring/data/notification/ServiceFailureNotificationTest.java new file mode 100644 index 0000000000..500a713f7b --- /dev/null +++ b/monitoring/src/test/java/org/thingsboard/monitoring/data/notification/ServiceFailureNotificationTest.java @@ -0,0 +1,97 @@ +/** + * Copyright © 2016-2026 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.monitoring.data.notification; + +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +class ServiceFailureNotificationTest { + + @Test + void stripResponseBodyRemovesNginxErrorHtml() { + String msg = "503 Service Temporarily Unavailable on POST request for \"https://domain/api/auth/login\": \"" + + "503 Service Temporarily Unavailable" + + "

503 Service Temporarily Unavailable


nginx
\""; + + String sanitized = ServiceFailureNotification.stripResponseBody(msg); + + assertThat(sanitized) + .isEqualTo("503 Service Temporarily Unavailable on POST request for \"https://domain/api/auth/login\""); + } + + @Test + void stripResponseBodyRemovesDoctypeHtml() { + String msg = "500 Internal Server Error: \"...\""; + + String sanitized = ServiceFailureNotification.stripResponseBody(msg); + + assertThat(sanitized).isEqualTo("500 Internal Server Error"); + } + + @Test + void stripResponseBodyLeavesPlainMessagesUntouched() { + String msg = "Connection refused"; + assertThat(ServiceFailureNotification.stripResponseBody(msg)).isEqualTo(msg); + } + + @Test + void stripResponseBodyHandlesNull() { + assertThat(ServiceFailureNotification.stripResponseBody(null)).isNull(); + } + + @Test + void linkifyReplacesRequestForUrlWithSlackMrkdwnLink() { + String msg = "503 Service Temporarily Unavailable on POST request for \"https://example.com/api/auth/login\""; + + assertThat(ServiceFailureNotification.linkifyRequestUrl(msg)) + .isEqualTo("503 Service Temporarily Unavailable on POST "); + } + + @Test + void linkifyReplacesRequestConnectToUrlFailed() { + String msg = "I/O error on POST request: Connect to https://example.com:443 failed: Connect timed out"; + + assertThat(ServiceFailureNotification.linkifyRequestUrl(msg)) + .isEqualTo("I/O error on POST : Connect timed out"); + } + + @Test + void linkifyLeavesMessagesWithoutRequestUrlUntouched() { + String msg = "Connection refused"; + assertThat(ServiceFailureNotification.linkifyRequestUrl(msg)).isEqualTo(msg); + } + + @Test + void linkifyHandlesNull() { + assertThat(ServiceFailureNotification.linkifyRequestUrl(null)).isNull(); + } + + @Test + void shortNameUsesShortNameProviderWhenAvailable() { + ShortNameProvider provider = () -> "MQTT"; + assertThat(ServiceFailureNotification.shortName(provider)).isEqualTo("MQTT"); + } + + @Test + void shortNameFallsBackToToStringForOtherKeys() { + Object key = new Object() { + @Override public String toString() { return "LOGIN"; } + }; + assertThat(ServiceFailureNotification.shortName(key)).isEqualTo("LOGIN"); + } + +} diff --git a/monitoring/src/test/java/org/thingsboard/monitoring/notification/incident/IncidentManagerTest.java b/monitoring/src/test/java/org/thingsboard/monitoring/notification/incident/IncidentManagerTest.java new file mode 100644 index 0000000000..b7051db534 --- /dev/null +++ b/monitoring/src/test/java/org/thingsboard/monitoring/notification/incident/IncidentManagerTest.java @@ -0,0 +1,183 @@ +/** + * Copyright © 2016-2026 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.monitoring.notification.incident; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.thingsboard.monitoring.data.notification.AffectedService; + +import java.time.Duration; +import java.util.List; +import java.util.concurrent.atomic.AtomicInteger; + +import static org.assertj.core.api.Assertions.assertThat; + +class IncidentManagerTest { + + private RecordingTransport transport; + private IncidentManager manager; + + @BeforeEach + void setUp() { + transport = new RecordingTransport(); + manager = new IncidentManager(transport, 3600L, "tbqa", false); + } + + @AfterEach + void tearDown() { + manager.shutdown(); + } + + @Test + void formatDurationRendersMinutesAndHours() { + assertThat(IncidentManager.formatDuration(Duration.ofSeconds(30))).isEqualTo("0m"); + assertThat(IncidentManager.formatDuration(Duration.ofMinutes(5))).isEqualTo("5m"); + assertThat(IncidentManager.formatDuration(Duration.ofMinutes(59))).isEqualTo("59m"); + assertThat(IncidentManager.formatDuration(Duration.ofMinutes(60))).isEqualTo("1h"); + assertThat(IncidentManager.formatDuration(Duration.ofMinutes(75))).isEqualTo("1h15m"); + assertThat(IncidentManager.formatDuration(Duration.ofMinutes(120))).isEqualTo("2h"); + } + + @Test + void firstFailureOpensIncidentAndPostsHeaderAndReply() { + manager.sendAlert("CoAP failure message", + List.of(AffectedService.failing("CoAP", 1))); + + assertThat(transport.incidents).hasSize(1); + assertThat(transport.incidents.get(0)).contains(":rotating_light:").contains(":red_circle: CoAP (1)"); + assertThat(transport.replies).hasSize(1); + assertThat(transport.replies.get(0).text()).isEqualTo("CoAP failure message"); + } + + @Test + void isolatedRecoveryWithoutActiveIncidentIsIgnored() { + manager.sendAlert("Login is OK", + List.of(AffectedService.recovered("Login"))); + + assertThat(transport.incidents).isEmpty(); + assertThat(transport.replies).isEmpty(); + assertThat(transport.updates).isEmpty(); + } + + @Test + void subsequentFailureUpdatesHeaderAndPostsReply() { + manager.sendAlert("CoAP failure", List.of(AffectedService.failing("CoAP", 1))); + manager.sendAlert("CoAP repeat", List.of(AffectedService.failing("CoAP", 3))); + + assertThat(transport.incidents).hasSize(1); + assertThat(transport.replies).hasSize(2); + assertThat(transport.updates).hasSize(1); + assertThat(transport.updates.get(0).text()).contains(":red_circle: CoAP (3)"); + } + + @Test + void recoveryAfterFailureMovesServiceToGreenAndKeepsFailureCount() { + manager.sendAlert("CoAP failure", List.of(AffectedService.failing("CoAP", 4))); + manager.sendAlert("CoAP is OK", List.of(AffectedService.recovered("CoAP"))); + + assertThat(transport.updates).hasSize(1); + String updated = transport.updates.get(0).text(); + assertThat(updated).contains(":large_green_circle: CoAP (4)").doesNotContain(":red_circle:"); + } + + @Test + void highLatencyIsTrackedAsYellow() { + manager.sendAlert("high latency", + List.of(AffectedService.highLatency("logInLatency"))); + + assertThat(transport.incidents.get(0)).contains(":large_yellow_circle: logInLatency"); + } + + @Test + void repeatingSameFailureCountDoesNotTriggerRedundantUpdate() { + manager.sendAlert("CoAP failure", List.of(AffectedService.failing("CoAP", 3))); + manager.sendAlert("CoAP still failing", List.of(AffectedService.failing("CoAP", 3))); + + assertThat(transport.updates).isEmpty(); + assertThat(transport.replies).hasSize(2); + } + + @Test + void fullLifecycleStartFailRecoverResolve() { + manager.sendAlert("Login failure", List.of(AffectedService.failing("Login", 1))); + manager.sendAlert("WS failure", List.of(AffectedService.failing("WS Connect", 1))); + manager.sendAlert("Login is OK", List.of(AffectedService.recovered("Login"))); + + assertThat(transport.incidents).hasSize(1); + + manager.resolveIncident(); + + assertThat(transport.updates).last() + .extracting(RecordingTransport.Message::text) + .asString() + .contains(":white_check_mark:") + .contains(":red_circle: WS Connect") + .contains(":large_green_circle: Login (1)"); + } + + @Test + void resolveWithoutActiveIncidentIsNoOp() { + manager.resolveIncident(); + assertThat(transport.updates).isEmpty(); + } + + @Test + void doesNotAutoResolveWhileServicesAreStillFailing() throws Exception { + manager.shutdown(); + transport = new RecordingTransport(); + manager = new IncidentManager(transport, 1L, "tbqa", false); + + manager.sendAlert("CoAP failure", List.of(AffectedService.failing("CoAP", 1))); + Thread.sleep(1500); + + assertThat(transport.updates) + .extracting(RecordingTransport.Message::text) + .noneMatch(t -> t.contains(":white_check_mark:")); + + manager.sendAlert("CoAP is OK", List.of(AffectedService.recovered("CoAP"))); + Thread.sleep(1500); + + assertThat(transport.updates) + .extracting(RecordingTransport.Message::text) + .anyMatch(t -> t.contains(":white_check_mark:")); + } + + private static class RecordingTransport implements IncidentTransport { + private final AtomicInteger threadCounter = new AtomicInteger(); + final java.util.List incidents = new java.util.ArrayList<>(); + final java.util.List replies = new java.util.ArrayList<>(); + final java.util.List updates = new java.util.ArrayList<>(); + + @Override + public String postIncident(String text) { + incidents.add(text); + return "thread-" + threadCounter.incrementAndGet(); + } + + @Override + public void postThreadReply(String threadId, String text) { + replies.add(new Message(threadId, text)); + } + + @Override + public void updateIncident(String threadId, String text) { + updates.add(new Message(threadId, text)); + } + + record Message(String threadId, String text) {} + } +} diff --git a/msa/black-box-tests/pom.xml b/msa/black-box-tests/pom.xml index 69a78c72bd..141ee1adac 100644 --- a/msa/black-box-tests/pom.xml +++ b/msa/black-box-tests/pom.xml @@ -21,7 +21,7 @@ org.thingsboard - 4.3.1.3-SNAPSHOT + 4.4.0-SNAPSHOT msa org.thingsboard.msa diff --git a/msa/edqs/pom.xml b/msa/edqs/pom.xml index 69cff7d0e6..b1034afe4e 100644 --- a/msa/edqs/pom.xml +++ b/msa/edqs/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard - 4.3.1.3-SNAPSHOT + 4.4.0-SNAPSHOT msa org.thingsboard.msa diff --git a/msa/js-executor/config/custom-environment-variables.yml b/msa/js-executor/config/custom-environment-variables.yml index 9923fe4ed3..290ae4c219 100644 --- a/msa/js-executor/config/custom-environment-variables.yml +++ b/msa/js-executor/config/custom-environment-variables.yml @@ -54,6 +54,18 @@ kafka: mechanism: "TB_QUEUE_KAFKA_CONFLUENT_SASL_MECHANISM" username: "TB_QUEUE_KAFKA_CONFLUENT_USERNAME" password: "TB_QUEUE_KAFKA_CONFLUENT_PASSWORD" + # Optional: OAuth2 Setting + oauth: + # Optional: OAuth2 Client ID + client_id: "TB_QUEUE_KAFKA_CONFLUENT_OAUTH_CLIENT_ID" + # Optional: OAuth2 Client Secret + client_secret: "TB_QUEUE_KAFKA_CONFLUENT_OAUTH_CLIENT_SECRET" + # Optional: OAuth2 Endpoint URL to get the token from + endpoint_url: "TB_QUEUE_KAFKA_CONFLUENT_OAUTH_ENDPOINT_URL" + # Optional: threshold to renew current token before it expires (ms). Token refresh is managed by this service; no equivalent setting exists on the JVM-based services. + refresh_threshold: "TB_QUEUE_KAFKA_CONFLUENT_OAUTH_REFRESH_THRESHOLD" + # Optional: OAuth2 scope/audience requested for the token. Required by some IdPs for client-credentials (e.g. Azure AD's "api:///.default") + scope: "TB_QUEUE_KAFKA_CONFLUENT_OAUTH_SCOPE" logger: level: "LOGGER_LEVEL" diff --git a/msa/js-executor/config/default.yml b/msa/js-executor/config/default.yml index 9a33190069..c84ae1db25 100644 --- a/msa/js-executor/config/default.yml +++ b/msa/js-executor/config/default.yml @@ -14,41 +14,53 @@ # 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, lz4 or none - topic_properties: "retention.ms:604800000;segment.bytes:52428800;retention.bytes:104857600;partitions:100;min.insync.replicas:1" - use_confluent_cloud: false - client_id: "kafkajs" #inject pod name to easy identify the client using /opt/kafka/bin/kafka-consumer-groups.sh + requestTimeout: "30000" # Time in milliseconds to wait for a response to a Kafka request before timing out + connectionTimeout: "1000" # Time in milliseconds to wait when establishing a connection to a Kafka broker + compression: "none" # Message compression codec for the producer: gzip, lz4 or none + topic_properties: "retention.ms:604800000;segment.bytes:52428800;retention.bytes:104857600;partitions:100;min.insync.replicas:1" # Semicolon-separated Kafka topic configuration properties applied on topic creation + use_confluent_cloud: false # Set to true to enable Confluent Cloud-specific configuration (SSL + SASL) + client_id: "kafkajs" # Kafka client identifier; inject the pod name to easily identify the client via kafka-consumer-groups.sh ssl: - enabled: false + enabled: false # Enable SSL/TLS for Kafka broker connections confluent: sasl: - mechanism: "PLAIN" + mechanism: "PLAIN" # SASL mechanism for Confluent Cloud authentication: PLAIN, SCRAM-SHA-256, or SCRAM-SHA-512 + oauth: + refresh_threshold: "60000" # How many ms before token expiry to proactively refresh it (default: 60 s) +# 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: # WARNING: setting this to "false" is DANGEROUS BY DESIGN. The non-sandbox # path compiles and runs user-supplied scripts in the host realm via @@ -57,9 +69,9 @@ script: # authors. Use "false" only as a performance trade-off in trusted, # non-public clusters where every script author is fully trusted. 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" + 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/package.json b/msa/js-executor/package.json index 0843ef2dbe..75df2f40f4 100644 --- a/msa/js-executor/package.json +++ b/msa/js-executor/package.json @@ -1,7 +1,7 @@ { "name": "thingsboard-js-executor", "private": true, - "version": "4.3.1.3", + "version": "4.4.0", "description": "ThingsBoard JavaScript Executor Microservice", "main": "server.ts", "bin": "server.js", @@ -19,6 +19,7 @@ "js-yaml": "^4.1.1", "kafkajs": "^2.2.4", "long": "^5.3.2", + "simple-oauth2": "^5.1.0", "uuid-parse": "^1.1.0", "winston": "^3.17.0", "winston-daily-rotate-file": "^5.0.0" @@ -35,6 +36,7 @@ "@types/config": "^3.3.5", "@types/express": "~5.0.3", "@types/node": "~22.17.2", + "@types/simple-oauth2": "^5.0.8", "@types/uuid-parse": "^1.0.2", "@yao-pkg/pkg": "^6.6.0", "fs-extra": "^11.3.1", diff --git a/msa/js-executor/pom.xml b/msa/js-executor/pom.xml index f57e06f4f2..a9b583ebd9 100644 --- a/msa/js-executor/pom.xml +++ b/msa/js-executor/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard - 4.3.1.3-SNAPSHOT + 4.4.0-SNAPSHOT msa org.thingsboard.msa diff --git a/msa/js-executor/queue/kafkaTemplate.ts b/msa/js-executor/queue/kafkaTemplate.ts index 175abd80f4..95c95c6e39 100644 --- a/msa/js-executor/queue/kafkaTemplate.ts +++ b/msa/js-executor/queue/kafkaTemplate.ts @@ -19,6 +19,7 @@ import fs from 'node:fs'; import { _logger, KafkaJsWinstonLogCreator } from '../config/logger'; import { JsInvokeMessageProcessor } from '../api/jsInvokeMessageProcessor' import { IQueue } from './queue.models'; +import { oauthBearerProvider } from './oAuthBearerProvider'; import { Admin, CompressionCodecs, @@ -36,6 +37,8 @@ import { KeyObject } from 'tls'; import process, { exit, kill } from 'process'; +const DEFAULT_OAUTH_REFRESH_THRESHOLD_MS = 60000; + export class KafkaTemplate implements IQueue { private logger = _logger(`kafkaTemplate`); @@ -114,11 +117,35 @@ export class KafkaTemplate implements IQueue { kafkaConfig['connectionTimeout'] = this.connectionTimeout; if (useConfluent) { - kafkaConfig['sasl'] = { - mechanism: config.get('kafka.confluent.sasl.mechanism') as any, - username: config.get('kafka.confluent.username'), - password: config.get('kafka.confluent.password') - }; + const saslMechanism = config.get('kafka.confluent.sasl.mechanism') as string; + if (saslMechanism.toLowerCase() === 'oauthbearer') { + const refreshThresholdMs = config.has('kafka.confluent.oauth.refresh_threshold') + ? Number(config.get('kafka.confluent.oauth.refresh_threshold')) + : DEFAULT_OAUTH_REFRESH_THRESHOLD_MS; + const scope = config.has('kafka.confluent.oauth.scope') + ? config.get('kafka.confluent.oauth.scope') as string + : undefined; + // Read as optional so a missing key yields '' (and the provider raises its clear + // "requires client_id, client_secret and endpoint_url" error) rather than node-config + // throwing a generic "Configuration property ... is not defined". + const optionalOauthStr = (key: string): string => config.has(key) ? config.get(key) as string : ''; + kafkaConfig['sasl'] = { + mechanism: 'oauthbearer', + oauthBearerProvider: oauthBearerProvider({ + clientId: optionalOauthStr('kafka.confluent.oauth.client_id'), + clientSecret: optionalOauthStr('kafka.confluent.oauth.client_secret'), + endpointUrl: optionalOauthStr('kafka.confluent.oauth.endpoint_url'), + refreshThresholdMs, + scope, + }) + }; + } else { + kafkaConfig['sasl'] = { + mechanism: saslMechanism as any, + username: config.get('kafka.confluent.username'), + password: config.get('kafka.confluent.password') + }; + } kafkaConfig['ssl'] = true; } diff --git a/msa/js-executor/queue/oAuthBearerProvider.ts b/msa/js-executor/queue/oAuthBearerProvider.ts new file mode 100644 index 0000000000..83ae5125b6 --- /dev/null +++ b/msa/js-executor/queue/oAuthBearerProvider.ts @@ -0,0 +1,171 @@ +/// +/// 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 { AccessToken, ClientCredentials } from 'simple-oauth2'; +import { _logger } from '../config/logger'; + +interface OauthBearerProviderOptions { + clientId: string; + clientSecret: string; + endpointUrl: string; + refreshThresholdMs: number; + // Optional OAuth2 scope. Required by some IdPs for client-credentials (e.g. Azure AD's "api:///.default"). + scope?: string; +} + +const RETRY_DELAY_MS = 5000; +const MIN_REFRESH_DELAY_MS = 1000; +// Safety margin applied at serve time: if the cached token is within this window of (or past) its +// hard expiry, fetch synchronously instead of serving it. Covers a scheduled refresh that slipped +// because the unref()'d timer was starved or the process was suspended. Also absorbs minor clock skew. +const EXPIRY_SAFETY_MS = 5000; + +export const oauthBearerProvider = (options: OauthBearerProviderOptions) => { + const logger = _logger('oauthBearerProvider'); + if (!options.clientId || !options.clientSecret || !options.endpointUrl) { + throw new Error('Kafka OAUTHBEARER requires kafka.confluent.oauth.client_id, client_secret and endpoint_url to be set'); + } + if (!/^https:\/\//i.test(options.endpointUrl)) { + logger.warn('Kafka OAuth token endpoint URL is not HTTPS (%s); client credentials will be sent unencrypted', options.endpointUrl); + } + const refreshThresholdMs = Number(options.refreshThresholdMs); + if (!Number.isFinite(refreshThresholdMs) || refreshThresholdMs < 0) { + throw new Error(`Kafka OAuth refresh_threshold must be a non-negative number, got: ${options.refreshThresholdMs}`); + } + const scope = options.scope && options.scope.trim().length > 0 ? options.scope.trim() : undefined; + let tokenUrl: URL; + try { + tokenUrl = new URL(options.endpointUrl); + } catch { + throw new Error(`Kafka OAuth endpoint_url is not a valid URL: ${options.endpointUrl}`); + } + const client = new ClientCredentials({ + client: { + id: options.clientId, + secret: options.clientSecret + }, + auth: { + // endpoint_url is the full token endpoint URL. Split it into host + path so + // simple-oauth2 does not append its default tokenPath (/oauth/token) and + // discard the real path (breaks Keycloak, Azure AD, Okta, etc.). + tokenHost: tokenUrl.origin, + tokenPath: tokenUrl.pathname + tokenUrl.search + } + }); + + // Last successfully fetched token. Kept across refreshes so a failed background refresh + // does not drop a still-valid token out from under KafkaJS (which calls this provider on + // each reauthentication). It is only swapped in after a successful refresh. + let cachedToken: string | undefined; + // Epoch ms at which cachedToken hard-expires (access_token lifetime from the IdP). 0 until the + // first successful fetch. Used to decide at serve time whether the cached token is still safe. + let cachedTokenExpiresAt = 0; + // In-flight token fetch, shared so a serve-triggered refresh and the scheduled timer (and + // concurrent KafkaJS callbacks) coalesce onto one request instead of stampeding the IdP. + let refreshInFlight: Promise | undefined; + let refreshTimer: NodeJS.Timeout | undefined; + let warnedShortLivedToken = false; + + function scheduleRefresh(delayMs: number): void { + if (refreshTimer) { + clearTimeout(refreshTimer); + } + const delay = Math.max(delayMs, MIN_REFRESH_DELAY_MS); + logger.info('Next Kafka OAuth token refresh in %dms', delay); + refreshTimer = setTimeout(() => { + // refreshTokenOnce() updates cachedToken on success and reschedules its own retry on failure; + // swallow here so a failed background refresh is not an unhandled rejection. + refreshTokenOnce().catch((err) => { + logger.error('Scheduled Kafka OAuth token refresh failed: %s', err?.message ?? err); + }); + }, delay); + refreshTimer.unref(); + } + + // Coalesce concurrent refreshes (scheduled timer, serve-time fallback, parallel KafkaJS callbacks) + // onto a single in-flight request so a near-expiry burst cannot stampede the token endpoint. + function refreshTokenOnce(): Promise { + if (!refreshInFlight) { + refreshInFlight = refreshToken().finally(() => { + refreshInFlight = undefined; + }); + } + return refreshInFlight; + } + + async function refreshToken(): Promise { + logger.info('Requesting Kafka OAuth bearer token'); + try { + // Client-credentials grant issues no refresh_token, so always request a fresh token. + const accessToken: AccessToken = await client.getToken(scope ? { scope } : {}); + + // Some IdPs return expires_in as a numeric string ("3600"); coerce before validating. + const rawExpiresIn = accessToken.token.expires_in; + const expiresIn = Number(rawExpiresIn); + if (!Number.isFinite(expiresIn) || expiresIn <= 0) { + throw new Error(`OAuth token response has invalid "expires_in": ${rawExpiresIn}`); + } + const accessTokenValue = accessToken.token.access_token; + if (typeof accessTokenValue !== 'string' || accessTokenValue.length === 0) { + throw new Error('OAuth token response has no "access_token"'); + } + + const lifetimeMs = expiresIn * 1000; + cachedToken = accessTokenValue; + cachedTokenExpiresAt = Date.now() + lifetimeMs; + + // If the token lifetime is shorter than the refresh threshold, refreshing "threshold + // before expiry" would fire immediately and clamp to MIN_REFRESH_DELAY_MS, hammering the + // token endpoint every second. Cap the effective threshold to half the lifetime instead. + let effectiveThresholdMs = refreshThresholdMs; + if (refreshThresholdMs >= lifetimeMs) { + effectiveThresholdMs = lifetimeMs / 2; + if (!warnedShortLivedToken) { + logger.warn('Kafka OAuth token lifetime (%dms) is <= refresh threshold (%dms); capping threshold to %dms to avoid hammering the token endpoint', + lifetimeMs, refreshThresholdMs, effectiveThresholdMs); + warnedShortLivedToken = true; + } + } + + const nextRefresh = lifetimeMs - effectiveThresholdMs; + scheduleRefresh(nextRefresh); + return accessTokenValue; + } catch (err) { + logger.error('Failed to obtain Kafka OAuth bearer token, retrying in %dms', RETRY_DELAY_MS); + scheduleRefresh(RETRY_DELAY_MS); + throw err; + } + } + + // Kick off the initial fetch eagerly so the first authentication is fast. + // The first fetch is retried internally; swallow here, so a startup failure is not an unhandled rejection. + refreshTokenOnce().catch(() => { /* retry already scheduled in refreshToken() */ }); + + return async function () { + // Serve the cached token while it is comfortably valid; the background timer rotates it + // proactively. If there is no token yet, or the cached one is within EXPIRY_SAFETY_MS of its + // hard expiry (a scheduled refresh slipped), fetch synchronously so KafkaJS never reauthenticates + // with an expired token. Concurrent callers coalesce onto the same request via refreshTokenOnce(). + if (!cachedToken || Date.now() >= cachedTokenExpiresAt - EXPIRY_SAFETY_MS) { + return { + value: await refreshTokenOnce() + }; + } + return { + value: cachedToken + }; + }; +}; diff --git a/msa/js-executor/yarn.lock b/msa/js-executor/yarn.lock index 5e3330e0d7..c11a29f1b7 100644 --- a/msa/js-executor/yarn.lock +++ b/msa/js-executor/yarn.lock @@ -131,6 +131,44 @@ enabled "2.0.x" kuler "^2.0.0" +"@hapi/boom@^10.0.1": + version "10.0.1" + resolved "https://registry.yarnpkg.com/@hapi/boom/-/boom-10.0.1.tgz#ebb14688275ae150aa6af788dbe482e6a6062685" + integrity sha512-ERcCZaEjdH3OgSJlyjVk8pHIFeus91CjKP3v+MpgBNp5IvGzP2l/bRiD78nqYcKPaZdbKkK5vDBVPd2ohHBlsA== + dependencies: + "@hapi/hoek" "^11.0.2" + +"@hapi/bourne@^3.0.0": + version "3.0.0" + resolved "https://registry.yarnpkg.com/@hapi/bourne/-/bourne-3.0.0.tgz#f11fdf7dda62fe8e336fa7c6642d9041f30356d7" + integrity sha512-Waj1cwPXJDucOib4a3bAISsKJVb15MKi9IvmTI/7ssVEm6sywXGjVJDhl6/umt1pK1ZS7PacXU3A1PmFKHEZ2w== + +"@hapi/hoek@^11.0.2", "@hapi/hoek@^11.0.4": + version "11.0.7" + resolved "https://registry.yarnpkg.com/@hapi/hoek/-/hoek-11.0.7.tgz#56a920793e0a42d10e530da9a64cc0d3919c4002" + integrity sha512-HV5undWkKzcB4RZUusqOpcgxOaq6VOAH7zhhIr2g3G8NF/MlFO75SjOr2NfuSx0Mh40+1FqCkagKLJRykUWoFQ== + +"@hapi/hoek@^9.0.0", "@hapi/hoek@^9.3.0": + version "9.3.0" + resolved "https://registry.yarnpkg.com/@hapi/hoek/-/hoek-9.3.0.tgz#8368869dcb735be2e7f5cb7647de78e167a251fb" + integrity sha512-/c6rf4UJlmHlC9b5BaNvzAcFv7HZ2QHaV0D4/HNlBdvFnvQq8RI4kYdhyPCl7Xj+oWvTWQ8ujhqS53LIgAe6KQ== + +"@hapi/topo@^5.1.0": + version "5.1.0" + resolved "https://registry.yarnpkg.com/@hapi/topo/-/topo-5.1.0.tgz#dc448e332c6c6e37a4dc02fd84ba8d44b9afb012" + integrity sha512-foQZKJig7Ob0BMAYBfcJk8d77QtOe7Wo4ox7ff1lQYoNNAb6jwcY1ncdoy2e9wQZzvNy7ODZCYJkK8kzmcAnAg== + dependencies: + "@hapi/hoek" "^9.0.0" + +"@hapi/wreck@^18.0.0": + version "18.1.2" + resolved "https://registry.yarnpkg.com/@hapi/wreck/-/wreck-18.1.2.tgz#1c1c84427085d7018ff4157ef3eb1e2cf080b26b" + integrity sha512-3dMnV2pfhQiyEqu8DL3VBmxkdLiRDiiUDuG79Dp+UK1gL9ZxAfDOUhB6k3D5MLqcgJJ1IARyGFhwoc1NITr/pg== + dependencies: + "@hapi/boom" "^10.0.1" + "@hapi/bourne" "^3.0.0" + "@hapi/hoek" "^11.0.2" + "@isaacs/fs-minipass@^4.0.0": version "4.0.1" resolved "https://registry.yarnpkg.com/@isaacs/fs-minipass/-/fs-minipass-4.0.1.tgz#2d59ae3ab4b38fb4270bfa23d30f8e2e86c7fe32" @@ -184,6 +222,23 @@ dependencies: "@napi-rs/triples" "^1.2.0" +"@sideway/address@^4.1.5": + version "4.1.5" + resolved "https://registry.yarnpkg.com/@sideway/address/-/address-4.1.5.tgz#4bc149a0076623ced99ca8208ba780d65a99b9d5" + integrity sha512-IqO/DUQHUkPeixNQ8n0JA6102hT9CmaljNTPmQ1u8MEhBo/R4Q8eKLN/vGZxuebwOroDB4cbpjheD4+/sKFK4Q== + dependencies: + "@hapi/hoek" "^9.0.0" + +"@sideway/formula@^3.0.1": + version "3.0.1" + resolved "https://registry.yarnpkg.com/@sideway/formula/-/formula-3.0.1.tgz#80fcbcbaf7ce031e0ef2dd29b1bfc7c3f583611f" + integrity sha512-/poHZJJVjx3L+zVD6g9KgHfYnb443oi7wLu/XKojDviHy6HOEOA6z1Trk5aR1dGcmPenJEgb2sK2I80LeS3MIg== + +"@sideway/pinpoint@^2.0.0": + version "2.0.0" + resolved "https://registry.yarnpkg.com/@sideway/pinpoint/-/pinpoint-2.0.0.tgz#cff8ffadc372ad29fd3f78277aeb29e632cc70df" + integrity sha512-RNiOoTPkptFtSVzQevY/yWtZwf/RxyVnPy/OcA9HBM3MlGDnBEYL5B41H0MTn0Uec8Hi+2qUtTfG2WWZBmMejQ== + "@tsconfig/node10@^1.0.7": version "1.0.11" resolved "https://registry.yarnpkg.com/@tsconfig/node10/-/node10-1.0.11.tgz#6ee46400685f130e278128c7b38b7e031ff5b2f2" @@ -294,6 +349,11 @@ "@types/node" "*" "@types/send" "*" +"@types/simple-oauth2@^5.0.8": + version "5.0.8" + resolved "https://registry.yarnpkg.com/@types/simple-oauth2/-/simple-oauth2-5.0.8.tgz#ec0c51a7df267e5d41b0b063aecdd34a312e9310" + integrity sha512-TehQqoOGdy3/rmFsCEGgnt1f4JhUCA0joWemGGCTbVYvoZvfBjkRsBFYmz8k0V/sn2XQZHe33L4lWxqMhIO3tQ== + "@types/triple-beam@^1.3.2": version "1.3.5" resolved "https://registry.yarnpkg.com/@types/triple-beam/-/triple-beam-1.3.5.tgz#74fef9ffbaa198eb8b588be029f38b00299caa2c" @@ -623,6 +683,13 @@ debug@4, debug@^4, debug@^4.3.5, debug@^4.4.0: dependencies: ms "^2.1.3" +debug@^4.3.4: + version "4.4.3" + resolved "https://registry.yarnpkg.com/debug/-/debug-4.4.3.tgz#c6ae432d9bd9662582fce08709b038c58e9e3d6a" + integrity sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA== + dependencies: + ms "^2.1.3" + decompress-response@^6.0.0: version "6.0.0" resolved "https://registry.yarnpkg.com/decompress-response/-/decompress-response-6.0.0.tgz#ca387612ddb7e104bd16d85aab00d5ecf09c66fc" @@ -1029,6 +1096,17 @@ isarray@~1.0.0: resolved "https://registry.yarnpkg.com/isarray/-/isarray-1.0.0.tgz#bb935d48582cba168c06834957a54a3e07124f11" integrity sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ== +joi@^17.6.4: + version "17.13.3" + resolved "https://registry.yarnpkg.com/joi/-/joi-17.13.3.tgz#0f5cc1169c999b30d344366d384b12d92558bcec" + integrity sha512-otDA4ldcIx+ZXsKHWmp0YizCweVRZG96J10b0FevjfuncLO1oX59THoAmHkNubYJ+9gWsYsp5k8v4ib6oDv1fA== + dependencies: + "@hapi/hoek" "^9.3.0" + "@hapi/topo" "^5.1.0" + "@sideway/address" "^4.1.5" + "@sideway/formula" "^3.0.1" + "@sideway/pinpoint" "^2.0.0" + js-yaml@^4.1.1: version "4.1.1" resolved "https://registry.yarnpkg.com/js-yaml/-/js-yaml-4.1.1.tgz#854c292467705b699476e1a2decc0c8a3458806b" @@ -1549,6 +1627,16 @@ simple-get@^4.0.0: once "^1.3.1" simple-concat "^1.0.0" +simple-oauth2@^5.1.0: + version "5.1.0" + resolved "https://registry.yarnpkg.com/simple-oauth2/-/simple-oauth2-5.1.0.tgz#1398fe2b8f4b4066298d63c155501b31b42238f2" + integrity sha512-gWDa38Ccm4MwlG5U7AlcJxPv3lvr80dU7ARJWrGdgvOKyzSj1gr3GBPN1rABTedAYvC/LsGYoFuFxwDBPtGEbw== + dependencies: + "@hapi/hoek" "^11.0.4" + "@hapi/wreck" "^18.0.0" + debug "^4.3.4" + joi "^17.6.4" + simple-swizzle@^0.2.2: version "0.2.2" resolved "https://registry.yarnpkg.com/simple-swizzle/-/simple-swizzle-0.2.2.tgz#a4da6b635ffcccca33f70d17cb92592de95e557a" diff --git a/msa/monitoring/pom.xml b/msa/monitoring/pom.xml index 9502552a52..a968d693df 100644 --- a/msa/monitoring/pom.xml +++ b/msa/monitoring/pom.xml @@ -22,7 +22,7 @@ 4.0.0 org.thingsboard - 4.3.1.3-SNAPSHOT + 4.4.0-SNAPSHOT msa diff --git a/msa/pom.xml b/msa/pom.xml index 1498847ce3..4e09ad0e0c 100644 --- a/msa/pom.xml +++ b/msa/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard - 4.3.1.3-SNAPSHOT + 4.4.0-SNAPSHOT thingsboard msa @@ -177,7 +177,7 @@ - 4.3.1-latest + 4.4.0-latest false diff --git a/msa/tb-node/pom.xml b/msa/tb-node/pom.xml index df53fda9c1..6f28a25c51 100644 --- a/msa/tb-node/pom.xml +++ b/msa/tb-node/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard - 4.3.1.3-SNAPSHOT + 4.4.0-SNAPSHOT msa org.thingsboard.msa diff --git a/msa/tb/docker-cassandra/Dockerfile b/msa/tb/docker-cassandra/Dockerfile index 9570358c1e..f3cc0a5d66 100644 --- a/msa/tb/docker-cassandra/Dockerfile +++ b/msa/tb/docker-cassandra/Dockerfile @@ -14,7 +14,7 @@ # limitations under the License. # -FROM thingsboard/openjdk17:bookworm-slim +FROM ${docker.base.image} ENV PG_MAJOR=16 @@ -40,6 +40,21 @@ ENV PATH=$PATH:/usr/lib/postgresql/$PG_MAJOR/bin ENV PGLOG=/var/log/postgres ENV CASSANDRA_LOG=/var/log/cassandra +# For SVG renderer Debian Trixie slim fonts should be installed explicitly +RUN echo "Previously installed font libraries:" \ + && (dpkg -l | grep -E 'font|dejavu|harfbuzz' || echo " (none)") \ + && apt-get update \ + && apt-get install -y --no-install-recommends \ + libharfbuzz0b fontconfig fonts-dejavu-core \ + && rm -rf /var/lib/apt/lists/* \ + && echo "Current fonts libraries:" \ + && (dpkg -l | grep -E 'font|dejavu|harfbuzz') \ + && echo "Font config list:" \ + && fc-list && test -n "$(fc-list)" \ + && (fc-list | grep -i dejavu) \ + && echo "Harfbuzz library test with ldconfig:" \ + && (ldconfig -p | grep harfbuzz) + COPY logback.xml ${pkg.name}.conf start-db.sh stop-db.sh start-tb.sh upgrade-tb.sh install-tb.sh ${pkg.name}.deb /tmp/ # Keep base image's customized conffiles (e.g. /etc/java-17-openjdk/security/java.security) @@ -48,17 +63,16 @@ COPY logback.xml ${pkg.name}.conf start-db.sh stop-db.sh start-tb.sh upgrade-tb. ENV DEBIAN_FRONTEND=noninteractive RUN apt-get update \ && apt-get install -y --no-install-recommends wget nmap procps gnupg2 \ - && echo "deb http://apt.postgresql.org/pub/repos/apt/ $(. /etc/os-release && echo -n $VERSION_CODENAME)-pgdg main" | tee --append /etc/apt/sources.list.d/pgdg.list > /dev/null \ - && wget -q https://www.postgresql.org/media/keys/ACCC4CF8.asc -O- | apt-key add - \ - && echo "deb https://debian.cassandra.apache.org 40x main" | tee -a /etc/apt/sources.list.d/cassandra.sources.list > /dev/null \ - && wget -q https://downloads.apache.org/cassandra/KEYS -O- | apt-key add - \ + && mkdir -p /etc/apt/keyrings \ + && wget -q https://www.postgresql.org/media/keys/ACCC4CF8.asc -O- | gpg --dearmor -o /etc/apt/keyrings/postgresql.gpg \ + && echo "deb [signed-by=/etc/apt/keyrings/postgresql.gpg] http://apt.postgresql.org/pub/repos/apt/ $(. /etc/os-release && echo -n $VERSION_CODENAME)-pgdg main" | tee /etc/apt/sources.list.d/pgdg.list > /dev/null \ + && wget -q https://downloads.apache.org/cassandra/KEYS -O- | gpg --dearmor -o /etc/apt/keyrings/cassandra.gpg \ + && echo "deb [signed-by=/etc/apt/keyrings/cassandra.gpg] https://debian.cassandra.apache.org 40x main" | tee /etc/apt/sources.list.d/cassandra.sources.list > /dev/null \ && apt-get update \ && apt-get install -y --no-install-recommends \ -o Dpkg::Options::="--force-confdef" -o Dpkg::Options::="--force-confold" \ cassandra cassandra-tools postgresql-${PG_MAJOR} \ && rm -rf /var/lib/apt/lists/* \ - && update-rc.d cassandra disable \ - && update-rc.d postgresql disable \ && apt-get purge -y --auto-remove \ && sed -i.old '/ulimit/d' /etc/init.d/cassandra \ && chmod a+x /tmp/*.sh \ diff --git a/msa/tb/docker-postgres/Dockerfile b/msa/tb/docker-postgres/Dockerfile index 0a0f61f904..871154a455 100644 --- a/msa/tb/docker-postgres/Dockerfile +++ b/msa/tb/docker-postgres/Dockerfile @@ -14,7 +14,7 @@ # limitations under the License. # -FROM thingsboard/openjdk17:bookworm-slim +FROM ${docker.base.image} ENV PG_MAJOR 12 @@ -32,16 +32,31 @@ ENV SPRING_DATASOURCE_USERNAME=${pkg.user} ENV PGLOG=/var/log/postgres +# For SVG renderer Debian Trixie slim fonts should be installed explicitly +RUN echo "Previously installed font libraries:" \ + && (dpkg -l | grep -E 'font|dejavu|harfbuzz' || echo " (none)") \ + && apt-get update \ + && apt-get install -y --no-install-recommends \ + libharfbuzz0b fontconfig fonts-dejavu-core \ + && rm -rf /var/lib/apt/lists/* \ + && echo "Current fonts libraries:" \ + && (dpkg -l | grep -E 'font|dejavu|harfbuzz') \ + && echo "Font config list:" \ + && fc-list && test -n "$(fc-list)" \ + && (fc-list | grep -i dejavu) \ + && echo "Harfbuzz library test with ldconfig:" \ + && (ldconfig -p | grep harfbuzz) + COPY logback.xml ${pkg.name}.conf start-db.sh stop-db.sh start-tb.sh upgrade-tb.sh install-tb.sh ${pkg.name}.deb /tmp/ RUN apt-get update \ && apt-get install -y --no-install-recommends wget gnupg2 \ - && echo "deb http://apt.postgresql.org/pub/repos/apt/ $(. /etc/os-release && echo -n $VERSION_CODENAME)-pgdg main" | tee --append /etc/apt/sources.list.d/pgdg.list > /dev/null \ - && wget -q https://www.postgresql.org/media/keys/ACCC4CF8.asc -O- | apt-key add - \ + && mkdir -p /etc/apt/keyrings \ + && wget -q https://www.postgresql.org/media/keys/ACCC4CF8.asc -O- | gpg --dearmor -o /etc/apt/keyrings/postgresql.gpg \ + && echo "deb [signed-by=/etc/apt/keyrings/postgresql.gpg] http://apt.postgresql.org/pub/repos/apt/ $(. /etc/os-release && echo -n $VERSION_CODENAME)-pgdg main" | tee /etc/apt/sources.list.d/pgdg.list > /dev/null \ && apt-get update \ && apt-get install -y --no-install-recommends postgresql-${PG_MAJOR} \ && rm -rf /var/lib/apt/lists/* \ - && update-rc.d postgresql disable \ && apt-get purge -y --auto-remove \ && chmod a+x /tmp/*.sh \ && mv /tmp/start-tb.sh /usr/bin \ diff --git a/msa/tb/pom.xml b/msa/tb/pom.xml index 327668587f..86651cd311 100644 --- a/msa/tb/pom.xml +++ b/msa/tb/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard - 4.3.1.3-SNAPSHOT + 4.4.0-SNAPSHOT msa org.thingsboard.msa diff --git a/msa/transport/coap/pom.xml b/msa/transport/coap/pom.xml index c496c8c8d0..60aa546e1d 100644 --- a/msa/transport/coap/pom.xml +++ b/msa/transport/coap/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard.msa - 4.3.1.3-SNAPSHOT + 4.4.0-SNAPSHOT transport org.thingsboard.msa.transport diff --git a/msa/transport/http/pom.xml b/msa/transport/http/pom.xml index e15e74c8b7..b0c0e7b625 100644 --- a/msa/transport/http/pom.xml +++ b/msa/transport/http/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard.msa - 4.3.1.3-SNAPSHOT + 4.4.0-SNAPSHOT transport org.thingsboard.msa.transport diff --git a/msa/transport/lwm2m/pom.xml b/msa/transport/lwm2m/pom.xml index 7fe35560b3..296a754c38 100644 --- a/msa/transport/lwm2m/pom.xml +++ b/msa/transport/lwm2m/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard.msa - 4.3.1.3-SNAPSHOT + 4.4.0-SNAPSHOT transport org.thingsboard.msa.transport diff --git a/msa/transport/mqtt/pom.xml b/msa/transport/mqtt/pom.xml index dcceba1e43..dc8fc5b39a 100644 --- a/msa/transport/mqtt/pom.xml +++ b/msa/transport/mqtt/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard.msa - 4.3.1.3-SNAPSHOT + 4.4.0-SNAPSHOT transport org.thingsboard.msa.transport diff --git a/msa/transport/pom.xml b/msa/transport/pom.xml index cf2411cf3e..92978fcaa7 100644 --- a/msa/transport/pom.xml +++ b/msa/transport/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard - 4.3.1.3-SNAPSHOT + 4.4.0-SNAPSHOT msa org.thingsboard.msa diff --git a/msa/transport/snmp/pom.xml b/msa/transport/snmp/pom.xml index b83b035b76..ed441a2208 100644 --- a/msa/transport/snmp/pom.xml +++ b/msa/transport/snmp/pom.xml @@ -21,7 +21,7 @@ org.thingsboard.msa transport - 4.3.1.3-SNAPSHOT + 4.4.0-SNAPSHOT org.thingsboard.msa.transport diff --git a/msa/vc-executor-docker/pom.xml b/msa/vc-executor-docker/pom.xml index fc1de84048..2eef220b7d 100644 --- a/msa/vc-executor-docker/pom.xml +++ b/msa/vc-executor-docker/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard - 4.3.1.3-SNAPSHOT + 4.4.0-SNAPSHOT msa org.thingsboard.msa diff --git a/msa/vc-executor/pom.xml b/msa/vc-executor/pom.xml index 0da1dbbbf4..028b84f98e 100644 --- a/msa/vc-executor/pom.xml +++ b/msa/vc-executor/pom.xml @@ -21,7 +21,7 @@ org.thingsboard - 4.3.1.3-SNAPSHOT + 4.4.0-SNAPSHOT msa org.thingsboard.msa 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 ab806701e5..9d7a890690 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). @@ -103,11 +107,23 @@ queue: # The endpoint identification algorithm used by clients to validate server hostname. The default value is https ssl.algorithm: "${TB_QUEUE_KAFKA_CONFLUENT_SSL_ALGORITHM:https}" # The mechanism used to authenticate Schema Registry requests. SASL/PLAIN should only be used with TLS/SSL as a transport layer to ensure that clear passwords are not transmitted on the wire without encryption + # Set to OAUTHBEARER to authenticate via OAuth2/OIDC client-credentials (configure the oauth.* settings below) sasl.mechanism: "${TB_QUEUE_KAFKA_CONFLUENT_SASL_MECHANISM:PLAIN}" # Using JAAS Configuration for specifying multiple SASL mechanisms on a broker sasl.config: "${TB_QUEUE_KAFKA_CONFLUENT_SASL_JAAS_CONFIG:org.apache.kafka.common.security.plain.PlainLoginModule required username=\"CLUSTER_API_KEY\" password=\"CLUSTER_API_SECRET\";}" # Protocol used to communicate with brokers. Valid values are: PLAINTEXT, SSL, SASL_PLAINTEXT, SASL_SSL security.protocol: "${TB_QUEUE_KAFKA_CONFLUENT_SECURITY_PROTOCOL:SASL_SSL}" + # Optional: OAuth2 (OIDC) client-credentials settings. Applied only when sasl.mechanism is OAUTHBEARER. + # ThingsBoard wires Kafka's built-in OAuthBearerLoginCallbackHandler; the token is fetched and refreshed automatically. + oauth: + # Optional: OAuth2 client id + client-id: "${TB_QUEUE_KAFKA_CONFLUENT_OAUTH_CLIENT_ID:}" + # Optional: OAuth2 client secret + client-secret: "${TB_QUEUE_KAFKA_CONFLUENT_OAUTH_CLIENT_SECRET:}" + # Optional: OAuth2/OIDC token endpoint URL to request the bearer token from + endpoint-url: "${TB_QUEUE_KAFKA_CONFLUENT_OAUTH_ENDPOINT_URL:}" + # Optional: OAuth2 scope/audience requested for the token. Required by some IdPs for client-credentials (e.g. Azure AD's "api:///.default") + scope: "${TB_QUEUE_KAFKA_CONFLUENT_OAUTH_SCOPE:}" # Key-value properties for Kafka consumer per specific topic, e.g. tb_ota_package is a topic name for ota, tb_rule_engine.sq is a topic name for default SequentialByOriginator queue. # Check TB_QUEUE_CORE_OTA_TOPIC and TB_QUEUE_RE_SQ_TOPIC params consumer-properties-per-topic: @@ -196,6 +212,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 +223,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 +239,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 +248,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 +257,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/package.json b/msa/web-ui/package.json index 6dc75cc9dd..f93bc52c9b 100644 --- a/msa/web-ui/package.json +++ b/msa/web-ui/package.json @@ -1,7 +1,7 @@ { "name": "thingsboard-web-ui", "private": true, - "version": "4.3.1.3", + "version": "4.4.0", "description": "ThingsBoard Web UI Microservice", "main": "server.ts", "bin": "server.js", diff --git a/msa/web-ui/pom.xml b/msa/web-ui/pom.xml index 02ac55d4a8..fb0eb2c066 100644 --- a/msa/web-ui/pom.xml +++ b/msa/web-ui/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard - 4.3.1.3-SNAPSHOT + 4.4.0-SNAPSHOT msa org.thingsboard.msa diff --git a/netty-mqtt/pom.xml b/netty-mqtt/pom.xml index 92766bb38d..c23aaabf80 100644 --- a/netty-mqtt/pom.xml +++ b/netty-mqtt/pom.xml @@ -19,11 +19,11 @@ 4.0.0 org.thingsboard - 4.3.1.3-SNAPSHOT + 4.4.0-SNAPSHOT thingsboard netty-mqtt - 4.3.1.3-SNAPSHOT + 4.4.0-SNAPSHOT jar Netty MQTT Client diff --git a/packaging/java/build.gradle b/packaging/java/build.gradle index 16d3db5131..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" 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 009d1aa460..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" diff --git a/pom.xml b/pom.xml index 069dc0954e..80941b67c1 100755 --- a/pom.xml +++ b/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard thingsboard - 4.3.1.3-SNAPSHOT + 4.4.0-SNAPSHOT pom Thingsboard @@ -28,8 +28,8 @@ 2016 - 17 - 17 + 25 + 25 ${basedir} none none @@ -62,7 +62,7 @@ ${project.name} /var/log/${pkg.name} /usr/share/${pkg.name} - 4.3.1.2 + 4.4.0 3.5.15 - 3.25.5 + 3.25.5 1.76.0 1.2.10 1.18.46 @@ -143,6 +143,7 @@ 4.0.2 1.7.5 3.8.0 + 1.18.4 1.16.1-TB1 2.43.0 1.24 @@ -170,7 +171,7 @@ 0.4.8 1.0.0 - 1.39.0 + 1.48.0 6.6.0 1.35.0 1.6.1 @@ -685,7 +686,7 @@ maven-compiler-plugin 3.11.0 - 17 + 25 -Xlint:deprecation -Xlint:removal @@ -729,7 +730,7 @@ org.thingsboard gradle-maven-plugin - 1.0.12 + 1.0.15 com.github.eirslett @@ -743,6 +744,7 @@ -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" @@ -1023,6 +1025,16 @@ pom import + + net.bytebuddy + byte-buddy + ${bytebuddy.version} + + + net.bytebuddy + byte-buddy-agent + ${bytebuddy.version} + org.thingsboard.langchain4j langchain4j-bom @@ -2046,4 +2058,10 @@ + + + thingsboard-repo + https://repo.thingsboard.io/artifactory/libs-release-public + + diff --git a/rest-client/pom.xml b/rest-client/pom.xml index cc0e1009d5..9996b19d00 100644 --- a/rest-client/pom.xml +++ b/rest-client/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard - 4.3.1.3-SNAPSHOT + 4.4.0-SNAPSHOT thingsboard rest-client diff --git a/rule-engine/pom.xml b/rule-engine/pom.xml index 976dd842b5..bba58e92a8 100644 --- a/rule-engine/pom.xml +++ b/rule-engine/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard - 4.3.1.3-SNAPSHOT + 4.4.0-SNAPSHOT thingsboard rule-engine diff --git a/rule-engine/rule-engine-api/pom.xml b/rule-engine/rule-engine-api/pom.xml index 85b305f1f2..a9f58a08b3 100644 --- a/rule-engine/rule-engine-api/pom.xml +++ b/rule-engine/rule-engine-api/pom.xml @@ -22,7 +22,7 @@ 4.0.0 org.thingsboard - 4.3.1.3-SNAPSHOT + 4.4.0-SNAPSHOT rule-engine org.thingsboard.rule-engine diff --git a/rule-engine/rule-engine-api/src/main/java/org/thingsboard/rule/engine/api/util/TbNodeUtils.java b/rule-engine/rule-engine-api/src/main/java/org/thingsboard/rule/engine/api/util/TbNodeUtils.java index 4a0f1883c0..8fac42e029 100644 --- a/rule-engine/rule-engine-api/src/main/java/org/thingsboard/rule/engine/api/util/TbNodeUtils.java +++ b/rule-engine/rule-engine-api/src/main/java/org/thingsboard/rule/engine/api/util/TbNodeUtils.java @@ -15,6 +15,7 @@ */ package org.thingsboard.rule.engine.api.util; +import com.fasterxml.jackson.core.io.JsonStringEncoder; import com.fasterxml.jackson.databind.JsonNode; import org.thingsboard.common.util.JacksonUtil; import org.thingsboard.rule.engine.api.TbNodeConfiguration; @@ -57,8 +58,12 @@ public final class TbNodeUtils { } public static String processPattern(String pattern, TbMsg tbMsg) { + return processPattern(pattern, tbMsg, false); + } + + public static String processPattern(String pattern, TbMsg tbMsg, boolean escapeJsonValues) { try { - String result = processPattern(pattern, tbMsg.getMetaData()); + String result = processPattern(pattern, tbMsg.getMetaData(), escapeJsonValues); JsonNode json = JacksonUtil.toJsonNode(tbMsg.getData()); result = result.replace(ALL_DATA_TEMPLATE, JacksonUtil.toString(json)); @@ -79,7 +84,11 @@ public final class TbNodeUtils { } if (jsonNode != null && jsonNode.isValueNode()) { - result = result.replace(formatDataVarTemplate(group), jsonNode.asText()); + String value = jsonNode.asText(); + if (escapeJsonValues) { + value = escapeJsonValue(value); + } + result = result.replace(formatDataVarTemplate(group), value); } } } @@ -89,22 +98,31 @@ public final class TbNodeUtils { } } - private static String processPattern(String pattern, TbMsgMetaData metaData) { + private static String processPattern(String pattern, TbMsgMetaData metaData, boolean escapeJsonValues) { String replacement = metaData.isEmpty() ? "{}" : JacksonUtil.toString(metaData.getData()); pattern = pattern.replace(ALL_METADATA_TEMPLATE, replacement); - return processTemplate(pattern, metaData.values()); + return processTemplate(pattern, metaData.values(), escapeJsonValues); + } + + private static String processPattern(String pattern, TbMsgMetaData metaData) { + return processPattern(pattern, metaData, false); } public static String processTemplate(String template, Map data) { + return processTemplate(template, data, false); + } + + private static String processTemplate(String template, Map data, boolean escapeJsonValues) { String result = template; for (Map.Entry kv : data.entrySet()) { - result = processVar(result, kv.getKey(), kv.getValue()); + String value = escapeJsonValues ? escapeJsonValue(kv.getValue()) : kv.getValue(); + result = result.replace(formatMetadataVarTemplate(kv.getKey()), value); } return result; } - private static String processVar(String pattern, String key, String val) { - return pattern.replace(formatMetadataVarTemplate(key), val); + private static String escapeJsonValue(String value) { + return new String(JsonStringEncoder.getInstance().quoteAsString(value)); } static String formatDataVarTemplate(String key) { diff --git a/rule-engine/rule-engine-api/src/test/java/org/thingsboard/rule/engine/api/util/TbNodeUtilsTest.java b/rule-engine/rule-engine-api/src/test/java/org/thingsboard/rule/engine/api/util/TbNodeUtilsTest.java index 18003d6b70..882e2080d7 100644 --- a/rule-engine/rule-engine-api/src/test/java/org/thingsboard/rule/engine/api/util/TbNodeUtilsTest.java +++ b/rule-engine/rule-engine-api/src/test/java/org/thingsboard/rule/engine/api/util/TbNodeUtilsTest.java @@ -305,6 +305,50 @@ public class TbNodeUtilsTest { assertThat(actual, is(expected)); } + @Test + public void testProcessPatternWithJsonEscaping() { + String pattern = "{\"name\":\"${user}\",\"desc\":\"$[description]\"}"; + TbMsgMetaData md = new TbMsgMetaData(); + md.putValue("user", "John \"Doe\""); + + ObjectNode node = JacksonUtil.newObjectNode(); + node.put("description", "line1\nline2"); + + TbMsg msg = TbMsg.newMsg() + .type(TbMsgType.POST_TELEMETRY_REQUEST) + .originator(TenantId.SYS_TENANT_ID) + .copyMetaData(md) + .data(JacksonUtil.toString(node)) + .build(); + + String result = TbNodeUtils.processPattern(pattern, msg, true); + Assertions.assertEquals("{\"name\":\"John \\\"Doe\\\"\",\"desc\":\"line1\\nline2\"}", result); + + // Verify the result is valid JSON + Assertions.assertDoesNotThrow(() -> JacksonUtil.toJsonNode(result)); + } + + @Test + public void testProcessPatternWithoutJsonEscaping() { + String pattern = "Hello ${user}, desc: $[description]"; + TbMsgMetaData md = new TbMsgMetaData(); + md.putValue("user", "John \"Doe\""); + + ObjectNode node = JacksonUtil.newObjectNode(); + node.put("description", "line1\nline2"); + + TbMsg msg = TbMsg.newMsg() + .type(TbMsgType.POST_TELEMETRY_REQUEST) + .originator(TenantId.SYS_TENANT_ID) + .copyMetaData(md) + .data(JacksonUtil.toString(node)) + .build(); + + // Without escaping, raw values are substituted as-is + String result = TbNodeUtils.processPattern(pattern, msg, false); + Assertions.assertEquals("Hello John \"Doe\", desc: line1\nline2", result); + } + @Test public void testMixedAllDataMetadataAndNormalTemplates() { // GIVEN diff --git a/rule-engine/rule-engine-components/pom.xml b/rule-engine/rule-engine-components/pom.xml index 4a3aaa5323..c1cfcf13c8 100644 --- a/rule-engine/rule-engine-components/pom.xml +++ b/rule-engine/rule-engine-components/pom.xml @@ -22,7 +22,7 @@ 4.0.0 org.thingsboard - 4.3.1.3-SNAPSHOT + 4.4.0-SNAPSHOT rule-engine org.thingsboard.rule-engine @@ -157,6 +157,10 @@ org.thingsboard.langchain4j langchain4j + + org.apache.httpcomponents.core5 + httpcore5 + diff --git a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/rest/TbHttpClient.java b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/rest/TbHttpClient.java index de1e73cb66..8e017e21f3 100644 --- a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/rest/TbHttpClient.java +++ b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/rest/TbHttpClient.java @@ -21,6 +21,7 @@ import io.netty.handler.ssl.SslContext; import io.netty.handler.timeout.ReadTimeoutHandler; import lombok.Data; import lombok.extern.slf4j.Slf4j; +import org.apache.hc.core5.net.URIBuilder; import org.springframework.http.HttpHeaders; import org.springframework.http.HttpMethod; import org.springframework.http.HttpStatus; @@ -41,6 +42,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.core.scheduler.Schedulers; @@ -50,10 +52,12 @@ import reactor.netty.transport.ProxyProvider; import javax.net.ssl.SSLException; import java.net.URI; +import java.net.URISyntaxException; import java.nio.charset.StandardCharsets; import java.util.Base64; import java.util.List; import java.util.Map; +import java.util.Objects; import java.util.Properties; import java.util.concurrent.BlockingQueue; import java.util.concurrent.LinkedBlockingQueue; @@ -166,10 +170,6 @@ 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)); @@ -223,10 +223,7 @@ public class TbHttpClient { } EventLoopGroup getSharedOrCreateEventLoopGroup(EventLoopGroup eventLoopGroupShared) { - if (eventLoopGroupShared != null) { - return eventLoopGroupShared; - } - return this.eventLoopGroup = new NioEventLoopGroup(); + return Objects.requireNonNullElseGet(eventLoopGroupShared, () -> this.eventLoopGroup = new NioEventLoopGroup()); } private void checkSystemProxyProperties() throws TbNodeException { @@ -341,8 +338,22 @@ public class TbHttpClient { boolean asyncStarted = false; try { String endpointUrl = TbNodeUtils.processPattern(config.getRestEndpointUrlPattern(), task.msg()); + + List> processedQueryParams; + if (config.getQueryParams() != null) { + processedQueryParams = config.getQueryParams().stream() + .map(param -> { + var processedParamName = TbNodeUtils.processPattern(param.key(), task.msg()); + var processedParamValue = TbNodeUtils.processPattern(param.value(), task.msg()); + return new KeyValueEntry<>(processedParamName, processedParamValue); + }) + .toList(); + } else { + processedQueryParams = null; + } + HttpMethod method = HttpMethod.valueOf(config.getRequestMethod()); - URI uri = buildEncodedUri(endpointUrl); + URI uri = buildEncodedUri(endpointUrl, processedQueryParams); RequestBodySpec req = webClient .method(method) @@ -352,7 +363,7 @@ public class TbHttpClient { if ((HttpMethod.POST.equals(method) || HttpMethod.PUT.equals(method) || HttpMethod.PATCH.equals(method) || HttpMethod.DELETE.equals(method)) && !config.isIgnoreRequestBody()) { - req.body(BodyInserters.fromValue(getData(task.msg(), config.isParseToPlainText()))); + req.body(BodyInserters.fromValue(getRequestBody(task.msg()))); } req.retrieve() @@ -398,13 +409,13 @@ public class TbHttpClient { if (origin instanceof WebClientResponseException restClientResponseException && restClientResponseException.getStatusCode().is2xxSuccessful()) { // return cause instead of original exception in case 2xx status code - // this will provide meaningful error message to the user + // this will provide a meaningful error message to the user return new RuntimeException(restClientResponseException.getCause()); } return origin; } - public URI buildEncodedUri(String endpointUrl) { + public URI buildEncodedUri(String endpointUrl, List> queryParams) { if (endpointUrl == null) { throw new RuntimeException("Url string cannot be null!"); } @@ -412,7 +423,13 @@ public class TbHttpClient { throw new RuntimeException("Url string cannot be empty!"); } - URI uri = UriComponentsBuilder.fromUriString(endpointUrl).build().encode().toUri(); + URI uri; + if (queryParams != null) { + uri = buildEncodedUriNew(endpointUrl, queryParams); + } else { + uri = buildEncodedUriLegacy(endpointUrl); + } + if (uri.getScheme() == null || uri.getScheme().isEmpty()) { throw new RuntimeException("Transport scheme(protocol) must be provided!"); } @@ -428,6 +445,51 @@ public class TbHttpClient { return uri; } + private URI buildEncodedUriNew(String endpointUrl, List> queryParams) { + try { + URIBuilder builder = new URIBuilder(endpointUrl); + queryParams.forEach(param -> builder.addParameter(param.key(), param.value())); + return builder.build(); + } catch (URISyntaxException e) { + throw new IllegalArgumentException( + "Invalid request URL: '" + endpointUrl + "'. The URL must be valid and properly encoded. " + + "If URL contains special characters (e.g., spaces), they must be percent-encoded (e.g., '/my device/' should be '/my%20device/').", e); + } + } + + /** + * @deprecated Query params embedded in the URL string are not encoded correctly. + *

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

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

+ * Use {@link #buildEncodedUriNew} with a separate query params list, + * where values are encoded exactly once: {@code +} → {@code %2B}, {@code @} → {@code %40}. + */ + @Deprecated(forRemoval = true) + private URI buildEncodedUriLegacy(String endpointUrl) { + return UriComponentsBuilder.fromUriString(endpointUrl).build().encode().toUri(); + } + + private Object getRequestBody(TbMsg msg) { + if (StringUtils.isNotBlank(config.getRequestBodyTemplate())) { + boolean escapeJson = !config.isParseToPlainText(); + String processedTemplate = TbNodeUtils.processPattern(config.getRequestBodyTemplate(), msg, escapeJson); + if (config.isParseToPlainText()) { + return processedTemplate; + } + try { + return JacksonUtil.toJsonNode(processedTemplate); + } catch (Exception e) { + throw new RuntimeException("Request body template produced invalid JSON: " + processedTemplate, e); + } + } + return getData(msg, config.isParseToPlainText()); + } + private Object getData(TbMsg tbMsg, boolean parseToPlainText) { String data = tbMsg.getData(); return parseToPlainText ? JacksonUtil.toPlainText(data) : JacksonUtil.toJsonNode(data); @@ -451,7 +513,7 @@ public class TbHttpClient { headers.forEach((key, values) -> { if (values != null && !values.isEmpty()) { if (values.size() == 1) { - consumer.accept(key, values.get(0)); + consumer.accept(key, values.getFirst()); } else { consumer.accept(key, JacksonUtil.toString(values)); } diff --git a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/rest/TbRestApiCallNode.java b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/rest/TbRestApiCallNode.java index 02ed33138d..4120dd7ad8 100644 --- a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/rest/TbRestApiCallNode.java +++ b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/rest/TbRestApiCallNode.java @@ -27,17 +27,24 @@ import org.thingsboard.rule.engine.external.TbAbstractExternalNode; import org.thingsboard.server.common.data.plugin.ComponentType; import org.thingsboard.server.common.data.util.TbPair; import org.thingsboard.server.common.msg.TbMsg; +import org.thingsboard.server.exception.DataValidationException; +import java.util.Iterator; import java.util.List; +import java.util.Set; + +import static org.thingsboard.server.dao.service.ConstraintValidator.validateFields; @RuleNode( type = ComponentType.EXTERNAL, name = "rest api call", configClazz = TbRestApiCallNodeConfiguration.class, - version = 3, + version = 4, nodeDescription = "Invoke REST API calls to external REST server", nodeDetails = "Will invoke REST API call GET | POST | PUT | DELETE to external REST server. " + - "Message payload added into Request body. Configured attributes can be added into Headers from Message Metadata." + + "Message payload is used as the request body by default. " + + "Optionally, a request body template can be configured with ${metadataKey} and $[messageKey] placeholders. " + + "Configured attributes can be added into Headers from Message Metadata." + " Outbound message will contain response fields " + "(status, statusCode, statusReason and response headers) in the Message Metadata." + " Response body saved in outbound Message payload. " + @@ -52,13 +59,23 @@ public class TbRestApiCallNode extends TbAbstractExternalNode { static final String PARSE_TO_PLAIN_TEXT = "parseToPlainText"; static final String MAX_IN_MEMORY_BUFFER_SIZE_IN_KB = "maxInMemoryBufferSizeInKb"; + static final String REQUEST_BODY_TEMPLATE = "requestBodyTemplate"; static final String TRIM_DOUBLE_QUOTES = "trimDoubleQuotes"; protected TbHttpClient httpClient; @Override public void init(TbContext ctx, TbNodeConfiguration configuration) throws TbNodeException { super.init(ctx); + TbRestApiCallNodeConfiguration config = TbNodeUtils.convert(configuration, TbRestApiCallNodeConfiguration.class); + + String errorPrefix = "'" + ctx.getSelf().getName() + "' node configuration is invalid: "; + try { + validateFields(config, errorPrefix); + } catch (DataValidationException e) { + throw new TbNodeException(e, true); + } + TbHttpClientSettings httpClientSettings = ctx.getTbHttpClientSettings(); httpClient = new TbHttpClient(config, ctx.getSharedEventLoop(), ctx.getTenantId() != null ? ctx.getTenantId().getId().toString() : "n/a", @@ -101,6 +118,25 @@ public class TbRestApiCallNode extends TbAbstractExternalNode { hasChanges = true; ((ObjectNode) oldConfiguration).put(MAX_IN_MEMORY_BUFFER_SIZE_IN_KB, 256); } + case 3: + if (!oldConfiguration.has(REQUEST_BODY_TEMPLATE)) { + hasChanges = true; + ((ObjectNode) oldConfiguration).putNull(REQUEST_BODY_TEMPLATE); + } + Set knownProperties = Set.of( + "restEndpointUrlPattern", "requestMethod", "headers", + "readTimeoutMs", "maxParallelRequestsCount", "parseToPlainText", + "enableProxy", "useSystemProxyProperties", "proxyHost", "proxyPort", + "proxyUser", "proxyPassword", "credentials", "ignoreRequestBody", + "requestBodyTemplate", "maxInMemoryBufferSizeInKb" + ); + Iterator fieldNames = oldConfiguration.fieldNames(); + while (fieldNames.hasNext()) { + if (!knownProperties.contains(fieldNames.next())) { + hasChanges = true; + fieldNames.remove(); + } + } break; default: break; diff --git a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/rest/TbRestApiCallNodeConfiguration.java b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/rest/TbRestApiCallNodeConfiguration.java index 8413c49c7d..0994ec19e3 100644 --- a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/rest/TbRestApiCallNodeConfiguration.java +++ b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/rest/TbRestApiCallNodeConfiguration.java @@ -15,25 +15,31 @@ */ package org.thingsboard.rule.engine.rest; -import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonIgnore; +import jakarta.validation.constraints.AssertTrue; +import jakarta.validation.constraints.NotNull; import lombok.Data; import org.springframework.http.HttpHeaders; import org.springframework.http.MediaType; import org.thingsboard.rule.engine.api.NodeConfiguration; import org.thingsboard.rule.engine.credentials.AnonymousCredentials; import org.thingsboard.rule.engine.credentials.ClientCredentials; +import org.thingsboard.server.common.data.util.KeyValueEntry; import java.util.Collections; +import java.util.List; import java.util.Map; +import java.util.Objects; -@JsonIgnoreProperties(ignoreUnknown = true) @Data public class TbRestApiCallNodeConfiguration implements NodeConfiguration { private String restEndpointUrlPattern; private String requestMethod; + // null for legacy configs - triggers different URL encoding logic in TbHttpClient to preserve backward compatibility + // all new/modified configs since introduction of this property are forced to have non-null value by UI (and use new encoding logic) + private List<@NotNull KeyValueEntry> queryParams; private Map headers; - private boolean useSimpleClientHttpFactory; private int readTimeoutMs; private int maxParallelRequestsCount; private boolean parseToPlainText; @@ -43,18 +49,32 @@ public class TbRestApiCallNodeConfiguration implements NodeConfiguration queryParam : queryParams) { + if (queryParam == null || queryParam.key() == null || queryParam.value() == null) { + return false; + } + } + return true; + } + @Override public TbRestApiCallNodeConfiguration defaultConfiguration() { - TbRestApiCallNodeConfiguration configuration = new TbRestApiCallNodeConfiguration(); + var configuration = new TbRestApiCallNodeConfiguration(); configuration.setRestEndpointUrlPattern("http://localhost/api"); configuration.setRequestMethod("POST"); configuration.setHeaders(Collections.singletonMap(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE)); - configuration.setUseSimpleClientHttpFactory(false); + configuration.setQueryParams(Collections.emptyList()); configuration.setReadTimeoutMs(0); configuration.setMaxParallelRequestsCount(0); configuration.setParseToPlainText(false); @@ -66,10 +86,7 @@ public class TbRestApiCallNodeConfiguration implements NodeConfiguration 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.getExternalCallExecutor()).thenReturn(DirectListeningExecutor.INSTANCE); - 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" + ) ); } @@ -225,8 +571,8 @@ public class TbHttpClientTest { Map data = metaData.getData(); Assertions.assertEquals(2, data.size()); - Assertions.assertEquals(data.get("Content-Type"), "binary"); - Assertions.assertEquals(data.get("Set-Cookie"), "[\"sap-context=sap-client=075; path=/\",\"sap-token=sap-client=075; path=/\"]"); + Assertions.assertEquals("binary", data.get("Content-Type")); + Assertions.assertEquals("[\"sap-context=sap-client=075; path=/\",\"sap-token=sap-client=075; path=/\"]", data.get("Set-Cookie")); } } diff --git a/rule-engine/rule-engine-components/src/test/java/org/thingsboard/rule/engine/rest/TbRestApiCallNodeTest.java b/rule-engine/rule-engine-components/src/test/java/org/thingsboard/rule/engine/rest/TbRestApiCallNodeTest.java index cc2daa2421..7d0e85b849 100644 --- a/rule-engine/rule-engine-components/src/test/java/org/thingsboard/rule/engine/rest/TbRestApiCallNodeTest.java +++ b/rule-engine/rule-engine-components/src/test/java/org/thingsboard/rule/engine/rest/TbRestApiCallNodeTest.java @@ -25,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.api.parallel.ResourceLock; @@ -49,23 +50,34 @@ import org.thingsboard.server.common.data.id.RuleChainId; import org.thingsboard.server.common.data.id.RuleNodeId; import org.thingsboard.server.common.data.msg.TbMsgType; import org.thingsboard.server.common.data.msg.TbNodeConnectionType; +import org.thingsboard.server.common.data.rule.RuleNode; +import org.thingsboard.server.common.data.util.KeyValueEntry; import org.thingsboard.server.common.msg.TbMsg; import org.thingsboard.server.common.msg.TbMsgDataType; import org.thingsboard.server.common.msg.TbMsgMetaData; +import org.thingsboard.server.exception.DataValidationException; import java.io.IOException; +import java.io.InputStream; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; import java.util.Collections; +import java.util.List; import java.util.concurrent.CountDownLatch; import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicReference; import java.util.stream.Stream; import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertNotSame; +import static org.junit.jupiter.api.Assertions.assertNull; import static org.junit.jupiter.api.Assertions.assertTrue; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.lenient; import static org.mockito.Mockito.never; import static org.mockito.Mockito.timeout; import static org.mockito.Mockito.verify; @@ -82,11 +94,11 @@ public class TbRestApiCallNodeTest extends AbstractRuleNodeUpgradeTest { @Mock private TbContext ctx; - private EntityId originator = new DeviceId(Uuids.timeBased()); - private TbMsgMetaData metaData = new TbMsgMetaData(); + private final EntityId originator = new DeviceId(Uuids.timeBased()); + private final TbMsgMetaData metaData = new TbMsgMetaData(); - private RuleChainId ruleChainId = new RuleChainId(Uuids.timeBased()); - private RuleNodeId ruleNodeId = new RuleNodeId(Uuids.timeBased()); + private final RuleChainId ruleChainId = new RuleChainId(Uuids.timeBased()); + private final RuleNodeId ruleNodeId = new RuleNodeId(Uuids.timeBased()); private HttpServer server; @@ -109,6 +121,14 @@ public class TbRestApiCallNodeTest extends AbstractRuleNodeUpgradeTest { } } + @BeforeEach + public void setup() { + RuleNode ruleNode = new RuleNode(); + ruleNode.setId(ruleNodeId); + ruleNode.setName("Test REST API call node"); + lenient().when(ctx.getSelf()).thenReturn(ruleNode); + } + @AfterEach public void teardown() { if (server != null) { @@ -116,26 +136,113 @@ public class TbRestApiCallNodeTest extends AbstractRuleNodeUpgradeTest { } } + @Test + public void shouldNotAllowNullQueryParamNames() { + // GIVEN + var config = new TbRestApiCallNodeConfiguration().defaultConfiguration(); + config.setQueryParams(List.of(new KeyValueEntry<>(null, "value"))); + + // WHEN-THEN + assertThatThrownBy(() -> new TbRestApiCallNode().init(ctx, new TbNodeConfiguration(JacksonUtil.valueToTree(config)))) + .isInstanceOf(TbNodeException.class) + .matches(e -> ((TbNodeException) e).isUnrecoverable()) + .rootCause() + .isInstanceOf(DataValidationException.class) + .hasMessageContaining("query parameter names and values must be non-null"); + } + + @Test + public void shouldNotAllowNullQueryParamValues() { + // GIVEN + var config = new TbRestApiCallNodeConfiguration().defaultConfiguration(); + config.setQueryParams(List.of(new KeyValueEntry<>("key", null))); + + // WHEN-THEN + assertThatThrownBy(() -> new TbRestApiCallNode().init(ctx, new TbNodeConfiguration(JacksonUtil.valueToTree(config)))) + .isInstanceOf(TbNodeException.class) + .matches(e -> ((TbNodeException) e).isUnrecoverable()) + .rootCause() + .isInstanceOf(DataValidationException.class) + .hasMessageContaining("query parameter names and values must be non-null"); + } + + @Test + public void shouldNotAllowNullQueryParamEntries() { + // GIVEN + var config = new TbRestApiCallNodeConfiguration().defaultConfiguration(); + + var queryParams = new ArrayList>(); + queryParams.add(new KeyValueEntry<>("key", "value")); + queryParams.add(null); + config.setQueryParams(queryParams); + + // WHEN-THEN + assertThatThrownBy(() -> new TbRestApiCallNode().init(ctx, new TbNodeConfiguration(JacksonUtil.valueToTree(config)))) + .isInstanceOf(TbNodeException.class) + .matches(e -> ((TbNodeException) e).isUnrecoverable()) + .rootCause() + .isInstanceOf(DataValidationException.class) + .hasMessageContaining("must not be null"); + } + + @Test + public void shouldUseNewEncodingForNewNodesByDefault() { + // GIVEN-WHEN + var defaultConfig = new TbRestApiCallNodeConfiguration().defaultConfiguration(); + + // THEN + assertEquals(Collections.emptyList(), defaultConfig.getQueryParams()); + } + + @Test + public void shouldDefaultToOldEncodingForLegacyConfigs() { + // GIVEN + String configJson = """ + { + "restEndpointUrlPattern": "http://url?param=value", + "requestMethod": "GET", + "parseToPlainText": false, + "ignoreRequestBody": false, + "enableProxy": false, + "useSystemProxyProperties": false, + "proxyHost": null, + "proxyPort": 0, + "proxyUser": null, + "proxyPassword": null, + "readTimeoutMs": 0, + "maxParallelRequestsCount": 0, + "headers": { + "Content-Type": "application/json", + "X-Authorization": "Bearer eyJhbGciOi...." + }, + "credentials": { + "type": "anonymous" + }, + "maxInMemoryBufferSizeInKb": 256 + }"""; + + // WHEN + var config = JacksonUtil.fromString(configJson, TbRestApiCallNodeConfiguration.class); + + // THEN + assertNull(config.getQueryParams()); + } + @Test public void deleteRequestWithoutBody() throws IOException, InterruptedException { final CountDownLatch latch = new CountDownLatch(1); final String path = "/path/to/delete"; - setupServer("*", new HttpRequestHandler() { - - @Override - public void handle(HttpRequest request, HttpResponse response, HttpContext context) - throws HttpException, IOException { - try { - assertEquals(request.getRequestLine().getUri(), path, "Request path matches"); - assertTrue(request.containsHeader("Foo"), "Custom header included"); - assertEquals("Bar", request.getFirstHeader("Foo").getValue(), "Custom header value"); - response.setStatusCode(200); - latch.countDown(); - } catch (Exception e) { - System.out.println("Exception handling request: " + e.toString()); - e.printStackTrace(); - latch.countDown(); - } + setupServer("*", (request, response, _) -> { + try { + assertEquals(path, request.getRequestLine().getUri(), "Request path matches"); + assertTrue(request.containsHeader("Foo"), "Custom header included"); + assertEquals("Bar", request.getFirstHeader("Foo").getValue(), "Custom header value"); + response.setStatusCode(200); + latch.countDown(); + } catch (Exception e) { + System.out.println("Exception handling request: " + e); + e.printStackTrace(); + latch.countDown(); } }); @@ -174,28 +281,23 @@ public class TbRestApiCallNodeTest extends AbstractRuleNodeUpgradeTest { public void deleteRequestWithBody() throws IOException, InterruptedException { final CountDownLatch latch = new CountDownLatch(1); final String path = "/path/to/delete"; - setupServer("*", new HttpRequestHandler() { - - @Override - public void handle(HttpRequest request, HttpResponse response, HttpContext context) - throws HttpException, IOException { - try { - assertEquals(path, request.getRequestLine().getUri(), "Request path matches"); - assertTrue(request.containsHeader("Content-Type"), "Content-Type included"); - assertEquals("application/json", - request.getFirstHeader("Content-Type").getValue(), "Content-Type value"); - assertTrue(request.containsHeader("Content-Length"), "Content-Length included"); - assertEquals("2", - request.getFirstHeader("Content-Length").getValue(), "Content-Length value"); - assertTrue(request.containsHeader("Foo"), "Custom header included"); - assertEquals("Bar", request.getFirstHeader("Foo").getValue(), "Custom header value"); - response.setStatusCode(200); - latch.countDown(); - } catch (Exception e) { - System.out.println("Exception handling request: " + e.toString()); - e.printStackTrace(); - latch.countDown(); - } + setupServer("*", (request, response, _) -> { + try { + assertEquals(path, request.getRequestLine().getUri(), "Request path matches"); + assertTrue(request.containsHeader("Content-Type"), "Content-Type included"); + assertEquals("application/json", + request.getFirstHeader("Content-Type").getValue(), "Content-Type value"); + assertTrue(request.containsHeader("Content-Length"), "Content-Length included"); + assertEquals("2", + request.getFirstHeader("Content-Length").getValue(), "Content-Length value"); + assertTrue(request.containsHeader("Foo"), "Custom header included"); + assertEquals("Bar", request.getFirstHeader("Foo").getValue(), "Custom header value"); + response.setStatusCode(200); + latch.countDown(); + } catch (Exception e) { + System.out.println("Exception handling request: " + e); + e.printStackTrace(); + latch.countDown(); } }); @@ -361,9 +463,147 @@ public class TbRestApiCallNodeTest extends AbstractRuleNodeUpgradeTest { verify(ctx, timeout(TIMEOUT).times(2)).tellSuccess(any()); } + @Test + public void postRequestWithBodyTemplate() throws IOException, InterruptedException { + final CountDownLatch latch = new CountDownLatch(1); + final String path = "/api/token"; + final AtomicReference capturedBody = new AtomicReference<>(); + setupServerWithBodyCapture(capturedBody, latch); + + TbRestApiCallNodeConfiguration config = new TbRestApiCallNodeConfiguration().defaultConfiguration(); + config.setRequestMethod("POST"); + config.setRequestBodyTemplate("{\"grant_type\":\"client_credentials\",\"client_id\":\"${clientId}\",\"value\":\"$[token]\"}"); + config.setRestEndpointUrlPattern(String.format("http://localhost:%d%s", server.getLocalPort(), path)); + initWithConfig(config); + + metaData.putValue("clientId", "my-client-123"); + TbMsg msg = TbMsg.newMsg() + .type(TbMsgType.POST_TELEMETRY_REQUEST) + .originator(originator) + .copyMetaData(metaData) + .dataType(TbMsgDataType.JSON) + .data("{\"token\":\"abc-xyz\"}") + .ruleChainId(ruleChainId) + .ruleNodeId(ruleNodeId) + .build(); + restNode.onMsg(ctx, msg); + + assertTrue(latch.await(10, TimeUnit.SECONDS), "Server handled request"); + assertEquals("{\"grant_type\":\"client_credentials\",\"client_id\":\"my-client-123\",\"value\":\"abc-xyz\"}", capturedBody.get()); + } + + @Test + public void postRequestWithBodyTemplateAndParseToPlainText() throws IOException, InterruptedException { + final CountDownLatch latch = new CountDownLatch(1); + final String path = "/api/text"; + final AtomicReference capturedBody = new AtomicReference<>(); + setupServerWithBodyCapture(capturedBody, latch); + + TbRestApiCallNodeConfiguration config = new TbRestApiCallNodeConfiguration().defaultConfiguration(); + config.setRequestMethod("POST"); + config.setParseToPlainText(true); + config.setRequestBodyTemplate("Hello ${name}, your token is $[token]!"); + config.setRestEndpointUrlPattern(String.format("http://localhost:%d%s", server.getLocalPort(), path)); + initWithConfig(config); + + metaData.putValue("name", "World"); + TbMsg msg = TbMsg.newMsg() + .type(TbMsgType.POST_TELEMETRY_REQUEST) + .originator(originator) + .copyMetaData(metaData) + .dataType(TbMsgDataType.JSON) + .data("{\"token\":\"abc-xyz\"}") + .ruleChainId(ruleChainId) + .ruleNodeId(ruleNodeId) + .build(); + restNode.onMsg(ctx, msg); + + assertTrue(latch.await(10, TimeUnit.SECONDS), "Server handled request"); + assertEquals("Hello World, your token is abc-xyz!", capturedBody.get()); + } + + @Test + public void postRequestWithBodyTemplateEscapesJsonSpecialChars() throws IOException, InterruptedException { + final CountDownLatch latch = new CountDownLatch(1); + final String path = "/api/token"; + final AtomicReference capturedBody = new AtomicReference<>(); + setupServerWithBodyCapture(capturedBody, latch); + + TbRestApiCallNodeConfiguration config = new TbRestApiCallNodeConfiguration().defaultConfiguration(); + config.setRequestMethod("POST"); + config.setRequestBodyTemplate("{\"name\":\"${userName}\",\"desc\":\"$[description]\"}"); + config.setRestEndpointUrlPattern(String.format("http://localhost:%d%s", server.getLocalPort(), path)); + initWithConfig(config); + + metaData.putValue("userName", "John \"Doe\""); + TbMsg msg = TbMsg.newMsg() + .type(TbMsgType.POST_TELEMETRY_REQUEST) + .originator(originator) + .copyMetaData(metaData) + .dataType(TbMsgDataType.JSON) + .data("{\"description\":\"line1\\nline2\"}") + .ruleChainId(ruleChainId) + .ruleNodeId(ruleNodeId) + .build(); + restNode.onMsg(ctx, msg); + + assertTrue(latch.await(10, TimeUnit.SECONDS), "Server handled request"); + assertEquals("{\"name\":\"John \\\"Doe\\\"\",\"desc\":\"line1\\nline2\"}", capturedBody.get()); + } + + @Test + public void postRequestWithEmptyBodyTemplateUsesMessageData() throws IOException, InterruptedException { + final CountDownLatch latch = new CountDownLatch(1); + final String path = "/api/data"; + final AtomicReference capturedBody = new AtomicReference<>(); + setupServerWithBodyCapture(capturedBody, latch); + + given(ctx.getExternalCallExecutor()).willReturn(DirectListeningExecutor.INSTANCE); + + TbRestApiCallNodeConfiguration config = new TbRestApiCallNodeConfiguration().defaultConfiguration(); + config.setRequestMethod("POST"); + // requestBodyTemplate is null by default — should use msg.getData() + config.setRestEndpointUrlPattern(String.format("http://localhost:%d%s", server.getLocalPort(), path)); + initWithConfig(config); + + TbMsg msg = TbMsg.newMsg() + .type(TbMsgType.POST_TELEMETRY_REQUEST) + .originator(originator) + .copyMetaData(metaData) + .dataType(TbMsgDataType.JSON) + .data("{\"temperature\":25}") + .ruleChainId(ruleChainId) + .ruleNodeId(ruleNodeId) + .build(); + restNode.onMsg(ctx, msg); + + assertTrue(latch.await(10, TimeUnit.SECONDS), "Server handled request"); + + ArgumentCaptor msgCaptor = ArgumentCaptor.forClass(TbMsg.class); + ArgumentCaptor metadataCaptor = ArgumentCaptor.forClass(TbMsgMetaData.class); + ArgumentCaptor dataCaptor = ArgumentCaptor.forClass(String.class); + verify(ctx, timeout(10_000)).transformMsg(msgCaptor.capture(), metadataCaptor.capture(), dataCaptor.capture()); + assertEquals("{\"temperature\":25}", capturedBody.get()); + } + + private void setupServerWithBodyCapture(AtomicReference capturedBody, CountDownLatch latch) throws IOException { + setupServer("*", (request, response, _) -> { + try { + if (request instanceof org.apache.http.HttpEntityEnclosingRequest entityRequest) { + InputStream is = entityRequest.getEntity().getContent(); + capturedBody.set(new String(is.readAllBytes(), StandardCharsets.UTF_8)); + } + response.setStatusCode(200); + latch.countDown(); + } catch (Exception e) { + e.printStackTrace(); + latch.countDown(); + } + }); + } + private static Stream givenFromVersionAndConfig_whenUpgrade_thenVerifyHasChangesAndConfig() { return Stream.of( - // config for version 2 with upgrade from version 0 Arguments.of(0, "{\"restEndpointUrlPattern\":\"http://localhost/api\",\"requestMethod\":\"POST\"," + "\"useSimpleClientHttpFactory\":false,\"ignoreRequestBody\":false,\"enableProxy\":false," + @@ -373,13 +613,12 @@ public class TbRestApiCallNodeTest extends AbstractRuleNodeUpgradeTest { "\"trimQueue\":null,\"maxQueueSize\":null,\"credentials\":{\"type\":\"anonymous\"},\"trimDoubleQuotes\":false}", true, "{\"restEndpointUrlPattern\":\"http://localhost/api\",\"requestMethod\": \"POST\"," + - "\"useSimpleClientHttpFactory\": false,\"parseToPlainText\": false,\"ignoreRequestBody\": false," + - "\"enableProxy\": false,\"useSystemProxyProperties\": false,\"proxyScheme\": null,\"proxyHost\": null," + + "\"parseToPlainText\": false,\"ignoreRequestBody\": false," + + "\"enableProxy\": false,\"useSystemProxyProperties\": false,\"proxyHost\": null," + "\"proxyPort\": 0,\"proxyUser\": null,\"proxyPassword\": null,\"readTimeoutMs\": 0," + "\"maxParallelRequestsCount\": 0,\"headers\": {\"Content-Type\": \"application/json\"}," + "\"credentials\": {\"type\": \"anonymous\"}," + - "\"maxInMemoryBufferSizeInKb\": 256}"), - // config for version 2 with upgrade from version 1 + "\"maxInMemoryBufferSizeInKb\": 256,\"requestBodyTemplate\": null}"), Arguments.of(1, "{\"restEndpointUrlPattern\":\"http://localhost/api\",\"requestMethod\": \"POST\"," + "\"useSimpleClientHttpFactory\": false,\"parseToPlainText\": false,\"ignoreRequestBody\": false," + @@ -390,13 +629,12 @@ public class TbRestApiCallNodeTest extends AbstractRuleNodeUpgradeTest { "\"credentials\": {\"type\": \"anonymous\"}}", true, "{\"restEndpointUrlPattern\":\"http://localhost/api\",\"requestMethod\": \"POST\"," + - "\"useSimpleClientHttpFactory\": false,\"parseToPlainText\": false,\"ignoreRequestBody\": false," + - "\"enableProxy\": false,\"useSystemProxyProperties\": false,\"proxyScheme\": null,\"proxyHost\": null," + + "\"parseToPlainText\": false,\"ignoreRequestBody\": false," + + "\"enableProxy\": false,\"useSystemProxyProperties\": false,\"proxyHost\": null," + "\"proxyPort\": 0,\"proxyUser\": null,\"proxyPassword\": null,\"readTimeoutMs\": 0," + "\"maxParallelRequestsCount\": 0,\"headers\": {\"Content-Type\": \"application/json\"}," + "\"credentials\": {\"type\": \"anonymous\"}," + - "\"maxInMemoryBufferSizeInKb\": 256}"), - // config for version 3 with upgrade from version 2 + "\"maxInMemoryBufferSizeInKb\": 256,\"requestBodyTemplate\": null}"), Arguments.of(2, "{\"restEndpointUrlPattern\":\"http://localhost/api\",\"requestMethod\": \"POST\"," + "\"useSimpleClientHttpFactory\": false,\"parseToPlainText\": false,\"ignoreRequestBody\": false," + @@ -406,12 +644,63 @@ public class TbRestApiCallNodeTest extends AbstractRuleNodeUpgradeTest { "\"credentials\": {\"type\": \"anonymous\"}}", true, "{\"restEndpointUrlPattern\":\"http://localhost/api\",\"requestMethod\": \"POST\"," + - "\"useSimpleClientHttpFactory\": false,\"parseToPlainText\": false,\"ignoreRequestBody\": false," + - "\"enableProxy\": false,\"useSystemProxyProperties\": false,\"proxyScheme\": null,\"proxyHost\": null," + + "\"parseToPlainText\": false,\"ignoreRequestBody\": false," + + "\"enableProxy\": false,\"useSystemProxyProperties\": false,\"proxyHost\": null," + "\"proxyPort\": 0,\"proxyUser\": null,\"proxyPassword\": null,\"readTimeoutMs\": 0," + "\"maxParallelRequestsCount\": 0,\"headers\": {\"Content-Type\": \"application/json\"}," + "\"credentials\": {\"type\": \"anonymous\"}," + - "\"maxInMemoryBufferSizeInKb\": 256}") + "\"maxInMemoryBufferSizeInKb\": 256,\"requestBodyTemplate\": null}"), + Arguments.of(3, """ + { + "restEndpointUrlPattern": "http://localhost/api", + "requestMethod": "POST", + "useSimpleClientHttpFactory": true, + "parseToPlainText": false, + "ignoreRequestBody": false, + "enableProxy": false, + "useSystemProxyProperties": false, + "proxyScheme": null, + "proxyHost": null, + "proxyPort": 0, + "proxyUser": null, + "proxyPassword": null, + "readTimeoutMs": 0, + "maxParallelRequestsCount": 0, + "headers": { + "Content-Type": "application/json" + }, + "credentials": { + "type": "anonymous" + }, + "maxInMemoryBufferSizeInKb": 256, + "trimQueue": true, + "maxQueueSize": 100, + "trimDoubleQuotes": false, + "useRedisQueueForMsgPersistence": false + }""", + true, """ + { + "restEndpointUrlPattern": "http://localhost/api", + "requestMethod": "POST", + "parseToPlainText": false, + "ignoreRequestBody": false, + "enableProxy": false, + "useSystemProxyProperties": false, + "proxyHost": null, + "proxyPort": 0, + "proxyUser": null, + "proxyPassword": null, + "readTimeoutMs": 0, + "maxParallelRequestsCount": 0, + "headers": { + "Content-Type": "application/json" + }, + "credentials": { + "type": "anonymous" + }, + "requestBodyTemplate": null, + "maxInMemoryBufferSizeInKb": 256 + }""") ); } diff --git a/rule-engine/rule-engine-components/src/test/java/org/thingsboard/rule/engine/rest/TbSendRestApiCallReplyNodeTest.java b/rule-engine/rule-engine-components/src/test/java/org/thingsboard/rule/engine/rest/TbSendRestApiCallReplyNodeTest.java index 7b00d249ff..062e2d0a2e 100644 --- a/rule-engine/rule-engine-components/src/test/java/org/thingsboard/rule/engine/rest/TbSendRestApiCallReplyNodeTest.java +++ b/rule-engine/rule-engine-components/src/test/java/org/thingsboard/rule/engine/rest/TbSendRestApiCallReplyNodeTest.java @@ -51,7 +51,7 @@ public class TbSendRestApiCallReplyNodeTest { private TbSendRestApiCallReplyNode node; private TbSendRestApiCallReplyNodeConfiguration config; - + @Mock private TbContext ctxMock; @Mock @@ -140,4 +140,5 @@ public class TbSendRestApiCallReplyNodeTest { TbMsg.EMPTY_STRING, "Request body is empty!") ); } + } diff --git a/tools/pom.xml b/tools/pom.xml index 12ea0159c1..30b563a498 100644 --- a/tools/pom.xml +++ b/tools/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard - 4.3.1.3-SNAPSHOT + 4.4.0-SNAPSHOT thingsboard tools diff --git a/transport/coap/pom.xml b/transport/coap/pom.xml index 74bc0becda..b17cc4e888 100644 --- a/transport/coap/pom.xml +++ b/transport/coap/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard - 4.3.1.3-SNAPSHOT + 4.4.0-SNAPSHOT transport org.thingsboard.transport diff --git a/transport/coap/src/main/resources/tb-coap-transport.yml b/transport/coap/src/main/resources/tb-coap-transport.yml index cc1500105a..623ecebae3 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:}" @@ -183,6 +188,7 @@ transport: check_interval_seconds: "${TB_TRANSPORT_SSL_CERTIFICATE_RELOAD_CHECK_INTERVAL_SECONDS:60}" # CoAP server parameters +# Configures the CoAP and CoAP/DTLS server endpoints, including bind addresses, timeouts, and TLS credentials. coap: # CoAP bind-address bind_address: "${COAP_BIND_ADDRESS:0.0.0.0}" @@ -264,6 +270,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). @@ -318,11 +325,23 @@ queue: # The endpoint identification algorithm used by clients to validate server hostname. The default value is https ssl.algorithm: "${TB_QUEUE_KAFKA_CONFLUENT_SSL_ALGORITHM:https}" # The mechanism used to authenticate Schema Registry requests. SASL/PLAIN should only be used with TLS/SSL as a transport layer to ensure that clear passwords are not transmitted on the wire without encryption + # Set to OAUTHBEARER to authenticate via OAuth2/OIDC client-credentials (configure the oauth.* settings below) sasl.mechanism: "${TB_QUEUE_KAFKA_CONFLUENT_SASL_MECHANISM:PLAIN}" # Using JAAS Configuration for specifying multiple SASL mechanisms on a broker sasl.config: "${TB_QUEUE_KAFKA_CONFLUENT_SASL_JAAS_CONFIG:org.apache.kafka.common.security.plain.PlainLoginModule required username=\"CLUSTER_API_KEY\" password=\"CLUSTER_API_SECRET\";}" # Protocol used to communicate with brokers. Valid values are: PLAINTEXT, SSL, SASL_PLAINTEXT, SASL_SSL security.protocol: "${TB_QUEUE_KAFKA_CONFLUENT_SECURITY_PROTOCOL:SASL_SSL}" + # Optional: OAuth2 (OIDC) client-credentials settings. Applied only when sasl.mechanism is OAUTHBEARER. + # ThingsBoard wires Kafka's built-in OAuthBearerLoginCallbackHandler; the token is fetched and refreshed automatically. + oauth: + # Optional: OAuth2 client id + client-id: "${TB_QUEUE_KAFKA_CONFLUENT_OAUTH_CLIENT_ID:}" + # Optional: OAuth2 client secret + client-secret: "${TB_QUEUE_KAFKA_CONFLUENT_OAUTH_CLIENT_SECRET:}" + # Optional: OAuth2/OIDC token endpoint URL to request the bearer token from + endpoint-url: "${TB_QUEUE_KAFKA_CONFLUENT_OAUTH_ENDPOINT_URL:}" + # Optional: OAuth2 scope/audience requested for the token. Required by some IdPs for client-credentials (e.g. Azure AD's "api:///.default") + scope: "${TB_QUEUE_KAFKA_CONFLUENT_OAUTH_SCOPE:}" # If you override any default Kafka topic name using environment variables, you must also specify the related consumer properties # for the new topic in `consumer-properties-per-topic-inline`. Otherwise, the topic will not inherit its expected configuration (e.g., max.poll.records, timeouts, etc). # Format: "topic1:key1=value1,key2=value2;topic2:key=value" @@ -417,12 +436,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: @@ -438,11 +459,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: @@ -451,6 +474,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 db6459b3c9..ae2af1e29b 100644 --- a/transport/http/pom.xml +++ b/transport/http/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard - 4.3.1.3-SNAPSHOT + 4.4.0-SNAPSHOT transport org.thingsboard.transport diff --git a/transport/http/src/main/resources/tb-http-transport.yml b/transport/http/src/main/resources/tb-http-transport.yml index 53f8f5fc5b..2b1d3dff8e 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: # Size of the thread pool that executes transport API callbacks (session registration, telemetry/attribute and RPC responses, entity update notifications, and the tenant profile fetch on a cache miss). Bounds how many such callbacks - including those that block on a backend round-trip - can run concurrently. callback_thread_pool_size: "${TB_TRANSPORT_CALLBACK_THREAD_POOL_SIZE:20}" @@ -214,6 +220,7 @@ transport: check_interval_seconds: "${TB_TRANSPORT_SSL_CERTIFICATE_RELOAD_CHECK_INTERVAL_SECONDS:60}" # Queue configuration parameters +# Configures the messaging queue backend (Kafka), topic definitions, partitioning, and per-service consumer settings. queue: type: "${TB_QUEUE_TYPE:kafka}" # kafka (Apache Kafka) prefix: "${TB_QUEUE_PREFIX:}" # Global queue prefix. If specified, prefix is added before default topic name: 'prefix.default_topic_name'. Prefix is applied to all topics (and consumer groups for kafka) . @@ -267,11 +274,23 @@ queue: # The endpoint identification algorithm used by clients to validate server host name. The default value is https ssl.algorithm: "${TB_QUEUE_KAFKA_CONFLUENT_SSL_ALGORITHM:https}" # The mechanism used to authenticate Schema Registry requests. SASL/PLAIN should only be used with TLS/SSL as transport layer to ensure that clear passwords are not transmitted on the wire without encryption + # Set to OAUTHBEARER to authenticate via OAuth2/OIDC client-credentials (configure the oauth.* settings below) sasl.mechanism: "${TB_QUEUE_KAFKA_CONFLUENT_SASL_MECHANISM:PLAIN}" # Using JAAS Configuration for specifying multiple SASL mechanisms on a broker sasl.config: "${TB_QUEUE_KAFKA_CONFLUENT_SASL_JAAS_CONFIG:org.apache.kafka.common.security.plain.PlainLoginModule required username=\"CLUSTER_API_KEY\" password=\"CLUSTER_API_SECRET\";}" # Protocol used to communicate with brokers. Valid values are: PLAINTEXT, SSL, SASL_PLAINTEXT, SASL_SSL security.protocol: "${TB_QUEUE_KAFKA_CONFLUENT_SECURITY_PROTOCOL:SASL_SSL}" + # Optional: OAuth2 (OIDC) client-credentials settings. Applied only when sasl.mechanism is OAUTHBEARER. + # ThingsBoard wires Kafka's built-in OAuthBearerLoginCallbackHandler; the token is fetched and refreshed automatically. + oauth: + # Optional: OAuth2 client id + client-id: "${TB_QUEUE_KAFKA_CONFLUENT_OAUTH_CLIENT_ID:}" + # Optional: OAuth2 client secret + client-secret: "${TB_QUEUE_KAFKA_CONFLUENT_OAUTH_CLIENT_SECRET:}" + # Optional: OAuth2/OIDC token endpoint URL to request the bearer token from + endpoint-url: "${TB_QUEUE_KAFKA_CONFLUENT_OAUTH_ENDPOINT_URL:}" + # Optional: OAuth2 scope/audience requested for the token. Required by some IdPs for client-credentials (e.g. Azure AD's "api:///.default") + scope: "${TB_QUEUE_KAFKA_CONFLUENT_OAUTH_SCOPE:}" # If you override any default Kafka topic name using environment variables, you must also specify the related consumer properties # for the new topic in `consumer-properties-per-topic-inline`. Otherwise, the topic will not inherit its expected configuration (e.g., max.poll.records, timeouts, etc). # Format: "topic1:key1=value1,key2=value2;topic2:key=value" @@ -366,12 +385,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: @@ -387,11 +408,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: @@ -400,6 +423,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 61ae17fe7e..045c7181f2 100644 --- a/transport/lwm2m/pom.xml +++ b/transport/lwm2m/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard - 4.3.1.3-SNAPSHOT + 4.4.0-SNAPSHOT transport org.thingsboard.transport diff --git a/transport/lwm2m/src/main/resources/tb-lwm2m-transport.yml b/transport/lwm2m/src/main/resources/tb-lwm2m-transport.yml index ca41c94434..67046ee798 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: # Size of the thread pool that executes transport API callbacks (session registration, telemetry/attribute and RPC responses, entity update notifications, and the tenant profile fetch on a cache miss). Bounds how many such callbacks - including those that block on a backend round-trip - can run concurrently. callback_thread_pool_size: "${TB_TRANSPORT_CALLBACK_THREAD_POOL_SIZE:20}" @@ -314,6 +320,7 @@ transport: check_interval_seconds: "${TB_TRANSPORT_SSL_CERTIFICATE_RELOAD_CHECK_INTERVAL_SECONDS:60}" # Queue configuration properties +# Defines the message queue backend (Kafka) and per-topic settings for transport, core, rule-engine, and JS evaluation. queue: type: "${TB_QUEUE_TYPE:kafka}" # kafka (Apache Kafka) prefix: "${TB_QUEUE_PREFIX:}" # Global queue prefix. If specified, prefix is added before default topic name: 'prefix.default_topic_name'. Prefix is applied to all topics (and consumer groups for kafka). @@ -368,11 +375,23 @@ queue: # The endpoint identification algorithm used by clients to validate server hostname. The default value is https ssl.algorithm: "${TB_QUEUE_KAFKA_CONFLUENT_SSL_ALGORITHM:https}" # The mechanism used to authenticate Schema Registry requests. SASL/PLAIN should only be used with TLS/SSL as a transport layer to ensure that clear passwords are not transmitted on the wire without encryption + # Set to OAUTHBEARER to authenticate via OAuth2/OIDC client-credentials (configure the oauth.* settings below) sasl.mechanism: "${TB_QUEUE_KAFKA_CONFLUENT_SASL_MECHANISM:PLAIN}" # Using JAAS Configuration for specifying multiple SASL mechanisms on a broker sasl.config: "${TB_QUEUE_KAFKA_CONFLUENT_SASL_JAAS_CONFIG:org.apache.kafka.common.security.plain.PlainLoginModule required username=\"CLUSTER_API_KEY\" password=\"CLUSTER_API_SECRET\";}" # Protocol used to communicate with brokers. Valid values are: PLAINTEXT, SSL, SASL_PLAINTEXT, SASL_SSL security.protocol: "${TB_QUEUE_KAFKA_CONFLUENT_SECURITY_PROTOCOL:SASL_SSL}" + # Optional: OAuth2 (OIDC) client-credentials settings. Applied only when sasl.mechanism is OAUTHBEARER. + # ThingsBoard wires Kafka's built-in OAuthBearerLoginCallbackHandler; the token is fetched and refreshed automatically. + oauth: + # Optional: OAuth2 client id + client-id: "${TB_QUEUE_KAFKA_CONFLUENT_OAUTH_CLIENT_ID:}" + # Optional: OAuth2 client secret + client-secret: "${TB_QUEUE_KAFKA_CONFLUENT_OAUTH_CLIENT_SECRET:}" + # Optional: OAuth2/OIDC token endpoint URL to request the bearer token from + endpoint-url: "${TB_QUEUE_KAFKA_CONFLUENT_OAUTH_ENDPOINT_URL:}" + # Optional: OAuth2 scope/audience requested for the token. Required by some IdPs for client-credentials (e.g. Azure AD's "api:///.default") + scope: "${TB_QUEUE_KAFKA_CONFLUENT_OAUTH_SCOPE:}" # If you override any default Kafka topic name using environment variables, you must also specify the related consumer properties # for the new topic in `consumer-properties-per-topic-inline`. Otherwise, the topic will not inherit its expected configuration (e.g., max.poll.records, timeouts, etc). # Format: "topic1:key1=value1,key2=value2;topic2:key=value" @@ -467,12 +486,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: @@ -488,11 +509,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: @@ -501,6 +524,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 369cd33e8b..4e68cbb49e 100644 --- a/transport/mqtt/pom.xml +++ b/transport/mqtt/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard - 4.3.1.3-SNAPSHOT + 4.4.0-SNAPSHOT transport org.thingsboard.transport diff --git a/transport/mqtt/src/main/resources/tb-mqtt-transport.yml b/transport/mqtt/src/main/resources/tb-mqtt-transport.yml index 99a8a5a593..b5a0e4997f 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: # Size of the thread pool that executes transport API callbacks (session registration, telemetry/attribute and RPC responses, entity update notifications, and the tenant profile fetch on a cache miss). Bounds how many such callbacks - including those that block on a backend round-trip - can run concurrently. callback_thread_pool_size: "${TB_TRANSPORT_CALLBACK_THREAD_POOL_SIZE:20}" @@ -247,6 +253,7 @@ transport: check_interval_seconds: "${TB_TRANSPORT_SSL_CERTIFICATE_RELOAD_CHECK_INTERVAL_SECONDS:60}" # Queue configuration parameters +# Defines the messaging queue backend (Kafka), topic names, partitioning, and poll/processing settings for all platform microservices. queue: type: "${TB_QUEUE_TYPE:kafka}" # kafka (Apache Kafka) prefix: "${TB_QUEUE_PREFIX:}" # Global queue prefix. If specified, prefix is added before default topic name: 'prefix.default_topic_name'. Prefix is applied to all topics (and consumer groups for kafka). @@ -301,11 +308,23 @@ queue: # The endpoint identification algorithm used by clients to validate server hostname. The default value is https ssl.algorithm: "${TB_QUEUE_KAFKA_CONFLUENT_SSL_ALGORITHM:https}" # The mechanism used to authenticate Schema Registry requests. SASL/PLAIN should only be used with TLS/SSL as a transport layer to ensure that clear passwords are not transmitted on the wire without encryption + # Set to OAUTHBEARER to authenticate via OAuth2/OIDC client-credentials (configure the oauth.* settings below) sasl.mechanism: "${TB_QUEUE_KAFKA_CONFLUENT_SASL_MECHANISM:PLAIN}" # Using JAAS Configuration for specifying multiple SASL mechanisms on a broker sasl.config: "${TB_QUEUE_KAFKA_CONFLUENT_SASL_JAAS_CONFIG:org.apache.kafka.common.security.plain.PlainLoginModule required username=\"CLUSTER_API_KEY\" password=\"CLUSTER_API_SECRET\";}" # Protocol used to communicate with brokers. Valid values are: PLAINTEXT, SSL, SASL_PLAINTEXT, SASL_SSL security.protocol: "${TB_QUEUE_KAFKA_CONFLUENT_SECURITY_PROTOCOL:SASL_SSL}" + # Optional: OAuth2 (OIDC) client-credentials settings. Applied only when sasl.mechanism is OAUTHBEARER. + # ThingsBoard wires Kafka's built-in OAuthBearerLoginCallbackHandler; the token is fetched and refreshed automatically. + oauth: + # Optional: OAuth2 client id + client-id: "${TB_QUEUE_KAFKA_CONFLUENT_OAUTH_CLIENT_ID:}" + # Optional: OAuth2 client secret + client-secret: "${TB_QUEUE_KAFKA_CONFLUENT_OAUTH_CLIENT_SECRET:}" + # Optional: OAuth2/OIDC token endpoint URL to request the bearer token from + endpoint-url: "${TB_QUEUE_KAFKA_CONFLUENT_OAUTH_ENDPOINT_URL:}" + # Optional: OAuth2 scope/audience requested for the token. Required by some IdPs for client-credentials (e.g. Azure AD's "api:///.default") + scope: "${TB_QUEUE_KAFKA_CONFLUENT_OAUTH_SCOPE:}" # If you override any default Kafka topic name using environment variables, you must also specify the related consumer properties # for the new topic in `consumer-properties-per-topic-inline`. Otherwise, the topic will not inherit its expected configuration (e.g., max.poll.records, timeouts, etc). # Format: "topic1:key1=value1,key2=value2;topic2:key=value" @@ -400,12 +419,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: @@ -421,11 +442,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: @@ -434,6 +457,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/pom.xml b/transport/pom.xml index c4b690932f..3b6195f9d8 100644 --- a/transport/pom.xml +++ b/transport/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard - 4.3.1.3-SNAPSHOT + 4.4.0-SNAPSHOT thingsboard transport diff --git a/transport/snmp/pom.xml b/transport/snmp/pom.xml index 2f7981d9a0..1575d25bdb 100644 --- a/transport/snmp/pom.xml +++ b/transport/snmp/pom.xml @@ -21,7 +21,7 @@ org.thingsboard - 4.3.1.3-SNAPSHOT + 4.4.0-SNAPSHOT transport diff --git a/transport/snmp/src/main/resources/tb-snmp-transport.yml b/transport/snmp/src/main/resources/tb-snmp-transport.yml index 5516148670..b0c4155924 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: # Size of the thread pool that executes transport API callbacks (session registration, telemetry/attribute and RPC responses, entity update notifications, and the tenant profile fetch on a cache miss). Bounds how many such callbacks - including those that block on a backend round-trip - can run concurrently. callback_thread_pool_size: "${TB_TRANSPORT_CALLBACK_THREAD_POOL_SIZE:20}" @@ -186,6 +192,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). @@ -240,11 +247,23 @@ queue: # The endpoint identification algorithm used by clients to validate server hostname. The default value is https ssl.algorithm: "${TB_QUEUE_KAFKA_CONFLUENT_SSL_ALGORITHM:https}" # The mechanism used to authenticate Schema Registry requests. SASL/PLAIN should only be used with TLS/SSL as a transport layer to ensure that clear passwords are not transmitted on the wire without encryption + # Set to OAUTHBEARER to authenticate via OAuth2/OIDC client-credentials (configure the oauth.* settings below) sasl.mechanism: "${TB_QUEUE_KAFKA_CONFLUENT_SASL_MECHANISM:PLAIN}" # Using JAAS Configuration for specifying multiple SASL mechanisms on a broker sasl.config: "${TB_QUEUE_KAFKA_CONFLUENT_SASL_JAAS_CONFIG:org.apache.kafka.common.security.plain.PlainLoginModule required username=\"CLUSTER_API_KEY\" password=\"CLUSTER_API_SECRET\";}" # Protocol used to communicate with brokers. Valid values are: PLAINTEXT, SSL, SASL_PLAINTEXT, SASL_SSL security.protocol: "${TB_QUEUE_KAFKA_CONFLUENT_SECURITY_PROTOCOL:SASL_SSL}" + # Optional: OAuth2 (OIDC) client-credentials settings. Applied only when sasl.mechanism is OAUTHBEARER. + # ThingsBoard wires Kafka's built-in OAuthBearerLoginCallbackHandler; the token is fetched and refreshed automatically. + oauth: + # Optional: OAuth2 client id + client-id: "${TB_QUEUE_KAFKA_CONFLUENT_OAUTH_CLIENT_ID:}" + # Optional: OAuth2 client secret + client-secret: "${TB_QUEUE_KAFKA_CONFLUENT_OAUTH_CLIENT_SECRET:}" + # Optional: OAuth2/OIDC token endpoint URL to request the bearer token from + endpoint-url: "${TB_QUEUE_KAFKA_CONFLUENT_OAUTH_ENDPOINT_URL:}" + # Optional: OAuth2 scope/audience requested for the token. Required by some IdPs for client-credentials (e.g. Azure AD's "api:///.default") + scope: "${TB_QUEUE_KAFKA_CONFLUENT_OAUTH_SCOPE:}" # If you override any default Kafka topic name using environment variables, you must also specify the related consumer properties # for the new topic in `consumer-properties-per-topic-inline`. Otherwise, the topic will not inherit its expected configuration (e.g., max.poll.records, timeouts, etc). # Format: "topic1:key1=value1,key2=value2;topic2:key=value" @@ -346,12 +365,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: @@ -367,11 +388,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: @@ -380,6 +403,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 851c521803..13619f6b21 100644 --- a/ui-ngx/package.json +++ b/ui-ngx/package.json @@ -1,6 +1,6 @@ { "name": "thingsboard", - "version": "4.3.1.3", + "version": "4.4.0", "scripts": { "ng": "ng", "start": "node --max_old_space_size=8048 ./node_modules/@angular/cli/bin/ng serve --configuration development --host 0.0.0.0 --open", @@ -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", diff --git a/ui-ngx/pom.xml b/ui-ngx/pom.xml index 61194d94bf..8e16f17653 100644 --- a/ui-ngx/pom.xml +++ b/ui-ngx/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard - 4.3.1.3-SNAPSHOT + 4.4.0-SNAPSHOT thingsboard org.thingsboard diff --git a/ui-ngx/src/app/core/api/alarm-data-subscription.ts b/ui-ngx/src/app/core/api/alarm-data-subscription.ts index 16dafd53eb..ad6abb6573 100644 --- a/ui-ngx/src/app/core/api/alarm-data-subscription.ts +++ b/ui-ngx/src/app/core/api/alarm-data-subscription.ts @@ -23,6 +23,7 @@ import { DatasourceType } from '@shared/models/widget.models'; import { AlarmData, AlarmDataPageLink, + ComplexOperation, EntityFilter, EntityKey, EntityKeyType, @@ -47,6 +48,7 @@ export interface AlarmDataSubscriptionOptions { pageLink?: AlarmDataPageLink; keyFilters?: Array; additionalKeyFilters?: Array; + keyFiltersOperation?: ComplexOperation; subscriptionTimewindow?: SubscriptionTimewindow; } @@ -120,6 +122,7 @@ export class AlarmDataSubscription { entityFilter: this.alarmDataSubscriptionOptions.entityFilter, pageLink: deepClone(this.alarmDataSubscriptionOptions.pageLink), keyFilters, + keyFiltersOperation: this.alarmDataSubscriptionOptions.keyFiltersOperation, alarmFields, entityFields, latestValues diff --git a/ui-ngx/src/app/core/api/alarm-data.service.ts b/ui-ngx/src/app/core/api/alarm-data.service.ts index 901155dec5..c0c5a928bf 100644 --- a/ui-ngx/src/app/core/api/alarm-data.service.ts +++ b/ui-ngx/src/app/core/api/alarm-data.service.ts @@ -85,6 +85,7 @@ export class AlarmDataService { alarmDataSubscriptionOptions.entityFilter = alarmSource.entityFilter; alarmDataSubscriptionOptions.pageLink = pageLink; alarmDataSubscriptionOptions.keyFilters = alarmSource.keyFilters; + alarmDataSubscriptionOptions.keyFiltersOperation = alarmSource.keyFiltersOperation; alarmDataSubscriptionOptions.additionalKeyFilters = additionalKeyFilters; } return alarmDataSubscriptionOptions; diff --git a/ui-ngx/src/app/core/api/alias-controller.ts b/ui-ngx/src/app/core/api/alias-controller.ts index ffd06a5289..de6863ee56 100644 --- a/ui-ngx/src/app/core/api/alias-controller.ts +++ b/ui-ngx/src/app/core/api/alias-controller.ts @@ -314,7 +314,15 @@ export class AliasController implements IAliasController { || newDatasource.type === DatasourceType.entityCount || newDatasource.type === DatasourceType.alarmCount) { if (newDatasource.filterId) { - newDatasource.keyFilters = this.getKeyFilters(newDatasource.filterId); + const filterInfo = this.getFilterInfo(newDatasource.filterId); + if (filterInfo) { + newDatasource.keyFilters = filterInfoToKeyFilters(filterInfo); + if (filterInfo.keyFiltersOperation) { + newDatasource.keyFiltersOperation = filterInfo.keyFiltersOperation; + } + } else { + newDatasource.keyFilters = []; + } } if (newDatasource.type === DatasourceType.alarmCount) { newDatasource.alarmFilter = this.entityService.resolveAlarmFilter(newDatasource.alarmFilterConfig, false); diff --git a/ui-ngx/src/app/core/api/entity-data-subscription.ts b/ui-ngx/src/app/core/api/entity-data-subscription.ts index 021c5e81e9..6f7949d259 100644 --- a/ui-ngx/src/app/core/api/entity-data-subscription.ts +++ b/ui-ngx/src/app/core/api/entity-data-subscription.ts @@ -34,6 +34,7 @@ import { import { AlarmFilter, ComparisonTsValue, + ComplexOperation, EntityData, EntityDataPageLink, EntityFilter, @@ -112,6 +113,7 @@ export interface EntityDataSubscriptionOptions { pageLink?: EntityDataPageLink; keyFilters?: Array; additionalKeyFilters?: Array; + keyFiltersOperation?: ComplexOperation; subscriptionTimewindow?: SubscriptionTimewindow; latestTsOffset?: number; } @@ -356,7 +358,6 @@ export class EntityDataSubscription { this.subscriber = new TelemetrySubscriber(this.telemetryService); this.dataCommand = new EntityDataCmd(); - let keyFilters = this.entityDataSubscriptionOptions.keyFilters; if (this.entityDataSubscriptionOptions.additionalKeyFilters) { if (keyFilters) { @@ -370,6 +371,7 @@ export class EntityDataSubscription { entityFilter: this.entityDataSubscriptionOptions.entityFilter, pageLink: this.entityDataSubscriptionOptions.pageLink, keyFilters, + keyFiltersOperation: this.entityDataSubscriptionOptions.keyFiltersOperation, entityFields, latestValues: this.latestValues }; @@ -494,7 +496,8 @@ export class EntityDataSubscription { } this.countCommand.query = { entityFilter: this.entityDataSubscriptionOptions.entityFilter, - keyFilters + keyFilters, + keyFiltersOperation: this.entityDataSubscriptionOptions.keyFiltersOperation }; this.subscriber.subscriptionCommands.push(this.countCommand); @@ -569,7 +572,8 @@ export class EntityDataSubscription { } this.alarmCountCommand.query = { entityFilter: this.entityDataSubscriptionOptions.entityFilter, - keyFilters + keyFilters, + keyFiltersOperation: this.entityDataSubscriptionOptions.keyFiltersOperation }; if (this.entityDataSubscriptionOptions.alarmFilter) { this.alarmCountCommand.query = {...this.alarmCountCommand.query, ...this.entityDataSubscriptionOptions.alarmFilter}; diff --git a/ui-ngx/src/app/core/api/entity-data.service.ts b/ui-ngx/src/app/core/api/entity-data.service.ts index 9ed064403c..697187b61e 100644 --- a/ui-ngx/src/app/core/api/entity-data.service.ts +++ b/ui-ngx/src/app/core/api/entity-data.service.ts @@ -183,6 +183,7 @@ export class EntityDataService { entityDataSubscriptionOptions.alarmFilter = datasource.alarmFilter; entityDataSubscriptionOptions.keyFilters = keyFilters; entityDataSubscriptionOptions.additionalKeyFilters = additionalKeyFilters; + entityDataSubscriptionOptions.keyFiltersOperation = datasource.keyFiltersOperation; if (entityDataSubscriptionOptions.datasourceType === DatasourceType.entity) { entityDataSubscriptionOptions.pageLink = pageLink; } diff --git a/ui-ngx/src/app/core/auth/auth.models.ts b/ui-ngx/src/app/core/auth/auth.models.ts index 5603a21dd7..482c3a6aa5 100644 --- a/ui-ngx/src/app/core/auth/auth.models.ts +++ b/ui-ngx/src/app/core/auth/auth.models.ts @@ -41,6 +41,7 @@ export interface SysParamsState { calculatedFieldDebugPerTenantLimitsConfiguration?: string; intermediateAggregationIntervalInSecForCF: number; trendzSettings: TrendzSettings; + allowKeyFiltersOrConditions: boolean; nullsOrderStrategy: NullsOrderStrategy; edqsEnabled: boolean; iotHubBaseUrl: string; diff --git a/ui-ngx/src/app/core/auth/auth.reducer.ts b/ui-ngx/src/app/core/auth/auth.reducer.ts index c0fe744f25..ce3107a4ef 100644 --- a/ui-ngx/src/app/core/auth/auth.reducer.ts +++ b/ui-ngx/src/app/core/auth/auth.reducer.ts @@ -43,6 +43,7 @@ const emptyUserAuthState: AuthPayload = { intermediateAggregationIntervalInSecForCF: 0, userSettings: initialUserSettings, trendzSettings: initialTrendzSettings, + allowKeyFiltersOrConditions: true, nullsOrderStrategy: 'default', edqsEnabled: false, iotHubBaseUrl: '' 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 69791908b3..6b5fb3e9b3 100644 --- a/ui-ngx/src/app/core/interceptors/global-http-interceptor.ts +++ b/ui-ngx/src/app/core/interceptors/global-http-interceptor.ts @@ -116,6 +116,8 @@ export class GlobalHttpInterceptor implements HttpInterceptor { } else if (errorResponse.status === 429) { if (resendRequest) { return this.retryRequest(req, next); + } else { + unhandled = true; } } else if (errorResponse.status === 403) { if (!ignoreErrors) { 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/utils.ts b/ui-ngx/src/app/core/utils.ts index 7f13f08ba6..17458c908a 100644 --- a/ui-ngx/src/app/core/utils.ts +++ b/ui-ngx/src/app/core/utils.ts @@ -226,8 +226,24 @@ export function objToBase64(obj: any): string { })); } +const textDecoderUtf8 = new TextDecoder('UTF-8', { fatal: true }); +const textDecoderLatin1 = new TextDecoder('ISO-8859-1'); + +export function bytesToString(bytes: Uint8Array): string { + try { + return textDecoderUtf8.decode(bytes); + } catch { + return textDecoderLatin1.decode(bytes); + } +} + export function base64toString(b64Encoded: string): string { - return decodeURIComponent(atob(b64Encoded).split('').map((c) => '%' + ('00' + c.charCodeAt(0).toString(16)).slice(-2)).join('')); + const binary = atob(b64Encoded); + const bytes = new Uint8Array(binary.length); + for (let i = 0; i < binary.length; i++) { + bytes[i] = binary.charCodeAt(i); + } + return bytesToString(bytes); } export function objToBase64URI(obj: any): string { @@ -235,8 +251,7 @@ export function objToBase64URI(obj: any): string { } export function base64toObj(b64Encoded: string): any { - const json = decodeURIComponent(atob(b64Encoded).split('').map((c) => '%' + ('00' + c.charCodeAt(0).toString(16)).slice(-2)).join('')); - return JSON.parse(json); + return JSON.parse(base64toString(b64Encoded)); } export function stringToBase64(value: string): string { 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 27c7ecf63e..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 @@ -102,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.component.html b/ui-ngx/src/app/modules/home/components/alarm-rules/alarm-rules.component.html index a5903e1019..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) { +
+ + + +
+}
@@ -55,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 fbbce2a469..4c7ebc7ae2 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..c5618cc4b4 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,114 @@
- + @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 }} +
+
+ @if(displayDataElement.canEdit) { + + } + @if(displayDataElement.canDelete) { + + } +
- - -
- - + } @else { +
+
+ {{ getUserInitials(displayDataElement.displayName) }}
- -
- - -
+ + +
+ + +
+
+
+ } + } +
+ }
- + @if (isDirectionAscending()) { - + }
-
- {{ getUserInitials(userDisplayName) }} -
+ @if (userDisplayName$ | async; as userDisplayName) { +
+ {{ getUserInitials(userDisplayName) }} +
+ } - + @if (getAlarmCommentValue()) { + + }
diff --git a/ui-ngx/src/app/modules/home/components/alarm/alarm-comment.component.ts b/ui-ngx/src/app/modules/home/components/alarm/alarm-comment.component.ts index cce31840ab..0160037ade 100644 --- a/ui-ngx/src/app/modules/home/components/alarm/alarm-comment.component.ts +++ b/ui-ngx/src/app/modules/home/components/alarm/alarm-comment.component.ts @@ -22,6 +22,7 @@ import { AlarmCommentService } from '@core/http/alarm-comment.service'; import { AbstractControl, FormBuilder, FormGroup } from '@angular/forms'; import { DialogService } from '@core/services/dialog.service'; import { AuthUser } from '@shared/models/user.model'; +import { Authority } from '@shared/models/authority.enum'; import { getCurrentAuthUser, selectUserDetails } from '@core/auth/auth.selectors'; import { Direction, SortOrder } from '@shared/models/page/sort-order'; import { MAX_SAFE_PAGE_SIZE, PageLink } from '@shared/models/page/page-link'; @@ -52,6 +53,8 @@ interface AlarmCommentsDisplayData { editedTime?: string; editedDateAgo?: string; showActions?: boolean; + canEdit?: boolean; + canDelete?: boolean; commentText?: string; isSystemComment?: boolean; avatarBgColor?: string; @@ -143,6 +146,11 @@ export class AlarmCommentComponent implements OnInit { displayDataElement.editedTime = this.datePipe.transform(alarmComment.comment.editedOn, 'yyyy-MM-dd HH:mm:ss'); displayDataElement.editedDateAgo = this.dateAgoPipe.transform(alarmComment.comment.editedOn) + '\n'; displayDataElement.showActions = false; + const isCommentAuthor = this.authUser.userId === alarmComment.userId?.id; + // Mirrors backend AlarmCommentController#deleteAlarmComment / checkUserPermission: + // author may edit and delete own comments; tenant admin may delete any comment. + displayDataElement.canEdit = isCommentAuthor; + displayDataElement.canDelete = isCommentAuthor || this.authUser.authority === Authority.TENANT_ADMIN; displayDataElement.isSystemComment = false; displayDataElement.avatarBgColor = this.utilsService.stringToHslColor(displayDataElement.displayName, 40, 60); @@ -273,11 +281,11 @@ export class AlarmCommentComponent implements OnInit { return this.alarmCommentSortOrder.direction === Direction.ASC; } - onCommentMouseEnter(commentId: string, displayDataIndex: number): void { + onCommentMouseEnter(displayDataIndex: number): void { if (!this.editMode) { - const alarmUserId = this.getAlarmCommentById(commentId).userId.id; - if (this.authUser.userId === alarmUserId) { - this.displayData[displayDataIndex].showActions = true; + const displayDataElement = this.displayData[displayDataIndex]; + if (displayDataElement.canEdit || displayDataElement.canDelete) { + displayDataElement.showActions = true; } } } 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) { + + + }
-
+
dashboard.select-existing
-
+
dashboard.create-new - + dashboard.new-dashboard-title - - {{ 'dashboard.title-required' | translate }} - + @if (addWidgetFormGroup.get('newDashboardTitle').hasError('required')) { + + {{ 'dashboard.title-required' | translate }} + + }
diff --git a/ui-ngx/src/app/modules/home/components/attribute/attribute-table.component.html b/ui-ngx/src/app/modules/home/components/attribute/attribute-table.component.html index c7d204884b..0ed2207b7f 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 0cc41c3546..717ff08020 100644 --- a/ui-ngx/src/app/modules/home/components/attribute/attribute-table.component.ts +++ b/ui-ngx/src/app/modules/home/components/attribute/attribute-table.component.ts @@ -40,7 +40,7 @@ import { MatDialog } from '@angular/material/dialog'; import { DialogService } from '@core/services/dialog.service'; import { Direction, SortOrder } from '@shared/models/page/sort-order'; import { forkJoin, merge, Observable, Subject } from 'rxjs'; -import { debounceTime, distinctUntilChanged, takeUntil } from 'rxjs/operators'; +import { debounceTime, distinctUntilChanged, take, takeUntil } from 'rxjs/operators'; import { EntityId } from '@shared/models/id/entity-id'; import { AttributeData, @@ -195,6 +195,7 @@ export class AttributeTableComponent extends PageComponent implements AfterViewI isSysAdmin = false; private destroy$ = new Subject(); + selectAllModel: boolean = false; constructor(protected store: Store, private attributeService: AttributeService, @@ -232,6 +233,14 @@ export class AttributeTableComponent extends PageComponent implements AfterViewI }); }); this.widgetResize$.observe(this.elementRef.nativeElement); + + this.dataSource.selection.changed.pipe( + takeUntil(this.destroy$) + ).subscribe(() => { + this.dataSource.isAllSelected().pipe(take(1)).subscribe(allSelected => { + this.selectAllModel = allSelected; + }); + }); } ngOnDestroy() { @@ -246,6 +255,7 @@ export class AttributeTableComponent extends PageComponent implements AfterViewI this.attributeScope = attributeScope; this.mode = 'default'; this.paginator.pageIndex = 0; + this.selectAllModel = false; this.updateData(true); } diff --git a/ui-ngx/src/app/modules/home/components/attribute/delete-timeseries-panel.component.html b/ui-ngx/src/app/modules/home/components/attribute/delete-timeseries-panel.component.html index 600d3f2a93..251496096b 100644 --- a/ui-ngx/src/app/modules/home/components/attribute/delete-timeseries-panel.component.html +++ b/ui-ngx/src/app/modules/home/components/attribute/delete-timeseries-panel.component.html @@ -25,31 +25,37 @@ - + attribute.delete-timeseries.strategy - - {{ strategiesTranslationsMap.get(strategy) | translate }} - + @for (strategy of strategiesTranslationsMap.keys(); track strategy) { + + {{ strategiesTranslationsMap.get(strategy) | translate }} + + } -
- - attribute.delete-timeseries.start-time - - - - - - attribute.delete-timeseries.ends-on - - - - -
- - {{ "attribute.delete-timeseries.rewrite-latest-value" | translate }} - + @if (isPeriodStrategy()) { +
+ + attribute.delete-timeseries.start-time + + + + + + attribute.delete-timeseries.ends-on + + + + +
+ } + @if (isPeriodStrategy() || isDeleteLatestStrategy()) { + + {{ "attribute.delete-timeseries.rewrite-latest-value" | translate }} + + }
diff --git a/ui-ngx/src/app/modules/home/components/attribute/delete-timeseries-panel.component.scss b/ui-ngx/src/app/modules/home/components/attribute/delete-timeseries-panel.component.scss index c42ab55735..4a5969afe7 100644 --- a/ui-ngx/src/app/modules/home/components/attribute/delete-timeseries-panel.component.scss +++ b/ui-ngx/src/app/modules/home/components/attribute/delete-timeseries-panel.component.scss @@ -26,7 +26,7 @@ display: flex; flex-direction: column; gap: 16px; - padding-top: 0; + padding-top: 4px; } .tb-select-interval { diff --git a/ui-ngx/src/app/modules/home/components/calculated-fields/calculated-field.component.html b/ui-ngx/src/app/modules/home/components/calculated-fields/calculated-field.component.html index 9eb8680594..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) { +
+ + + +
+}
@@ -55,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) { @@ -83,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/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 91f1fc0ac2..49c7fad6e2 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 }}
@@ -139,5 +141,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) { - - + } + @if (isEdit) { - - + } + @if (currentDashboardId && isEdit && isTenantAdmin()) { - - + } + @if (isEdit) { - - - - - + } - + @if (isEdit) { - - - + } @else { + @if (displayDashboardTimewindow()) { > - - - + } + } + @if (isEdit) { - + } @@ -262,12 +263,13 @@ tooltipPosition="below" [aliasController]="dashboardCtx.aliasController"> - - + @if (!isEdit && !widgetEditMode && !embedded && displayDashboardsSelect()) { + + + }
@@ -281,41 +283,45 @@ [class.tb-dashboard-toolbar-closed]="!toolbarOpened"> -
-

{{ translatedDashboardTitle }}

- - dashboard.title - - -
+ @if (!widgetEditMode) { +
+

{{ translatedDashboardTitle }}

+ + dashboard.title + + +
+ } - - - - + + + + } -
- {{ 'dashboard.breakpoint-value' | translate : {value: currentBreakpointValue } }} -
+ @if (isEdit) { +
+ {{ 'dashboard.breakpoint-value' | translate : {value: currentBreakpointValue } }} +
+ }
-
- - - -
- + @if (!readonly && (hideToolbar || widgetEditMode)) { +
+ + + +
+ } + @if (!embedded) { + + }
- -
- - {{ 'widget.basic-mode' | translate }} - {{ 'widget.advanced-mode' | translate }} - -
-
- - -
- - + @if (!isEditingWidgetClosed) { + +
+ @if (tbEditWidget.widgetConfig?.hasBasicMode) { + + {{ 'widget.basic-mode' | translate }} + {{ 'widget.advanced-mode' | translate }} + + } +
+
+ + +
+ } + @if (!isAddingWidgetClosed && !widgetEditMode && 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 +
+
+ } +
+ }
-
+ }
- + @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..58cb21253d 100644 --- a/ui-ngx/src/app/modules/home/components/dashboard-page/states/dashboard-state-dialog.component.html +++ b/ui-ngx/src/app/modules/home/components/dashboard-page/states/dashboard-state-dialog.component.html @@ -25,26 +25,34 @@ close - - + @if (isLoading$ | async) { + + + }
- + dashboard.state-name - - {{ 'dashboard.state-name-required' | translate }} - + @if (stateFormGroup.get('name').hasError('required')) { + + {{ 'dashboard.state-name-required' | translate }} + + } - + dashboard.state-id - - {{ 'dashboard.state-id-required' | translate }} - - - {{ 'dashboard.state-id-exists' | translate }} - + @if (stateFormGroup.get('id').hasError('required')) { + + {{ 'dashboard.state-id-required' | translate }} + + } + @if (stateFormGroup.get('id').hasError('stateExists')) { + + {{ 'dashboard.state-id-exists' | translate }} + + } {{ 'dashboard.is-root-state' | translate }} diff --git a/ui-ngx/src/app/modules/home/components/dashboard-page/states/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/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..fa9044ed9b 100644 --- a/ui-ngx/src/app/modules/home/components/event/event-filter-panel.component.html +++ b/ui-ngx/src/app/modules/home/components/event/event-filter-panel.component.html @@ -17,48 +17,52 @@ -->
- - - - + @for (column of showColumns; track column) { + @switch (column.key) { + @case (isSelector(column.key)) { + {{ column.title | translate}} {{ 'event.all-events' | translate}} - - {{ value }} - + @for (value of selectorValues(column.key); track value) { + + {{ value }} + + } - - - + } + @case (isNumberFields(column.key)) { + {{ column.title | translate}} - - {{ 'event.min-value' | translate }} - + @if (eventFilterFormGroup.get(column.key).hasError('min')) { + + {{ 'event.min-value' | translate }} + + } - - + } + @case ('isError') { {{ 'event.has-error' | translate }} - - - + } + @case ('errorStr') { + {{ column.title | translate}} - - - + } + @default { + {{ column.title | translate}} - - - + } + } + }
- + @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..0cd0ea729e 100644 --- a/ui-ngx/src/app/modules/home/components/filter/filter-dialog.component.html +++ b/ui-ngx/src/app/modules/home/components/filter/filter-dialog.component.html @@ -16,54 +16,61 @@ -->
- +

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

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

{{ filter.filter | customTranslate }}

-
- -
-
-
-
- - - {{ userInputControl.get('label').value | customTranslate }} - - - - - - {{ userInputControl.get('label').value | customTranslate }} - -
- {{ userInputControl.get('unitSymbol').value }} + @for (userInputControl of userInputsFormArray.controls; track userInputControl) { + @switch (userInputControl.controls.valueType.value) { + @case (valueTypeEnum.STRING) { + + {{ userInputControl.controls.label.value | customTranslate }} + + + } + @case (valueTypeEnum.NUMERIC) { + + {{ userInputControl.controls.label.value | customTranslate }} + + @if (userInputControl.controls.unitSymbol.value) { +
+ {{ userInputControl.controls.unitSymbol.value }}
-
- - - - - - - - {{ userInputControl.get('label').value | customTranslate }} - - -
-
-
+ } + + } + @case (valueTypeEnum.DATE_TIME) { + + } + @case (valueTypeEnum.BOOLEAN) { + + + {{ userInputControl.controls.label.value | customTranslate }} + + + } + } + }
diff --git a/ui-ngx/src/app/modules/home/components/filter/user-filter-dialog.component.scss b/ui-ngx/src/app/modules/home/components/filter/user-filter-dialog.component.scss index d3b1d316ab..552c49b56a 100644 --- a/ui-ngx/src/app/modules/home/components/filter/user-filter-dialog.component.scss +++ b/ui-ngx/src/app/modules/home/components/filter/user-filter-dialog.component.scss @@ -14,14 +14,14 @@ * limitations under the License. */ :host ::ng-deep { - .user-filter-dialog { - .mat-mdc-form-field.mat-mdc-form-field-has-icon-suffix.mat-form-field-hide-placeholder { + .numeric-field { + &.mat-mdc-form-field-has-icon-suffix.mat-form-field-hide-placeholder { .mat-mdc-form-field-icon-suffix { place-self: center; } } - .mat-mdc-form-field.mat-mdc-form-field-has-icon-suffix { + &.mat-mdc-form-field-has-icon-suffix { .mat-mdc-form-field-icon-suffix { place-self: baseline; } diff --git a/ui-ngx/src/app/modules/home/components/filter/user-filter-dialog.component.ts b/ui-ngx/src/app/modules/home/components/filter/user-filter-dialog.component.ts index 6e068fdcf3..59231f8b80 100644 --- a/ui-ngx/src/app/modules/home/components/filter/user-filter-dialog.component.ts +++ b/ui-ngx/src/app/modules/home/components/filter/user-filter-dialog.component.ts @@ -14,12 +14,11 @@ /// limitations under the License. /// -import { Component, DestroyRef, Inject, SkipSelf } from '@angular/core'; -import { ErrorStateMatcher } from '@angular/material/core'; +import { Component, DestroyRef, Inject } from '@angular/core'; import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog'; import { Store } from '@ngrx/store'; import { AppState } from '@core/core.state'; -import { FormArray, FormBuilder, FormControl, FormGroup, FormGroupDirective, NgForm, Validators } from '@angular/forms'; +import { FormArray, FormBuilder, FormControl, FormGroup, Validators } from '@angular/forms'; import { Router } from '@angular/router'; import { DialogComponent } from '@app/shared/components/dialog.component'; import { TranslateService } from '@ngx-translate/core'; @@ -39,29 +38,31 @@ export interface UserFilterDialogData { filter: Filter; } +interface UserInputForm { + label: FormControl; + valueType: FormControl; + unitSymbol: FormControl; + value: FormControl; +} + @Component({ selector: 'tb-user-filter-dialog', templateUrl: './user-filter-dialog.component.html', - providers: [{ provide: ErrorStateMatcher, useExisting: UserFilterDialogComponent }], styleUrls: ['./user-filter-dialog.component.scss'], standalone: false }) -export class UserFilterDialogComponent extends DialogComponent - implements ErrorStateMatcher { +export class UserFilterDialogComponent extends DialogComponent { filter: Filter; - userFilterFormGroup: FormGroup; + userInputsFormArray: FormArray>; valueTypeEnum = EntityKeyValueType; - submitted = false; - constructor(protected store: Store, protected router: Router, @Inject(MAT_DIALOG_DATA) public data: UserFilterDialogData, - @SkipSelf() private errorStateMatcher: ErrorStateMatcher, - public dialogRef: MatDialogRef, + protected dialogRef: MatDialogRef, private fb: FormBuilder, private translate: TranslateService, private destroyRef: DestroyRef, @@ -70,19 +71,13 @@ export class UserFilterDialogComponent extends DialogComponent = []; - for (const userInput of userInputs) { - userInputControls.push(this.createUserInputFormControl(userInput)); - } - - this.userFilterFormGroup = this.fb.group({ - userInputs: this.fb.array(userInputControls) - }); + const userInputControls = userInputs.map(input => this.createUserInputFormControl(input)); + this.userInputsFormArray = this.fb.array(userInputControls); } - private createUserInputFormControl(userInput: UserFilterInputInfo): FormGroup { + private createUserInputFormControl(userInput: UserFilterInputInfo): FormGroup { const predicateValue: FilterPredicateValue = (userInput.info.keyFilterPredicate as any).value; - let value = isDefinedAndNotNull(predicateValue.userValue) ? predicateValue.userValue : predicateValue.defaultValue; + let value: string | number | boolean = isDefinedAndNotNull(predicateValue.userValue) ? predicateValue.userValue : predicateValue.defaultValue; let unitSymbol = ''; let valueConvertor: TbUnitConverter; if (userInput.valueType === EntityKeyValueType.NUMERIC) { @@ -91,42 +86,31 @@ export class UserFilterDialogComponent extends DialogComponent({ + label: this.fb.control(userInput.label), + valueType: this.fb.control(userInput.valueType), + unitSymbol: this.fb.control(unitSymbol), + value: this.fb.control(value, userInput.valueType === EntityKeyValueType.NUMERIC || - userInput.valueType === EntityKeyValueType.DATE_TIME ? [Validators.required] : []] + userInput.valueType === EntityKeyValueType.DATE_TIME ? [Validators.required] : []) }); - userInputControl.get('value').valueChanges.pipe( + userInputControl.controls.value.valueChanges.pipe( takeUntilDestroyed(this.destroyRef) ).subscribe(userValue => { - let value = userValue; + let val = userValue; if (valueConvertor) { - value = valueConvertor(value as number); + val = valueConvertor(val as number); } - (userInput.info.keyFilterPredicate as any).value.userValue = value; + (userInput.info.keyFilterPredicate as any).value.userValue = val; }); return userInputControl; } - userInputsFormArray(): FormArray { - return this.userFilterFormGroup.get('userInputs') as FormArray; - } - - isErrorState(control: FormControl | null, form: FormGroupDirective | NgForm | null): boolean { - const originalErrorState = this.errorStateMatcher.isErrorState(control, form); - const customErrorState = !!(control && control.invalid && this.submitted); - return originalErrorState || customErrorState; - } - cancel(): void { this.dialogRef.close(null); } save(): void { - this.submitted = true; this.dialogRef.close(this.filter); } } diff --git a/ui-ngx/src/app/modules/home/components/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) { + + {{deviceProvisionTypeTranslateMap.get(type) | translate}} + + } - - {{ 'device-profile.provision-strategy-required' | translate }} - + @if (provisionConfigurationFormGroup.get('type').hasError('required')) { + {{ 'device-profile.provision-strategy-required' | translate }} + } -
- - - - - - - + @switch (provisionConfigurationFormGroup.get('type').value) { + @case (deviceProvisionType.ALLOW_CREATE_NEW_DEVICES) { + + } + @case (deviceProvisionType.CHECK_PRE_PROVISIONED_DEVICES) { + + } + @case (deviceProvisionType.X509_CERTIFICATE_CHAIN) {
- + device-profile.provision-strategy-x509.certificate-value - - - {{ 'device-profile.provision-strategy-x509.certificate-value-required' | translate }} - + + @if (provisionConfigurationFormGroup.get('certificateValue').hasError('required')) { + {{ 'device-profile.provision-strategy-x509.certificate-value-required' | translate }} + } - + device-profile.provision-strategy-x509.cn-regex-variable - - {{ 'device-profile.provision-strategy-x509.cn-regex-variable-required' | translate }} - + tb-help-popup="device-profile/x509-chain-regex-examples" + tb-help-popup-placement="top" + trigger-style="letter-spacing:0.25px" + [tb-help-popup-style]="{maxWidth: '820px'}">
+ @if (provisionConfigurationFormGroup.get('certificateRegExPattern').hasError('required')) { + {{ 'device-profile.provision-strategy-x509.cn-regex-variable-required' | translate }} + } device-profile.provision-strategy-x509.cn-regex-variable-hint - - -
- - device-profile.provision-device-key - - - - {{ 'device-profile.provision-device-key-required' | translate }} - - - - device-profile.provision-device-secret - - - - {{ 'device-profile.provision-device-secret-required' | translate }} - - -
-
-
+ } + } + +
+ + device-profile.provision-device-key + + + @if (provisionConfigurationFormGroup.get('provisionDeviceKey').hasError('required')) { + {{ 'device-profile.provision-device-key-required' | translate }} + } + + + device-profile.provision-device-secret + + + @if (provisionConfigurationFormGroup.get('provisionDeviceSecret').hasError('required')) { + {{ 'device-profile.provision-device-secret-required' | translate }} + } + +
+
diff --git a/ui-ngx/src/app/modules/home/components/profile/device-profile-provision-configuration.component.ts b/ui-ngx/src/app/modules/home/components/profile/device-profile-provision-configuration.component.ts index 1da59bbc5c..05aef4cfbb 100644 --- a/ui-ngx/src/app/modules/home/components/profile/device-profile-provision-configuration.component.ts +++ b/ui-ngx/src/app/modules/home/components/profile/device-profile-provision-configuration.component.ts @@ -62,7 +62,7 @@ export class DeviceProfileProvisionConfigurationComponent implements ControlValu provisionConfigurationFormGroup: UntypedFormGroup; deviceProvisionType = DeviceProvisionType; - deviceProvisionTypes = Object.keys(DeviceProvisionType); + deviceProvisionTypes = Object.values(DeviceProvisionType); deviceProvisionTypeTranslateMap = deviceProvisionTypeTranslationMap; private requiredValue: boolean; diff --git a/ui-ngx/src/app/modules/home/components/profile/device-profile.component.html b/ui-ngx/src/app/modules/home/components/profile/device-profile.component.html index 00ddc65358..9b54e59253 100644 --- a/ui-ngx/src/app/modules/home/components/profile/device-profile.component.html +++ b/ui-ngx/src/app/modules/home/components/profile/device-profile.component.html @@ -15,79 +15,90 @@ limitations under the License. --> -
- - - - -
- + + + +
+ +
-
+}
-
- +
+ device-profile.name - - {{ 'device-profile.name-required' | translate }} - - - {{ 'device-profile.name-max-length' | translate }} - + @if (entityForm.get('name').hasError('required')) { + {{ 'device-profile.name-required' | translate }} + } + @if (entityForm.get('name').hasError('maxlength')) { + {{ 'device-profile.name-max-length' | translate }} + } - {{'device-profile.mobile-dashboard-hint' | translate}} + @if (!entityForm.get('defaultDashboardId').disabled) { + {{'device-profile.mobile-dashboard-hint' | translate}} + } - {{'device-profile.default-edge-rule-chain-hint' | translate}} + @if (!entityForm.get('defaultEdgeRuleChainId').disabled) { + {{'device-profile.default-edge-rule-chain-hint' | translate}} + } - + device-profile.type - - {{deviceProfileTypeTranslations.get(type) | translate}} - + @for (type of deviceProfileTypes; track type) { + + {{ deviceProfileTypeTranslations.get(type) | translate }} + + } - - {{ 'device-profile.type-required' | translate }} - + @if (entityForm.get('type').hasError('required')) { + {{ 'device-profile.type-required' | translate }} + } - + device-profile.description - + - - device-profile.transport-type - - - {{deviceTransportTypeTranslations.get(type) | translate}} - - - - {{ 'device-profile.transport-type-required' | translate }} - - -
- - - - -
device-profile.profile-configuration
-
-
- - - - -
- - - -
device-profile.transport-configuration
-
-
- - - - -
- @if (entityForm.get('profileData.alarms').value?.length) { - + @if (standalone) { + + device-profile.transport-type + + @for (type of deviceTransportTypes; track type) { + + {{ deviceTransportTypeTranslations.get(type) | translate }} + + } + + @if (entityForm.get('transportType').hasError('required')) { + {{ 'device-profile.transport-type-required' | translate }} + } + +
+ + @if (displayProfileConfiguration) { + + + +
device-profile.profile-configuration
+
+
+ + + + +
+ } + @if (displayTransportConfiguration) { + + + +
device-profile.transport-configuration
+
+
+ + + + +
+ } + @if (entityForm.get('profileData.alarms').value?.length) { + + + +
{{'device-profile.alarm-rules-with-count' | translate: + {count: entityForm.get('profileData.alarms').value ? + entityForm.get('profileData.alarms').value.length : 0} }}
+
+
+ + + + +
+ } + -
{{'device-profile.alarm-rules-with-count' | translate: - {count: entityForm.get('profileData.alarms').value ? - entityForm.get('profileData.alarms').value.length : 0} }}
+
device-profile.device-provisioning
- - + +
- } - - - -
device-profile.device-provisioning
-
-
- - - - -
-
-
+
+
+ }
diff --git a/ui-ngx/src/app/modules/home/components/profile/device/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..1ad031c3a9 100644 --- a/ui-ngx/src/app/modules/home/components/profile/device/lwm2m/lwm2m-attributes-key-list.component.html +++ b/ui-ngx/src/app/modules/home/components/profile/device/lwm2m/lwm2m-attributes-key-list.component.html @@ -21,51 +21,63 @@
-
-
- - - - {{ attributeNameTranslationMap.get(attributeName) | translate }} - - - - {{ 'device-profile.lwm2m.attribute-name-required' | translate }} - - - - - - {{ 'device-profile.lwm2m.attribute-value-required' | translate }} - - - {{ 'device-profile.lwm2m.attribute-value-pattern' | translate }} - - - + } +
+
+ } +
device-profile.lwm2m.no-attributes-set
+ @if (!disabled && isAddEnabled) { +
+
-
-
device-profile.lwm2m.no-attributes-set
-
- -
+ } diff --git a/ui-ngx/src/app/modules/home/components/profile/device/lwm2m/lwm2m-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..2f0ef4d0af 100644 --- a/ui-ngx/src/app/modules/home/components/profile/device/snmp/snmp-device-profile-mapping.component.html +++ b/ui-ngx/src/app/modules/home/components/profile/device/snmp/snmp-device-profile-mapping.component.html @@ -25,54 +25,70 @@
-
-
- - - - {{ dataTypesTranslationMap.get(dataType) | translate }} - - - - {{ 'device-profile.snmp.data-type-required' | translate }} - - - - - - {{ 'device-profile.snmp.data-key-required' | translate }} - - - - - - {{ 'device-profile.snmp.oid-required' | translate }} - - - {{ 'device-profile.snmp.oid-pattern' | translate }} - - - + } +
+
+ } + @if (!mappingsConfigFormArray.controls.length && !disabled) { +
+ device-profile.snmp.please-add-mapping-config +
+ } + @if (!disabled) { +
+
- -
- device-profile.snmp.please-add-mapping-config -
-
- -
+ } diff --git a/ui-ngx/src/app/modules/home/components/profile/device/snmp/snmp-device-profile-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..4d7b127aa3 100644 --- a/ui-ngx/src/app/modules/home/components/profile/tenant-profile-autocomplete.component.html +++ b/ui-ngx/src/app/modules/home/components/profile/tenant-profile-autocomplete.component.html @@ -15,7 +15,7 @@ limitations under the License. --> - + tenant-profile.tenant-profile - - {{ displayTenantProfileFn(selectTenantProfileFormGroup.get('tenantProfile').value) }} - - - - + @if (showDetailsPageLink && selectTenantProfileFormGroup.get('tenantProfile').value && disabled) { + + {{ displayTenantProfileFn(selectTenantProfileFormGroup.get('tenantProfile').value) }} + + } + @if (selectTenantProfileFormGroup.get('tenantProfile').value && !disabled) { + + } + @if (selectTenantProfileFormGroup.get('tenantProfile').value?.id && !disabled) { + + } + @if (!selectTenantProfileFormGroup.get('tenantProfile').value && !disabled) { + + } - - - - -
-
- tenant-profile.no-tenant-profiles-found -
- - + @for (tenantProfile of filteredTenantProfiles | async; track tenantProfile) { + + + + } + @if (!(filteredTenantProfiles | async)?.length) { + +
+ @if (!textIsNotEmpty(searchText)) { +
+ tenant-profile.no-tenant-profiles-found +
+ } @else { + {{ translate.get('tenant-profile.no-tenant-profiles-matching', - {entity: truncate.transform(searchText, true, 6, '...')}) | async }} + {entity: truncate.transform(searchText, true, 6, '...')}) | async }} + + } + + tenant-profile.create-new-tenant-profile - - - tenant-profile.create-new-tenant-profile - -
-
+
+
+ }
- - {{ 'tenant-profile.tenant-profile-required' | translate }} - + @if (selectTenantProfileFormGroup.get('tenantProfile').hasError('required')) { + + {{ 'tenant-profile.tenant-profile-required' | translate }} + + }
diff --git a/ui-ngx/src/app/modules/home/components/profile/tenant-profile-autocomplete.component.ts b/ui-ngx/src/app/modules/home/components/profile/tenant-profile-autocomplete.component.ts index 1bd785cf7a..f59d79cd4a 100644 --- a/ui-ngx/src/app/modules/home/components/profile/tenant-profile-autocomplete.component.ts +++ b/ui-ngx/src/app/modules/home/components/profile/tenant-profile-autocomplete.component.ts @@ -35,6 +35,7 @@ import { MatDialog } from '@angular/material/dialog'; import { TenantProfileDialogComponent, TenantProfileDialogData } from './tenant-profile-dialog.component'; import { emptyPageData } from '@shared/models/page/page-data'; import { getEntityDetailsPageURL } from '@core/utils'; +import { MatFormFieldAppearance } from '@angular/material/form-field'; @Component({ selector: 'tb-tenant-profile-autocomplete', @@ -71,6 +72,9 @@ export class TenantProfileAutocompleteComponent implements ControlValueAccessor, @Input() showDetailsPageLink = false; + @Input() + appearance: MatFormFieldAppearance = 'fill'; + @Output() tenantProfileUpdated = new EventEmitter(); diff --git a/ui-ngx/src/app/modules/home/components/profile/tenant-profile-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} }} + + + + + + + } @@ -95,9 +103,9 @@
- + tenant-profile.description - +
diff --git a/ui-ngx/src/app/modules/home/components/profile/tenant/default-tenant-profile-configuration.component.html b/ui-ngx/src/app/modules/home/components/profile/tenant/default-tenant-profile-configuration.component.html index 19bd852f0a..ddd159388a 100644 --- a/ui-ngx/src/app/modules/home/components/profile/tenant/default-tenant-profile-configuration.component.html +++ b/ui-ngx/src/app/modules/home/components/profile/tenant/default-tenant-profile-configuration.component.html @@ -21,58 +21,74 @@ {{ 'tenant-profile.entities' | translate }} tenant-profile.unlimited
- + tenant-profile.maximum-devices - - {{ 'tenant-profile.maximum-devices-required' | translate}} - - - {{ 'tenant-profile.maximum-devices-range' | translate}} - + @if (tenantProfileConfigurationForm.get('maxDevices').hasError('required')) { + + {{ 'tenant-profile.maximum-devices-required' | translate}} + + } + @if (tenantProfileConfigurationForm.get('maxDevices').hasError('min')) { + + {{ 'tenant-profile.maximum-devices-range' | translate}} + + } - + tenant-profile.maximum-dashboards - - {{ 'tenant-profile.maximum-dashboards-required' | translate}} - - - {{ 'tenant-profile.maximum-dashboards-range' | translate}} - + @if (tenantProfileConfigurationForm.get('maxDashboards').hasError('required')) { + + {{ 'tenant-profile.maximum-dashboards-required' | translate}} + + } + @if (tenantProfileConfigurationForm.get('maxDashboards').hasError('min')) { + + {{ 'tenant-profile.maximum-dashboards-range' | translate}} + + }
- + tenant-profile.maximum-assets - - {{ 'tenant-profile.maximum-assets-required' | translate}} - - - {{ 'tenant-profile.maximum-assets-range' | translate}} - + @if (tenantProfileConfigurationForm.get('maxAssets').hasError('required')) { + + {{ 'tenant-profile.maximum-assets-required' | translate}} + + } + @if (tenantProfileConfigurationForm.get('maxAssets').hasError('min')) { + + {{ 'tenant-profile.maximum-assets-range' | translate}} + + } - + tenant-profile.maximum-users - - {{ 'tenant-profile.maximum-users-required' | translate}} - - - {{ 'tenant-profile.maximum-users-range' | translate}} - + @if (tenantProfileConfigurationForm.get('maxUsers').hasError('required')) { + + {{ 'tenant-profile.maximum-users-required' | translate}} + + } + @if (tenantProfileConfigurationForm.get('maxUsers').hasError('min')) { + + {{ 'tenant-profile.maximum-users-range' | translate}} + + }
@@ -84,45 +100,57 @@
- + tenant-profile.maximum-customers - - {{ 'tenant-profile.maximum-customers-required' | translate}} - - - {{ 'tenant-profile.maximum-customers-range' | translate}} - + @if (tenantProfileConfigurationForm.get('maxCustomers').hasError('required')) { + + {{ 'tenant-profile.maximum-customers-required' | translate}} + + } + @if (tenantProfileConfigurationForm.get('maxCustomers').hasError('min')) { + + {{ 'tenant-profile.maximum-customers-range' | translate}} + + } - + tenant-profile.maximum-rule-chains - - {{ 'tenant-profile.maximum-rule-chains-required' | translate}} - - - {{ 'tenant-profile.maximum-rule-chains-range' | translate}} - + @if (tenantProfileConfigurationForm.get('maxRuleChains').hasError('required')) { + + {{ 'tenant-profile.maximum-rule-chains-required' | translate}} + + } + @if (tenantProfileConfigurationForm.get('maxRuleChains').hasError('min')) { + + {{ 'tenant-profile.maximum-rule-chains-range' | translate}} + + }
- + tenant-profile.maximum-edges - - {{ 'tenant-profile.maximum-edges-required' | translate }} - - - {{ 'tenant-profile.maximum-edges-range' | translate }} - + @if (tenantProfileConfigurationForm.get('maxEdges').hasError('required')) { + + {{ 'tenant-profile.maximum-edges-required' | translate }} + + } + @if (tenantProfileConfigurationForm.get('maxEdges').hasError('min')) { + + {{ 'tenant-profile.maximum-edges-range' | translate }} + + }
@@ -136,30 +164,38 @@ {{ 'tenant-profile.rule-engine' | translate }} tenant-profile.unlimited
- + tenant-profile.max-r-e-executions - - {{ 'tenant-profile.max-r-e-executions-required' | translate}} - - - {{ 'tenant-profile.max-r-e-executions-range' | translate}} - + @if (tenantProfileConfigurationForm.get('maxREExecutions').hasError('required')) { + + {{ 'tenant-profile.max-r-e-executions-required' | translate}} + + } + @if (tenantProfileConfigurationForm.get('maxREExecutions').hasError('min')) { + + {{ 'tenant-profile.max-r-e-executions-range' | translate}} + + } - + tenant-profile.max-transport-messages - - {{ 'tenant-profile.max-transport-messages-required' | translate}} - - - {{ 'tenant-profile.max-transport-messages-range' | translate}} - + @if (tenantProfileConfigurationForm.get('maxTransportMessages').hasError('required')) { + + {{ 'tenant-profile.max-transport-messages-required' | translate}} + + } + @if (tenantProfileConfigurationForm.get('maxTransportMessages').hasError('min')) { + + {{ 'tenant-profile.max-transport-messages-range' | translate}} + + }
@@ -171,58 +207,74 @@
- + tenant-profile.max-j-s-executions - - {{ 'tenant-profile.max-j-s-executions-required' | translate}} - - - {{ 'tenant-profile.max-j-s-executions-range' | translate}} - + @if (tenantProfileConfigurationForm.get('maxJSExecutions').hasError('required')) { + + {{ 'tenant-profile.max-j-s-executions-required' | translate}} + + } + @if (tenantProfileConfigurationForm.get('maxJSExecutions').hasError('min')) { + + {{ 'tenant-profile.max-j-s-executions-range' | translate}} + + } - + tenant-profile.max-tbel-executions - - {{ 'tenant-profile.max-tbel-executions-required' | translate}} - - - {{ 'tenant-profile.max-tbel-executions-range' | translate}} - + @if (tenantProfileConfigurationForm.get('maxTbelExecutions').hasError('required')) { + + {{ 'tenant-profile.max-tbel-executions-required' | translate}} + + } + @if (tenantProfileConfigurationForm.get('maxTbelExecutions').hasError('min')) { + + {{ 'tenant-profile.max-tbel-executions-range' | translate}} + + }
- + tenant-profile.max-rule-node-executions-per-message - - {{ 'tenant-profile.max-rule-node-executions-per-message-required' | translate}} - - - {{ 'tenant-profile.max-rule-node-executions-per-message-range' | translate}} - + @if (tenantProfileConfigurationForm.get('maxRuleNodeExecutionsPerMessage').hasError('required')) { + + {{ 'tenant-profile.max-rule-node-executions-per-message-required' | translate}} + + } + @if (tenantProfileConfigurationForm.get('maxRuleNodeExecutionsPerMessage').hasError('min')) { + + {{ 'tenant-profile.max-rule-node-executions-per-message-range' | translate}} + + } - + tenant-profile.max-transport-data-points - - {{ 'tenant-profile.max-transport-data-points-required' | translate}} - - - {{ 'tenant-profile.max-transport-data-points-range' | translate}} - + @if (tenantProfileConfigurationForm.get('maxTransportDataPoints').hasError('required')) { + + {{ 'tenant-profile.max-transport-data-points-required' | translate}} + + } + @if (tenantProfileConfigurationForm.get('maxTransportDataPoints').hasError('min')) { + + {{ 'tenant-profile.max-transport-data-points-range' | translate}} + + }
@@ -234,45 +286,57 @@ {{ 'tenant-profile.calculated-fields' | translate }} tenant-profile.unlimited
- + tenant-profile.max-calculated-fields - - {{ 'tenant-profile.max-calculated-fields-required' | translate}} - - - {{ 'tenant-profile.max-calculated-fields-range' | translate}} - + @if (tenantProfileConfigurationForm.get('maxCalculatedFieldsPerEntity').hasError('required')) { + + {{ 'tenant-profile.max-calculated-fields-required' | translate}} + + } + @if (tenantProfileConfigurationForm.get('maxCalculatedFieldsPerEntity').hasError('min')) { + + {{ 'tenant-profile.max-calculated-fields-range' | translate}} + + } - + tenant-profile.max-data-points-per-rolling-arg - - {{ 'tenant-profile.max-data-points-per-rolling-arg-required' | translate}} - - - {{ 'tenant-profile.max-data-points-per-rolling-arg-range' | translate}} - + @if (tenantProfileConfigurationForm.get('maxDataPointsPerRollingArg').hasError('required')) { + + {{ 'tenant-profile.max-data-points-per-rolling-arg-required' | translate}} + + } + @if (tenantProfileConfigurationForm.get('maxDataPointsPerRollingArg').hasError('min')) { + + {{ 'tenant-profile.max-data-points-per-rolling-arg-range' | translate}} + + }
- + tenant-profile.max-arguments-per-cf - - {{ 'tenant-profile.max-arguments-per-cf-required' | translate}} - - - {{ 'tenant-profile.max-arguments-per-cf-range' | translate}} - + @if (tenantProfileConfigurationForm.get('maxArgumentsPerCF').hasError('required')) { + + {{ 'tenant-profile.max-arguments-per-cf-required' | translate}} + + } + @if (tenantProfileConfigurationForm.get('maxArgumentsPerCF').hasError('min')) { + + {{ 'tenant-profile.max-arguments-per-cf-range' | translate}} + + }
@@ -285,129 +349,165 @@
- + tenant-profile.max-state-size - - {{ 'tenant-profile.max-state-size-required' | translate}} - - - {{ 'tenant-profile.max-state-size-range' | translate}} - + @if (tenantProfileConfigurationForm.get('maxStateSizeInKBytes').hasError('required')) { + + {{ 'tenant-profile.max-state-size-required' | translate}} + + } + @if (tenantProfileConfigurationForm.get('maxStateSizeInKBytes').hasError('min')) { + + {{ 'tenant-profile.max-state-size-range' | translate}} + + } - + tenant-profile.max-value-argument-size - - {{ 'tenant-profile.max-value-argument-size-required' | translate}} - - - {{ 'tenant-profile.max-value-argument-size-range' | translate}} - + @if (tenantProfileConfigurationForm.get('maxSingleValueArgumentSizeInKBytes').hasError('required')) { + + {{ 'tenant-profile.max-value-argument-size-required' | translate}} + + } + @if (tenantProfileConfigurationForm.get('maxSingleValueArgumentSizeInKBytes').hasError('min')) { + + {{ 'tenant-profile.max-value-argument-size-range' | translate}} + + }
- + tenant-profile.max-related-level-per-argument - - {{ 'tenant-profile.max-related-level-per-argument-required' | translate}} - - - {{ 'tenant-profile.max-related-level-per-argument-range' | translate}} - + @if (tenantProfileConfigurationForm.get('maxRelationLevelPerCfArgument').hasError('required')) { + + {{ 'tenant-profile.max-related-level-per-argument-required' | translate}} + + } + @if (tenantProfileConfigurationForm.get('maxRelationLevelPerCfArgument').hasError('min')) { + + {{ 'tenant-profile.max-related-level-per-argument-range' | translate}} + + } - + tenant-profile.min-allowed-scheduled-update-interval - - {{ 'tenant-profile.min-allowed-scheduled-update-interval-required' | translate}} - - - {{ 'tenant-profile.min-allowed-scheduled-update-interval-range' | translate}} - + @if (tenantProfileConfigurationForm.get('minAllowedScheduledUpdateIntervalInSecForCF').hasError('required')) { + + {{ 'tenant-profile.min-allowed-scheduled-update-interval-required' | translate}} + + } + @if (tenantProfileConfigurationForm.get('minAllowedScheduledUpdateIntervalInSecForCF').hasError('min')) { + + {{ 'tenant-profile.min-allowed-scheduled-update-interval-range' | translate}} + + }
- + tenant-profile.min-allowed-aggregation-interval - - {{ 'tenant-profile.min-allowed-aggregation-interval-required' | translate}} - - - {{ 'tenant-profile.min-allowed-aggregation-interval-range' | translate}} - + @if (tenantProfileConfigurationForm.get('minAllowedAggregationIntervalInSecForCF').hasError('required')) { + + {{ 'tenant-profile.min-allowed-aggregation-interval-required' | translate}} + + } + @if (tenantProfileConfigurationForm.get('minAllowedAggregationIntervalInSecForCF').hasError('min')) { + + {{ 'tenant-profile.min-allowed-aggregation-interval-range' | translate}} + + } - + tenant-profile.min-allowed-deduplication-interval - - {{ 'tenant-profile.min-allowed-deduplication-interval-required' | translate}} - - - {{ 'tenant-profile.min-allowed-deduplication-interval-range' | translate}} - + @if (tenantProfileConfigurationForm.get('minAllowedDeduplicationIntervalInSecForCF').hasError('required')) { + + {{ 'tenant-profile.min-allowed-deduplication-interval-required' | translate}} + + } + @if (tenantProfileConfigurationForm.get('minAllowedDeduplicationIntervalInSecForCF').hasError('min')) { + + {{ 'tenant-profile.min-allowed-deduplication-interval-range' | translate}} + + }
- + tenant-profile.intermediate-aggregation-interval - - {{ 'tenant-profile.intermediate-aggregation-interval-required' | translate}} - - - {{ 'tenant-profile.intermediate-aggregation-interval-range' | translate}} - + @if (tenantProfileConfigurationForm.get('intermediateAggregationIntervalInSecForCF').hasError('required')) { + + {{ 'tenant-profile.intermediate-aggregation-interval-required' | translate}} + + } + @if (tenantProfileConfigurationForm.get('intermediateAggregationIntervalInSecForCF').hasError('min')) { + + {{ 'tenant-profile.intermediate-aggregation-interval-range' | translate}} + + } - + tenant-profile.reevaluation-check-interval - - {{ 'tenant-profile.reevaluation-check-interval-required' | translate}} - - - {{ 'tenant-profile.reevaluation-check-interval-range' | translate}} - + @if (tenantProfileConfigurationForm.get('cfReevaluationCheckInterval').hasError('required')) { + + {{ 'tenant-profile.reevaluation-check-interval-required' | translate}} + + } + @if (tenantProfileConfigurationForm.get('cfReevaluationCheckInterval').hasError('min')) { + + {{ 'tenant-profile.reevaluation-check-interval-range' | translate}} + + }
- + tenant-profile.relation-search-entity-limit - - {{ 'tenant-profile.relation-search-entity-limit-required' | translate}} - - - {{ 'tenant-profile.relation-search-entity-limit-range' | translate}} - + @if (tenantProfileConfigurationForm.get('maxRelatedEntitiesToReturnPerCfArgument').hasError('required')) { + + {{ 'tenant-profile.relation-search-entity-limit-required' | translate}} + + } + @if (tenantProfileConfigurationForm.get('maxRelatedEntitiesToReturnPerCfArgument').hasError('min')) { + + {{ 'tenant-profile.relation-search-entity-limit-range' | translate}} + + } tenant-profile.relation-search-entity-limit-hint
@@ -421,86 +521,110 @@ {{ 'tenant-profile.time-to-live' | translate }} tenant-profile.unlimited
- + tenant-profile.max-d-p-storage-days - - {{ 'tenant-profile.max-d-p-storage-days-required' | translate}} - - - {{ 'tenant-profile.max-d-p-storage-days-range' | translate}} - + @if (tenantProfileConfigurationForm.get('maxDPStorageDays').hasError('required')) { + + {{ 'tenant-profile.max-d-p-storage-days-required' | translate}} + + } + @if (tenantProfileConfigurationForm.get('maxDPStorageDays').hasError('min')) { + + {{ 'tenant-profile.max-d-p-storage-days-range' | translate}} + + } - + tenant-profile.alarms-ttl-days - - {{ 'tenant-profile.alarms-ttl-days-required' | translate}} - - - {{ 'tenant-profile.alarms-ttl-days-days-range' | translate}} - + @if (tenantProfileConfigurationForm.get('alarmsTtlDays').hasError('required')) { + + {{ 'tenant-profile.alarms-ttl-days-required' | translate}} + + } + @if (tenantProfileConfigurationForm.get('alarmsTtlDays').hasError('min')) { + + {{ 'tenant-profile.alarms-ttl-days-days-range' | translate}} + + }
- + tenant-profile.default-storage-ttl-days - - {{ 'tenant-profile.default-storage-ttl-days-required' | translate}} - - - {{ 'tenant-profile.default-storage-ttl-days-range' | translate}} - + @if (tenantProfileConfigurationForm.get('defaultStorageTtlDays').hasError('required')) { + + {{ 'tenant-profile.default-storage-ttl-days-required' | translate}} + + } + @if (tenantProfileConfigurationForm.get('defaultStorageTtlDays').hasError('min')) { + + {{ 'tenant-profile.default-storage-ttl-days-range' | translate}} + + } - + tenant-profile.rpc-ttl-days - - {{ 'tenant-profile.rpc-ttl-days-required' | translate}} - - - {{ 'tenant-profile.rpc-ttl-days-days-range' | translate}} - + @if (tenantProfileConfigurationForm.get('rpcTtlDays').hasError('required')) { + + {{ 'tenant-profile.rpc-ttl-days-required' | translate}} + + } + @if (tenantProfileConfigurationForm.get('rpcTtlDays').hasError('min')) { + + {{ 'tenant-profile.rpc-ttl-days-days-range' | translate}} + + }
- + tenant-profile.queue-stats-ttl-days - - {{ 'tenant-profile.queue-stats-ttl-days-required' | translate}} - - - {{ 'tenant-profile.queue-stats-ttl-days-range' | translate}} - + @if (tenantProfileConfigurationForm.get('queueStatsTtlDays').hasError('required')) { + + {{ 'tenant-profile.queue-stats-ttl-days-required' | translate}} + + } + @if (tenantProfileConfigurationForm.get('queueStatsTtlDays').hasError('min')) { + + {{ 'tenant-profile.queue-stats-ttl-days-range' | translate}} + + } - + tenant-profile.rule-engine-exceptions-ttl-days - - {{ 'tenant-profile.rule-engine-exceptions-ttl-days-required' | translate}} - - - {{ 'tenant-profile.rule-engine-exceptions-ttl-days-range' | translate}} - + @if (tenantProfileConfigurationForm.get('ruleEngineExceptionsTtlDays').hasError('required')) { + + {{ 'tenant-profile.rule-engine-exceptions-ttl-days-required' | translate}} + + } + @if (tenantProfileConfigurationForm.get('ruleEngineExceptionsTtlDays').hasError('min')) { + + {{ 'tenant-profile.rule-engine-exceptions-ttl-days-range' | translate}} + + }
@@ -513,59 +637,77 @@ {{ 'tenant-profile.sms-enabled' | translate }} - - tenant-profile.max-sms - - - {{ 'tenant-profile.max-sms-required' | translate}} - - - {{ 'tenant-profile.max-sms-range' | translate}} - - + @if (tenantProfileConfigurationForm.get('smsEnabled').value) { + + tenant-profile.max-sms + + @if (tenantProfileConfigurationForm.get('maxSms').hasError('required')) { + + {{ 'tenant-profile.max-sms-required' | translate}} + + } + @if (tenantProfileConfigurationForm.get('maxSms').hasError('min')) { + + {{ 'tenant-profile.max-sms-range' | translate}} + + } + + }
- + tenant-profile.max-emails - - {{ 'tenant-profile.max-emails-required' | translate}} - - - {{ 'tenant-profile.max-emails-range' | translate}} - + @if (tenantProfileConfigurationForm.get('maxEmails').hasError('required')) { + + {{ 'tenant-profile.max-emails-required' | translate}} + + } + @if (tenantProfileConfigurationForm.get('maxEmails').hasError('min')) { + + {{ 'tenant-profile.max-emails-range' | translate}} + + } - + tenant-profile.max-created-alarms - - {{ 'tenant-profile.max-created-alarms-required' | translate}} - - - {{ 'tenant-profile.max-created-alarms-range' | translate}} - + @if (tenantProfileConfigurationForm.get('maxCreatedAlarms').hasError('required')) { + + {{ 'tenant-profile.max-created-alarms-required' | translate}} + + } + @if (tenantProfileConfigurationForm.get('maxCreatedAlarms').hasError('min')) { + + {{ 'tenant-profile.max-created-alarms-range' | translate}} + + }
- + tenant-profile.alarms-reevaluation-interval - - {{ 'tenant-profile.alarms-reevaluation-interval-required' | translate}} - - - {{ 'tenant-profile.alarms-reevaluation-interval-range' | translate}} - + @if (tenantProfileConfigurationForm.get('alarmsReevaluationInterval').hasError('required')) { + + {{ 'tenant-profile.alarms-reevaluation-interval-required' | translate}} + + } + @if (tenantProfileConfigurationForm.get('alarmsReevaluationInterval').hasError('min')) { + + {{ 'tenant-profile.alarms-reevaluation-interval-range' | translate}} + + }
@@ -577,14 +719,16 @@ {{ 'tenant-profile.debug' | translate }}
- + tenant-profile.maximum-debug-duration-min - - {{ 'tenant-profile.maximum-debug-duration-min-range' | translate }} - + @if (tenantProfileConfigurationForm.get('maxDebugModeDurationMinutes').hasError('min')) { + + {{ 'tenant-profile.maximum-debug-duration-min-range' | translate }} + + }
@@ -596,45 +740,57 @@ {{ 'tenant-profile.ota-files-in-bytes' | translate }} tenant-profile.unlimited
- + tenant-profile.maximum-resources-sum-data-size - - {{ 'tenant-profile.maximum-resources-sum-data-size-required' | translate}} - - - {{ 'tenant-profile.maximum-resources-sum-data-size-range' | translate}} - + @if (tenantProfileConfigurationForm.get('maxResourcesInBytes').hasError('required')) { + + {{ 'tenant-profile.maximum-resources-sum-data-size-required' | translate}} + + } + @if (tenantProfileConfigurationForm.get('maxResourcesInBytes').hasError('min')) { + + {{ 'tenant-profile.maximum-resources-sum-data-size-range' | translate}} + + } - + tenant-profile.maximum-ota-packages-sum-data-size - - {{ 'tenant-profile.maximum-ota-package-sum-data-size-required' | translate}} - - - {{ 'tenant-profile.maximum-ota-package-sum-data-size-range' | translate}} - + @if (tenantProfileConfigurationForm.get('maxOtaPackagesInBytes').hasError('required')) { + + {{ 'tenant-profile.maximum-ota-package-sum-data-size-required' | translate}} + + } + @if (tenantProfileConfigurationForm.get('maxOtaPackagesInBytes').hasError('min')) { + + {{ 'tenant-profile.maximum-ota-package-sum-data-size-range' | translate}} + + }
- + tenant-profile.maximum-resource-size - - {{ 'tenant-profile.maximum-resource-size-required' | translate}} - - - {{ 'tenant-profile.maximum-resource-size-range' | translate}} - + @if (tenantProfileConfigurationForm.get('maxResourceSize').hasError('required')) { + + {{ 'tenant-profile.maximum-resource-size-required' | translate}} + + } + @if (tenantProfileConfigurationForm.get('maxResourceSize').hasError('min')) { + + {{ 'tenant-profile.maximum-resource-size-range' | translate}} + + }
@@ -646,35 +802,43 @@ {{ 'tenant-profile.ws-title' | translate }} tenant-profile.unlimited
- + tenant-profile.ws-limit-max-sessions-per-tenant - - {{ 'tenant-profile.too-small-value-zero' | translate}} - + @if (tenantProfileConfigurationForm.get('maxWsSessionsPerTenant').hasError('min')) { + + {{ 'tenant-profile.too-small-value-zero' | translate}} + + } - + tenant-profile.ws-limit-max-subscriptions-per-tenant - - {{ 'tenant-profile.too-small-value-zero' | translate}} - + @if (tenantProfileConfigurationForm.get('maxWsSubscriptionsPerTenant').hasError('min')) { + + {{ 'tenant-profile.too-small-value-zero' | translate}} + + }
- + tenant-profile.ws-limit-max-sessions-per-customer - - {{ 'tenant-profile.too-small-value-zero' | translate}} - + @if (tenantProfileConfigurationForm.get('maxWsSessionsPerCustomer').hasError('min')) { + + {{ 'tenant-profile.too-small-value-zero' | translate}} + + } - + tenant-profile.ws-limit-max-subscriptions-per-customer - - {{ 'tenant-profile.too-small-value-zero' | translate}} - + @if (tenantProfileConfigurationForm.get('maxWsSubscriptionsPerCustomer').hasError('min')) { + + {{ 'tenant-profile.too-small-value-zero' | translate}} + + }
@@ -685,44 +849,54 @@
- + tenant-profile.ws-limit-max-sessions-per-public-user - - {{ 'tenant-profile.too-small-value-zero' | translate}} - + @if (tenantProfileConfigurationForm.get('maxWsSessionsPerPublicUser').hasError('min')) { + + {{ 'tenant-profile.too-small-value-zero' | translate}} + + } - + tenant-profile.ws-limit-max-subscriptions-per-public-user - - {{ 'tenant-profile.too-small-value-zero' | translate}} - + @if (tenantProfileConfigurationForm.get('maxWsSubscriptionsPerPublicUser').hasError('min')) { + + {{ 'tenant-profile.too-small-value-zero' | translate}} + + }
- + tenant-profile.ws-limit-max-sessions-per-regular-user - - {{ 'tenant-profile.too-small-value-zero' | translate}} - + @if (tenantProfileConfigurationForm.get('maxWsSessionsPerRegularUser').hasError('min')) { + + {{ 'tenant-profile.too-small-value-zero' | translate}} + + } - + tenant-profile.ws-limit-max-subscriptions-per-regular-user - - {{ 'tenant-profile.too-small-value-zero' | translate}} - + @if (tenantProfileConfigurationForm.get('maxWsSubscriptionsPerRegularUser').hasError('min')) { + + {{ 'tenant-profile.too-small-value-zero' | translate}} + + }
- + tenant-profile.ws-limit-queue-per-session - - {{ 'tenant-profile.too-small-value-one' | translate}} - + @if (tenantProfileConfigurationForm.get('wsMsgQueueLimitPerSession').hasError('min')) { + + {{ 'tenant-profile.too-small-value-one' | translate}} + + } {{ 'tenant-profile.queue-size-is-limited-by-system-configuration' | translate}} diff --git a/ui-ngx/src/app/modules/home/components/profile/tenant/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..159dcc6540 100644 --- a/ui-ngx/src/app/modules/home/components/profile/tenant/rate-limits/rate-limits-list.component.html +++ b/ui-ngx/src/app/modules/home/components/profile/tenant/rate-limits/rate-limits-list.component.html @@ -16,51 +16,56 @@ -->
-
- @if($index > 0) { -
tenant-profile.rate-limits.and-also-less-than
- } -
- - tenant-profile.rate-limits.number-of-messages - - - @if (rateLimit.get('value').hasError('required')) { - tenant-profile.rate-limits.number-of-messages-required - } @else if(rateLimit.get('value').hasError('min')) { - tenant-profile.rate-limits.number-of-messages-min + @for (rateLimit of rateLimitsFormArray.controls; track rateLimit) { +
+ @if($index > 0) { +
tenant-profile.rate-limits.and-also-less-than
+ } +
+ + tenant-profile.rate-limits.number-of-messages + + + @if (rateLimit.get('value').hasError('required')) { + tenant-profile.rate-limits.number-of-messages-required + } @else if(rateLimit.get('value').hasError('min')) { + tenant-profile.rate-limits.number-of-messages-min + } + + + tenant-profile.rate-limits.per-seconds + + + @if (rateLimit.get('time').hasError('required')) { + tenant-profile.rate-limits.per-seconds-required + } @else if (rateLimit.get('time').hasError('min')) { + tenant-profile.rate-limits.per-seconds-min + } @else if (rateLimit.get('time').hasError('duplicateTime')) { + tenant-profile.rate-limits.per-seconds-duplicate + } + + @if (!rateLimitsFormArray.disabled) { + } - - - tenant-profile.rate-limits.per-seconds - - - @if (rateLimit.get('time').hasError('required')) { - tenant-profile.rate-limits.per-seconds-required - } @else if (rateLimit.get('time').hasError('min')) { - tenant-profile.rate-limits.per-seconds-min - } @else if (rateLimit.get('time').hasError('duplicateTime')) { - tenant-profile.rate-limits.per-seconds-duplicate - } - - +
-
- + } + @if (!rateLimitsFormArray.disabled) { + + }
tenant-profile.rate-limits.preview diff --git a/ui-ngx/src/app/modules/home/components/profile/tenant/rate-limits/rate-limits.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..94803a8739 100644 --- a/ui-ngx/src/app/modules/home/components/queue/queue-form.component.html +++ b/ui-ngx/src/app/modules/home/components/queue/queue-form.component.html @@ -16,18 +16,24 @@ -->
- + admin.queue-name - - {{ 'queue.name-required' | translate }} - - - {{ 'queue.name-unique' | translate }} - - - {{ 'queue.name-pattern' | translate }} - + @if (queueFormGroup.get('name').hasError('required')) { + + {{ 'queue.name-required' | translate }} + + } + @if (queueFormGroup.get('name').hasError('unique')) { + + {{ 'queue.name-unique' | translate }} + + } + @if (queueFormGroup.get('name').hasError('pattern')) { + + {{ 'queue.name-pattern' | translate }} + + } @@ -41,30 +47,38 @@
- - {{ queueSubmitStrategyTypesMap.get(queueSubmitStrategyTypes[strategy]).label | translate }} - - - + @for (strategy of submitStrategies; track strategy) { + + {{ queueSubmitStrategyTypesMap.get(queueSubmitStrategyTypes[strategy]).label | translate }} + + + + }
-
- - - queue.batch-size - - - {{ 'queue.batch-size-required' | translate }} - - - {{ 'queue.batch-size-min-value' | translate }} - - -
+ @if (hideBatchSize) { +
+ + + queue.batch-size + + @if (queueFormGroup.get('submitStrategy.batchSize').hasError('required')) { + + {{ 'queue.batch-size-required' | translate }} + + } + @if (queueFormGroup.get('submitStrategy.batchSize').hasError('min') && + !queueFormGroup.get('submitStrategy.batchSize').hasError('required')) { + + {{ 'queue.batch-size-min-value' | translate }} + + } + +
+ }
@@ -78,8 +92,9 @@
- - + + @for (strategy of processingStrategies; track strategy) { + {{ queueProcessingStrategyTypesMap.get(queueProcessingStrategyTypes[strategy]).label | translate }} - + } +
- + queue.retries - - {{ 'queue.retries-required' | translate }} - - - {{ 'queue.retries-min-value' | translate }} - + @if (queueFormGroup.get('processingStrategy.retries').hasError('required')) { + + {{ 'queue.retries-required' | translate }} + + } + @if (queueFormGroup.get('processingStrategy.retries').hasError('min') && + !queueFormGroup.get('processingStrategy.retries').hasError('required')) { + + {{ 'queue.retries-min-value' | translate }} + + } - + queue.failure-percentage - - {{ 'queue.failure-percentage-required' | translate }} - - - {{ 'queue.failure-percentage-min-value' | translate }} - - - {{ 'queue.failure-percentage-max-value' | translate }} - + @if (queueFormGroup.get('processingStrategy.failurePercentage').hasError('required')) { + + {{ 'queue.failure-percentage-required' | translate }} + + } + @if (queueFormGroup.get('processingStrategy.failurePercentage').hasError('min') && + !queueFormGroup.get('processingStrategy.failurePercentage').hasError('required') && + !queueFormGroup.get('processingStrategy.failurePercentage').hasError('max')) { + + {{ 'queue.failure-percentage-min-value' | translate }} + + } + @if (queueFormGroup.get('processingStrategy.failurePercentage').hasError('max') && + !queueFormGroup.get('processingStrategy.failurePercentage').hasError('required') && + !queueFormGroup.get('processingStrategy.failurePercentage').hasError('min')) { + + {{ 'queue.failure-percentage-max-value' | translate }} + + } - + queue.pause-between-retries - - {{ 'queue.pause-between-retries-required' | translate }} - - - {{ 'queue.pause-between-retries-min-value' | translate }} - + @if (queueFormGroup.get('processingStrategy.pauseBetweenRetries').hasError('required')) { + + {{ 'queue.pause-between-retries-required' | translate }} + + } + @if (queueFormGroup.get('processingStrategy.pauseBetweenRetries').hasError('min') && + !queueFormGroup.get('processingStrategy.pauseBetweenRetries').hasError('required')) { + + {{ 'queue.pause-between-retries-min-value' | translate }} + + } - + queue.max-pause-between-retries - - {{ 'queue.max-pause-between-retries-required' | translate }} - - - {{ 'queue.max-pause-between-retries-min-value' | translate }} - + @if (queueFormGroup.get('processingStrategy.maxPauseBetweenRetries').hasError('required')) { + + {{ 'queue.max-pause-between-retries-required' | translate }} + + } + @if (queueFormGroup.get('processingStrategy.maxPauseBetweenRetries').hasError('min') && + !queueFormGroup.get('processingStrategy.maxPauseBetweenRetries').hasError('required')) { + + {{ 'queue.max-pause-between-retries-min-value' | translate }} + + }
@@ -160,27 +194,35 @@
- + queue.poll-interval - - {{ 'queue.poll-interval-required' | translate }} - - - {{ 'queue.poll-interval-min-value' | translate }} - + @if (queueFormGroup.get('pollInterval').hasError('required')) { + + {{ 'queue.poll-interval-required' | translate }} + + } + @if (queueFormGroup.get('pollInterval').hasError('min') && + !queueFormGroup.get('pollInterval').hasError('required')) { + + {{ 'queue.poll-interval-min-value' | translate }} + + } - + queue.partitions - - {{ 'queue.partitions-required' | translate }} - - - {{ 'queue.partitions-min-value' | translate }} - + @if (queueFormGroup.get('partitions').hasError('required')) { + + {{ 'queue.partitions-required' | translate }} + + } + @if (queueFormGroup.get('partitions').hasError('min') && + !queueFormGroup.get('partitions').hasError('required')) { + + {{ 'queue.partitions-min-value' | translate }} + + }
@@ -190,16 +232,20 @@ {{ 'queue.consumer-per-partition' | translate }} - + queue.processing-timeout - - {{ 'queue.pack-processing-timeout-required' | translate }} - - - {{ 'queue.pack-processing-timeout-min-value' | translate }} - + @if (queueFormGroup.get('packProcessingTimeout').hasError('required')) { + + {{ 'queue.pack-processing-timeout-required' | translate }} + + } + @if (queueFormGroup.get('packProcessingTimeout').hasError('min') && + !queueFormGroup.get('packProcessingTimeout').hasError('required')) { + + {{ 'queue.pack-processing-timeout-min-value' | translate }} + + }
@@ -211,14 +257,14 @@ {{ 'queue.duplicate-msg-to-all-partitions' | translate }} - + queue.custom-properties - + queue.custom-properties-hint - + queue.description - + queue.description-hint
diff --git a/ui-ngx/src/app/modules/home/components/relation/relation-dialog.component.html b/ui-ngx/src/app/modules/home/components/relation/relation-dialog.component.html index 6b63c75a35..ffbfb48cf3 100644 --- a/ui-ngx/src/app/modules/home/components/relation/relation-dialog.component.html +++ b/ui-ngx/src/app/modules/home/components/relation/relation-dialog.component.html @@ -25,20 +25,25 @@ close - - -
+ @if (isLoading$ | async) { + + + } @else { +
+ }
+ [required]="true"> {{(direction === entitySearchDirection.FROM ? - 'relation.to-entity' : 'relation.from-entity') | translate}} + 'relation.to-entity' : 'relation.from-entity') | translate}} - + @if (submitted && + additionalInfo.invalid) { + + }
diff --git a/ui-ngx/src/app/modules/home/components/relation/relation-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..3984d85077 100644 --- a/ui-ngx/src/app/modules/home/components/relation/relation-table.component.html +++ b/ui-ngx/src/app/modules/home/components/relation/relation-table.component.html @@ -19,17 +19,19 @@
-
+
{{(direction == directions.FROM ? 'relation.from-relations' : 'relation.to-relations') | translate}} - + relation.direction - - {{ directionTypeTranslations.get(directions[type]) | translate }} - + @for (type of directionTypes; track type) { + + {{ directionTypeTranslations.get(directions[type]) | translate }} + + }
@@ -87,7 +89,7 @@
+ matSort [matSortActive]="pageLink.sortOrder.property" [matSortDirection]="pageLink.sortDirection()" matSortDisableClear> + > - - {{ 'relation.to-entity-type' | translate }} - - {{ relation.toEntityTypeName }} - - - - {{ 'relation.to-entity-name' | translate }} - + @if (direction === directions.FROM) { + + {{ 'relation.to-entity-type' | translate }} + + {{ relation.toEntityTypeName }} + + + } + @if (direction === directions.FROM) { + + {{ 'relation.to-entity-name' | translate }} + @if(relation.entityURL){ - {{ relation.toName | customTranslate }} + {{ relation.toName | customTranslate }} } @else { {{ relation.toName | customTranslate }} } - - - - {{ 'relation.from-entity-type' | translate }} - - {{ relation.fromEntityTypeName }} - - - - {{ 'relation.from-entity-name' | translate }} - - @if(relation.entityURL){ - + + + } + @if (direction === directions.TO) { + + {{ 'relation.from-entity-type' | translate }} + + {{ relation.fromEntityTypeName }} + + + } + @if (direction === directions.TO) { + + {{ 'relation.from-entity-name' | translate }} + + @if(relation.entityURL){ + + {{ relation.fromName | customTranslate }} + + } @else { {{ relation.fromName | customTranslate }} - - } @else { - {{ relation.fromName | customTranslate }} - } - - + } + + + } @@ -179,16 +189,16 @@ + *matRowDef="let relation; columns: displayedColumns;" (click)="dataSource.selection.toggle(relation)">
{{ 'relation.no-relations-text' }} + class="no-data-found flex items-center justify-center" translate>{{ 'relation.no-relations-text' }}
+ [hidePageSize]="hidePageSize">
diff --git a/ui-ngx/src/app/modules/home/components/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-row.component.html b/ui-ngx/src/app/modules/home/components/rule-node/action/advanced-processing-setting-row.component.html index d4213b95e5..7543feb3ad 100644 --- a/ui-ngx/src/app/modules/home/components/rule-node/action/advanced-processing-setting-row.component.html +++ b/ui-ngx/src/app/modules/home/components/rule-node/action/advanced-processing-setting-row.component.html @@ -17,7 +17,7 @@ -->
{{ title }}
- + rule-node-config.save-time-series.strategy @for (strategy of processingStrategies; track strategy) { @@ -34,6 +34,7 @@ maxErrorText="{{ 'rule-node-config.save-time-series.deduplication-interval-min-max-range' | translate }}" [minTime]="1" [maxTime]="maxDeduplicateTime" + [appearance]="appearance" formControlName="deduplicationIntervalSecs"> } diff --git a/ui-ngx/src/app/modules/home/components/rule-node/action/advanced-processing-setting-row.component.ts b/ui-ngx/src/app/modules/home/components/rule-node/action/advanced-processing-setting-row.component.ts index 22cdec0c82..c569d3270f 100644 --- a/ui-ngx/src/app/modules/home/components/rule-node/action/advanced-processing-setting-row.component.ts +++ b/ui-ngx/src/app/modules/home/components/rule-node/action/advanced-processing-setting-row.component.ts @@ -32,6 +32,7 @@ import { } from '@home/components/rule-node/action/timeseries-config.models'; import { isDefinedAndNotNull } from '@core/utils'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; +import { MatFormFieldAppearance } from '@angular/material/form-field'; @Component({ selector: 'tb-advanced-processing-setting-row', @@ -52,6 +53,9 @@ export class AdvancedProcessingSettingRowComponent implements ControlValueAccess @Input() title: string; + @Input() + appearance: MatFormFieldAppearance = 'fill'; + processingSettingRowForm = this.fb.group({ type: [defaultAdvancedProcessingConfig.type], deduplicationIntervalSecs: [{value: 60, disabled: true}] diff --git a/ui-ngx/src/app/modules/home/components/rule-node/action/advanced-processing-setting.component.html b/ui-ngx/src/app/modules/home/components/rule-node/action/advanced-processing-setting.component.html index 9f51ca5189..0136d08250 100644 --- a/ui-ngx/src/app/modules/home/components/rule-node/action/advanced-processing-setting.component.html +++ b/ui-ngx/src/app/modules/home/components/rule-node/action/advanced-processing-setting.component.html @@ -19,26 +19,41 @@ + > - - - - - + @if (timeseries) { + + } + @if (attributes) { + + } + @if (latest) { + + } + @if (webSockets) { + + } + @if (calculatedFields) { + + }
diff --git a/ui-ngx/src/app/modules/home/components/rule-node/action/assign-customer-config.component.html b/ui-ngx/src/app/modules/home/components/rule-node/action/assign-customer-config.component.html index b50241b5cd..64a23c30e6 100644 --- a/ui-ngx/src/app/modules/home/components/rule-node/action/assign-customer-config.component.html +++ b/ui-ngx/src/app/modules/home/components/rule-node/action/assign-customer-config.component.html @@ -17,13 +17,15 @@ -->
- + rule-node-config.customer-name-pattern - - {{ 'rule-node-config.customer-name-pattern-required' | translate }} - + @if (assignCustomerConfigForm.get('customerNamePattern').hasError('required') || + assignCustomerConfigForm.get('customerNamePattern').hasError('pattern')) { + + {{ 'rule-node-config.customer-name-pattern-required' | translate }} + + } rule-node-config.customer-name-pattern-hint
diff --git a/ui-ngx/src/app/modules/home/components/rule-node/action/attributes-config.component.html b/ui-ngx/src/app/modules/home/components/rule-node/action/attributes-config.component.html index ef4c5ee8c2..51ac7c34b6 100644 --- a/ui-ngx/src/app/modules/home/components/rule-node/action/attributes-config.component.html +++ b/ui-ngx/src/app/modules/home/components/rule-node/action/attributes-config.component.html @@ -28,7 +28,7 @@
@if(!attributesConfigForm.get('processingSettings.isAdvanced').value) { - + rule-node-config.save-attribute.strategy @for (strategy of processingStrategies; track strategy) { @@ -39,6 +39,7 @@ @if(attributesConfigForm.get('processingSettings.type').value === ProcessingType.DEDUPLICATE) {
- + {{ 'rule-node-config.attributes-scope' | translate }} - - {{ telemetryTypeTranslationsMap.get(scope) | translate }} - + @for (scope of attributeScopes; track scope) { + + {{ telemetryTypeTranslationsMap.get(scope) | translate }} + + } - + {{ 'rule-node-config.attributes-scope-value' | translate }}
-
- - {{ 'rule-node-config.send-attributes-updated-notification' | translate }} - -
-
- - {{ 'rule-node-config.notify-device' | translate }} - -
+ @if (attributesConfigForm.get('scope').value !== attributeScopeMap.CLIENT_SCOPE) { +
+ + {{ 'rule-node-config.send-attributes-updated-notification' | translate }} + +
+ } + @if (attributesConfigForm.get('scope').value === attributeScopeMap.SHARED_SCOPE) { +
+ + {{ 'rule-node-config.notify-device' | translate }} + +
+ }
diff --git a/ui-ngx/src/app/modules/home/components/rule-node/action/attributes-config.component.ts b/ui-ngx/src/app/modules/home/components/rule-node/action/attributes-config.component.ts index 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..ab3e54ccdc 100644 --- a/ui-ngx/src/app/modules/home/components/rule-node/action/clear-alarm-config.component.html +++ b/ui-ngx/src/app/modules/home/components/rule-node/action/clear-alarm-config.component.html @@ -16,52 +16,60 @@ -->
- - - - - - - + @if (tbelEnabled) { + + } + @if (clearAlarmConfigForm.get('scriptLang').value === scriptLanguage.JS) { + + + + } + @if (clearAlarmConfigForm.get('scriptLang').value === scriptLanguage.TBEL) { + + + + }
- + rule-node-config.alarm-type - - {{ 'rule-node-config.alarm-type-required' | translate }} - + @if (clearAlarmConfigForm.get('alarmType').hasError('required')) { + + {{ 'rule-node-config.alarm-type-required' | translate }} + + } rule-node-config.general-pattern-hint
diff --git a/ui-ngx/src/app/modules/home/components/rule-node/action/create-alarm-config.component.html b/ui-ngx/src/app/modules/home/components/rule-node/action/create-alarm-config.component.html index f5e933a6d5..6b4ec383a1 100644 --- a/ui-ngx/src/app/modules/home/components/rule-node/action/create-alarm-config.component.html +++ b/ui-ngx/src/app/modules/home/components/rule-node/action/create-alarm-config.component.html @@ -19,110 +19,137 @@ {{ 'rule-node-config.use-message-alarm-data' | translate }} - - {{ 'rule-node-config.overwrite-alarm-details' | translate }} - -
- - - - - - - -
- -
-
-
- - rule-node-config.alarm-type - - - {{ 'rule-node-config.alarm-type-required' | translate }} - - rule-node-config.general-pattern-hint - - - {{ 'rule-node-config.use-alarm-severity-pattern' | translate }} - - - rule-node-config.alarm-severity - - - {{ alarmSeverityTranslationMap.get(severity) | translate }} - - - - {{ 'rule-node-config.alarm-severity-required' | translate }} - - - - rule-node-config.alarm-severity-pattern - - - {{ 'rule-node-config.alarm-severity-required' | translate }} - - - - - {{ 'rule-node-config.propagate' | translate }} + @if (createAlarmConfigForm.get('useMessageAlarmData').value === true) { + + {{ 'rule-node-config.overwrite-alarm-details' | translate }} -
- - rule-node-config.relation-types-list - - - {{key}} - close - - - - rule-node-config.relation-types-list-hint + } + @if (createAlarmConfigForm.get('useMessageAlarmData').value === false || + createAlarmConfigForm.get('overwriteAlarmDetails').value === true) { +
+ @if (tbelEnabled) { + + } + @if (createAlarmConfigForm.get('scriptLang').value === scriptLanguage.JS) { + + + + } + @if (createAlarmConfigForm.get('scriptLang').value === scriptLanguage.TBEL) { + + + + } +
+ +
+
+ } + @if (createAlarmConfigForm.get('useMessageAlarmData').value === false) { +
+ + rule-node-config.alarm-type + + @if (createAlarmConfigForm.get('alarmType').hasError('required')) { + + {{ 'rule-node-config.alarm-type-required' | translate }} + + } + rule-node-config.general-pattern-hint + + {{ 'rule-node-config.use-alarm-severity-pattern' | translate }} + + @if (!createAlarmConfigForm.get('dynamicSeverity').value) { + + rule-node-config.alarm-severity + + @for (severity of alarmSeverities; track severity) { + + {{ alarmSeverityTranslationMap.get(severity) | translate }} + + } + + @if (createAlarmConfigForm.get('severity').hasError('required')) { + + {{ 'rule-node-config.alarm-severity-required' | translate }} + + } + + } + @if (createAlarmConfigForm.get('dynamicSeverity').value) { + + rule-node-config.alarm-severity-pattern + + @if (createAlarmConfigForm.get('severity').hasError('required')) { + + {{ 'rule-node-config.alarm-severity-required' | translate }} + + } + + + } + + {{ 'rule-node-config.propagate' | translate }} + + @if (createAlarmConfigForm.get('propagate').value === true) { +
+ + rule-node-config.relation-types-list + + @for (key of createAlarmConfigForm.get('relationTypes').value; track key) { + + {{key}} + close + + } + + + rule-node-config.relation-types-list-hint + +
+ } + + {{ 'rule-node-config.propagate-to-owner' | translate }} + + + {{ 'rule-node-config.propagate-to-tenant' | translate }} +
- - {{ 'rule-node-config.propagate-to-owner' | translate }} - - - {{ 'rule-node-config.propagate-to-tenant' | translate }} - -
+ }
diff --git a/ui-ngx/src/app/modules/home/components/rule-node/action/create-alarm-config.component.ts b/ui-ngx/src/app/modules/home/components/rule-node/action/create-alarm-config.component.ts index 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..a50b62b26d 100644 --- a/ui-ngx/src/app/modules/home/components/rule-node/action/create-relation-config.component.html +++ b/ui-ngx/src/app/modules/home/components/rule-node/action/create-relation-config.component.html @@ -19,15 +19,18 @@
rule-node-config.relation-parameters
- + relation.direction - - {{ directionTypeTranslations.get(type) | translate }} - + @for (type of directionTypes; track type) { + + {{ directionTypeTranslations.get(type) | translate }} + + } @@ -38,6 +41,7 @@
rule-node-config.target-entity
- - {{ entityTypeNamePatternTranslation.get(createRelationConfigForm.get('entityType').value) | translate }} - - + @if (createRelationConfigForm.get('entityType').value && + createRelationConfigForm.get('entityType').value !== entityType.TENANT) { + + {{ entityTypeNamePatternTranslation.get(createRelationConfigForm.get('entityType').value) | translate }} + + + } - - rule-node-config.profile-name - - + @if (createRelationConfigForm.get('entityType').value === entityType.DEVICE || + createRelationConfigForm.get('entityType').value === entityType.ASSET) { + + rule-node-config.profile-name + + + }
- + @if (createRelationConfigForm.get('entityType').value === entityType.CUSTOMER || + createRelationConfigForm.get('entityType').value === entityType.DEVICE || + createRelationConfigForm.get('entityType').value === entityType.ASSET) { + + } -
- - {{ 'rule-node-config.create-entity-if-not-exists' | translate }} - -
+ @if (createRelationConfigForm.get('entityType').value === entityType.CUSTOMER || + createRelationConfigForm.get('entityType').value === entityType.DEVICE || + createRelationConfigForm.get('entityType').value === entityType.ASSET) { +
+ + {{ 'rule-node-config.create-entity-if-not-exists' | translate }} + +
+ }
diff --git a/ui-ngx/src/app/modules/home/components/rule-node/action/create-relation-config.component.ts b/ui-ngx/src/app/modules/home/components/rule-node/action/create-relation-config.component.ts index 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..a260f16b96 100644 --- a/ui-ngx/src/app/modules/home/components/rule-node/action/delete-attributes-config.component.html +++ b/ui-ngx/src/app/modules/home/components/rule-node/action/delete-attributes-config.component.html @@ -20,16 +20,18 @@
- + {{ 'rule-node-config.attributes-scope' | translate }} - - {{ telemetryTypeTranslationsMap.get(scope) | translate }} - + @for (scope of attributeScopes; track scope) { + + {{ telemetryTypeTranslationsMap.get(scope) | translate }} + + } - + {{ 'rule-node-config.attributes-scope-value' | translate }}
- + {{ 'rule-node-config.attributes-keys' | translate }} - - {{key}} - close - + @for (key of deleteAttributesConfigForm.get('keys').value; track key) { + + {{key}} + close + + } - {{ 'rule-node-config.attributes-keys-required' | translate }} + @if (deleteAttributesConfigForm.get('keys').hasError('required')) { + {{ 'rule-node-config.attributes-keys-required' | translate }} + } rule-node-config.general-pattern-hint @@ -77,13 +82,14 @@ {{ 'rule-node-config.send-attributes-deleted-notification' | translate }}
-
- - {{ 'rule-node-config.notify-device' | translate }} - -
+ @if (deleteAttributesConfigForm.get('scope').value === attributeScopeMap.SHARED_SCOPE) { +
+ + {{ 'rule-node-config.notify-device' | translate }} + +
+ } diff --git a/ui-ngx/src/app/modules/home/components/rule-node/action/delete-attributes-config.component.ts b/ui-ngx/src/app/modules/home/components/rule-node/action/delete-attributes-config.component.ts index 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..5c1517804d 100644 --- a/ui-ngx/src/app/modules/home/components/rule-node/action/delete-relation-config.component.html +++ b/ui-ngx/src/app/modules/home/components/rule-node/action/delete-relation-config.component.html @@ -19,15 +19,18 @@
rule-node-config.relation-parameters
- + relation.direction - - {{ directionTypeTranslations.get(type) | translate }} - + @for (type of directionTypes; track type) { + + {{ directionTypeTranslations.get(type) | translate }} + + } @@ -40,23 +43,31 @@ {{ 'rule-node-config.delete-relation-with-specific-entity' | translate }}
-
-
- - - - {{ entityTypeNamePatternTranslation.get(deleteRelationConfigForm.get('entityType').value) | translate }} - - + @if (deleteRelationConfigForm.get('deleteForSingleEntity').value) { +
+
+ + + @if (deleteRelationConfigForm.get('entityType').value && deleteRelationConfigForm.get('entityType').value !== entityType.TENANT) { + + {{ entityTypeNamePatternTranslation.get(deleteRelationConfigForm.get('entityType').value) | translate }} + + + } +
+ @if (deleteRelationConfigForm.get('entityType').value && deleteRelationConfigForm.get('entityType').value !== entityType.TENANT) { + + }
- -
+ }
diff --git a/ui-ngx/src/app/modules/home/components/rule-node/action/delete-relation-config.component.ts b/ui-ngx/src/app/modules/home/components/rule-node/action/delete-relation-config.component.ts index 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..bdb412f385 100644 --- a/ui-ngx/src/app/modules/home/components/rule-node/action/device-state-config.component.html +++ b/ui-ngx/src/app/modules/home/components/rule-node/action/device-state-config.component.html @@ -16,12 +16,14 @@ -->
- - {{ 'rule-node-config.select-device-connectivity-event' | translate }} - - - {{ messageTypeNames.get(eventOption) }} - - - + + {{ 'rule-node-config.select-device-connectivity-event' | translate }} + + @for (eventOption of eventOptions; track eventOption) { + + {{ messageTypeNames.get(eventOption) }} + + } + +
diff --git a/ui-ngx/src/app/modules/home/components/rule-node/action/generator-config.component.html b/ui-ngx/src/app/modules/home/components/rule-node/action/generator-config.component.html index 4463973874..d0d91e0bb1 100644 --- a/ui-ngx/src/app/modules/home/components/rule-node/action/generator-config.component.html +++ b/ui-ngx/src/app/modules/home/components/rule-node/action/generator-config.component.html @@ -19,25 +19,33 @@
rule-node-config.generation-parameters
- + rule-node-config.message-count - - {{ 'rule-node-config.message-count-required' | translate }} - - - {{ 'rule-node-config.min-message-count-message' | translate }} - + @if (generatorConfigForm.get('msgCount').hasError('required')) { + + {{ 'rule-node-config.message-count-required' | translate }} + + } + @if (generatorConfigForm.get('msgCount').hasError('min')) { + + {{ 'rule-node-config.min-message-count-message' | translate }} + + } - + rule-node-config.generation-frequency-seconds - - {{ 'rule-node-config.generation-frequency-required' | translate }} - - - {{ 'rule-node-config.min-generation-frequency-message' | translate }} - + @if (generatorConfigForm.get('periodInSeconds').hasError('required')) { + + {{ 'rule-node-config.generation-frequency-required' | translate }} + + } + @if (generatorConfigForm.get('periodInSeconds').hasError('min')) { + + {{ 'rule-node-config.min-generation-frequency-message' | translate }} + + }
@@ -45,6 +53,7 @@
rule-node-config.originator
rule-node-config.generator-function - - - - {{ 'rule-node-config.script-lang-tbel' | translate }} - - - {{ 'rule-node-config.script-lang-js' | translate }} - - - - - - - - {{ 'rule-node-config.script-lang-tbel' | translate }} - - - {{ 'rule-node-config.script-lang-js' | translate }} - - - - + @if (generatorConfigForm.get('scriptLang').value === scriptLanguage.JS) { + + @if (tbelEnabled) { + + + {{ 'rule-node-config.script-lang-tbel' | translate }} + + + {{ 'rule-node-config.script-lang-js' | translate }} + + + } + + + } + @if (generatorConfigForm.get('scriptLang').value === scriptLanguage.TBEL) { + + + + {{ 'rule-node-config.script-lang-tbel' | translate }} + + + {{ 'rule-node-config.script-lang-js' | translate }} + + + + + }
- - - - + @if (tbelEnabled) { + + } + @if (logConfigForm.get('scriptLang').value === scriptLanguage.JS) { + + + + } + @if (logConfigForm.get('scriptLang').value === scriptLanguage.TBEL) { + + + + }
- - rule-node-config.customer-name-pattern - - - {{ 'rule-node-config.customer-name-pattern-required' | translate }} - - rule-node-config.customer-name-pattern-hint - + @if (unassignCustomerConfigForm.get('unassignFromCustomer').value) { + + rule-node-config.customer-name-pattern + + @if (unassignCustomerConfigForm.get('customerNamePattern').hasError('required') || + unassignCustomerConfigForm.get('customerNamePattern').hasError('pattern')) { + + {{ 'rule-node-config.customer-name-pattern-required' | translate }} + + } + rule-node-config.customer-name-pattern-hint + + }
diff --git a/ui-ngx/src/app/modules/home/components/rule-node/common/arguments-map-config.component.html b/ui-ngx/src/app/modules/home/components/rule-node/common/arguments-map-config.component.html index 9250c5b544..633c169cf1 100644 --- a/ui-ngx/src/app/modules/home/components/rule-node/common/arguments-map-config.component.html +++ b/ui-ngx/src/app/modules/home/components/rule-node/common/arguments-map-config.component.html @@ -23,101 +23,128 @@ [formGroup]="argumentsFormGroup" (cdkDropListDropped)="onDrop($event)" [cdkDropListDisabled]="disabled"> - -
- -
- {{argumentControl.get('name').value}}. -
- - rule-node-config.argument-source-field-input - - - {{ argumentTypeMap.get(argumentControl.get('type').value)?.name | translate }} - - - {{ argumentTypeMap.get(argument).name | translate }} - - {{ argumentTypeMap.get(argument).description | translate }} - - - - - rule-node-config.argument-source-field-input-required - - -
- - rule-node-config.argument-key-field-input - - - help - - - rule-node-config.argument-key-field-input-required - - - - rule-node-config.constant-value-field-input - - - rule-node-config.constant-value-field-input-required - - - - rule-node-config.default-value-field-input - + @for (argumentControl of argumentsFormArray.controls; track argumentControl) { + +
+ @if (!disabled) { + + } +
+ @if (displayArgumentName) { + {{argumentControl.get('name').value}}. + } +
+ + rule-node-config.argument-source-field-input + + + {{ argumentTypeMap.get(argumentControl.get('type').value)?.name | translate }} + + @for (argument of arguments; track argument) { + + {{ argumentTypeMap.get(argument).name | translate }} + + {{ argumentTypeMap.get(argument).description | translate }} + + + } + + @if (argumentControl.get('type').hasError('required')) { + + rule-node-config.argument-source-field-input-required + + } +
+ @if (argumentControl.get('type').value && argumentControl.get('type').value !== ArgumentType.CONSTANT) { + + rule-node-config.argument-key-field-input + + + help + + @if (argumentControl.get('key').hasError('required')) { + + rule-node-config.argument-key-field-input-required + + } + + } + @if (argumentControl.get('type').value === ArgumentType.CONSTANT) { + + rule-node-config.constant-value-field-input + + @if (argumentControl.get('key').hasError('required')) { + + rule-node-config.constant-value-field-input-required + + } + + } + @if (argumentControl.get('type').value && argumentControl.get('type').value !== ArgumentType.CONSTANT) { + + rule-node-config.default-value-field-input + + + } +
+ @if (argumentControl.get('type').value === ArgumentType.ATTRIBUTE) { + + rule-node-config.attribute-scope-field-input + + @for (scope of attributeScope; track scope) { + + {{ attributeScopeMap.get(scope) | translate }} + + } + + @if (argumentControl.get('attributeScope').hasError('required')) { + + rule-node-config.attribute-scope-field-input-required + + } + + }
- - rule-node-config.attribute-scope-field-input - - - {{ attributeScopeMap.get(scope) | translate }} - - - - rule-node-config.attribute-scope-field-input-required - - + @if (!disabled) { + + }
-
-
- + + }
-
+ @if (!argumentsFormArray.length) { +
rule-node-config.no-arguments-prompt -
+ class="tb-prompt flex items-center justify-center">rule-node-config.no-arguments-prompt +
+ }
diff --git a/ui-ngx/src/app/modules/home/components/rule-node/common/kv-list-config.component.ts b/ui-ngx/src/app/modules/home/components/rule-node/common/kv-list-config.component.ts new file mode 100644 index 0000000000..98a5140de4 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/rule-node/common/kv-list-config.component.ts @@ -0,0 +1,84 @@ +/// +/// Copyright © 2016-2026 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +/// See the License for the specific language governing permissions and +/// limitations under the License. +/// + +import { Component, forwardRef, OnInit } from '@angular/core'; +import { + AbstractControl, + ControlValueAccessor, + NG_VALIDATORS, + NG_VALUE_ACCESSOR, + Validator, + Validators +} from '@angular/forms'; +import { KvMapConfigOldComponent } from '@home/components/rule-node/common/kv-map-config-old.component'; + +@Component({ + selector: 'tb-kv-list-config', + templateUrl: './kv-map-config-old.component.html', + styleUrls: ['./kv-map-config-old.component.scss'], + standalone: false, + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => KvListConfigComponent), + multi: true + }, + { + provide: NG_VALIDATORS, + useExisting: forwardRef(() => KvListConfigComponent), + multi: true, + } + ] +}) +export class KvListConfigComponent extends KvMapConfigOldComponent implements ControlValueAccessor, OnInit, Validator { + + override writeValue(kvList: any): void { + const keyValsControls: Array = []; + if (Array.isArray(kvList) && kvList.length > 0) { + for (const property of kvList) { + keyValsControls.push(this.fb.group({ + key: [property.key, [Validators.required]], + value: [property.value, [Validators.required]] + })); + } + } + this.kvListFormGroup.setControl('keyVals', this.fb.array(keyValsControls), {emitEvent: false}); + } + + public override validate() { + const kvList: { key: string; value: string }[] = this.kvListFormGroup.get('keyVals').value; + if (!kvList.length && this.required) { + return { + kvMapRequired: true + }; + } + if (!this.kvListFormGroup.valid) { + return { + kvFieldsRequired: true + }; + } + return null; + } + + protected override updateModel() { + const kvList: { key: string; value: string }[] = this.kvListFormGroup.get('keyVals').value; + if (this.required && !kvList.length || !this.kvListFormGroup.valid) { + this.propagateChange(null); + } else { + this.propagateChange(kvList); + } + } +} diff --git a/ui-ngx/src/app/modules/home/components/rule-node/common/kv-map-config-old.component.html b/ui-ngx/src/app/modules/home/components/rule-node/common/kv-map-config-old.component.html index a0ce66a5a7..831630074a 100644 --- a/ui-ngx/src/app/modules/home/components/rule-node/common/kv-map-config-old.component.html +++ b/ui-ngx/src/app/modules/home/components/rule-node/common/kv-map-config-old.component.html @@ -16,40 +16,57 @@ -->
-
- {{ keyText | translate }} - {{ valText | translate }} - -
-
-
- - - - {{ keyRequiredText | translate }} - - - - - - {{ valRequiredText | translate }} - - - + @if (description) { +
+ } + @if (keyValsFormArray().controls.length || !emptyText) { +
+ {{ keyText | translate }} + {{ valText | translate }} +
-
+ } +
+ @for (keyValControl of keyValsFormArray().controls; track $index) { +
+ + + @if (keyValControl.get('key').hasError('required')) { + + {{ keyRequiredText | translate }} + + } + + + + @if (keyValControl.get('value').hasError('required')) { + + {{ valRequiredText | translate }} + + } + + +
+ } @empty { + @if (emptyText) { +
{{emptyText | translate}}
+ } + } + @if (hintText) { +
+ }
{{ labelText }}
-
- {{ requiredText }} -
-
- rule-node-config.map-fields-required -
-
- {{ 'rule-node-config.key-val.unique-key-value-pair-error' | translate: - { - valText: valText, - keyText: keyText - } }} -
+ @if (kvListFormGroup.hasError('kvMapRequired')) { +
+ {{ requiredText }} +
+ } + @if (kvListFormGroup.hasError('required')) { +
+ rule-node-config.map-fields-required +
+ } + @if (kvListFormGroup.hasError('uniqueKeyValuePair')) { +
+ {{ 'rule-node-config.key-val.unique-key-value-pair-error' | translate: + { + valText: valText, + keyText: keyText + } }} +
+ }
@@ -40,26 +46,31 @@
-
- - - - - - -
- + @for (keyValControl of keyValsFormArray().controls; track keyValControl) { +
+ + + + + + +
+ +
-
+ } @empty { + @if (emptyText) { +
{{ emptyText }}
+ } + }
@@ -68,5 +79,7 @@ {{ 'rule-node-config.key-val.add-mapping-entry' | translate }}
- + @if (popupHelpLink || hintText) { + + }
diff --git a/ui-ngx/src/app/modules/home/components/rule-node/common/kv-map-config.component.ts b/ui-ngx/src/app/modules/home/components/rule-node/common/kv-map-config.component.ts index e98f145724..ac8b62487e 100644 --- a/ui-ngx/src/app/modules/home/components/rule-node/common/kv-map-config.component.ts +++ b/ui-ngx/src/app/modules/home/components/rule-node/common/kv-map-config.component.ts @@ -80,6 +80,8 @@ export class KvMapConfigComponent implements ControlValueAccessor, OnInit, Valid @Input() hintText: string; + @Input() emptyText: string; + @Input() popupHelpLink: string; @Input() @@ -171,7 +173,7 @@ export class KvMapConfigComponent implements ControlValueAccessor, OnInit, Valid }; writeValue(keyValMap: { [key: string]: string }): void { - const keyValuesData = Object.keys(keyValMap).map(key => ({key, value: keyValMap[key]})); + const keyValuesData = Object.keys(keyValMap ?? {}).map(key => ({key, value: keyValMap[key]})); if (this.keyValsFormArray().length === keyValuesData.length) { this.keyValsFormArray().patchValue(keyValuesData, {emitEvent: false}); } else { diff --git a/ui-ngx/src/app/modules/home/components/rule-node/common/math-function-autocomplete.component.html b/ui-ngx/src/app/modules/home/components/rule-node/common/math-function-autocomplete.component.html index c342149422..5ae8457030 100644 --- a/ui-ngx/src/app/modules/home/components/rule-node/common/math-function-autocomplete.component.html +++ b/ui-ngx/src/app/modules/home/components/rule-node/common/math-function-autocomplete.component.html @@ -15,7 +15,7 @@ limitations under the License. --> - + rule-node-config.functions-field-input - + @if (mathFunctionForm.get('operation').value) { + + } - - - - {{ option.description }} - - - - rule-node-config.no-option-found - + @for (option of filteredOptions | async; track option) { + + + + {{ option.description }} + + + } + @if (!(filteredOptions | async)?.length) { + + rule-node-config.no-option-found + + } diff --git a/ui-ngx/src/app/modules/home/components/rule-node/common/math-function-autocomplete.component.ts b/ui-ngx/src/app/modules/home/components/rule-node/common/math-function-autocomplete.component.ts index 6f540b6181..8603144e2e 100644 --- a/ui-ngx/src/app/modules/home/components/rule-node/common/math-function-autocomplete.component.ts +++ b/ui-ngx/src/app/modules/home/components/rule-node/common/math-function-autocomplete.component.ts @@ -21,6 +21,7 @@ import { TranslateService } from '@ngx-translate/core'; import { FunctionData, MathFunction, MathFunctionMap } from '../rule-node-config.models'; import { map, tap } from 'rxjs/operators'; import { coerceBooleanProperty } from '@angular/cdk/coercion'; +import { MatFormFieldAppearance } from '@angular/material/form-field'; @Component({ selector: 'tb-math-function-autocomplete', @@ -50,6 +51,9 @@ export class MathFunctionAutocompleteComponent implements ControlValueAccessor, @Input() disabled: boolean; + @Input() + appearance: MatFormFieldAppearance = 'fill'; + @ViewChild('operationInput', {static: true}) operationInput: ElementRef; mathFunctionForm: UntypedFormGroup; diff --git a/ui-ngx/src/app/modules/home/components/rule-node/common/message-types-config.component.html b/ui-ngx/src/app/modules/home/components/rule-node/common/message-types-config.component.html index 7ad685d8e2..2ecac54219 100644 --- a/ui-ngx/src/app/modules/home/components/rule-node/common/message-types-config.component.html +++ b/ui-ngx/src/app/modules/home/components/rule-node/common/message-types-config.component.html @@ -15,16 +15,19 @@ limitations under the License. --> - - {{ label }} + + @if (label) { + {{ label }} + } - - {{messageType.name}} - close - + @for (messageType of messageTypes; track messageType) { + + {{messageType.name}} + close + + } - - - - -
-
- rule-node-config.no-message-types-found + @for (messageType of filteredMessageTypes | async; track messageType) { + + + + } + @if ((filteredMessageTypes | async)?.length === 0) { + +
+ @if (!textIsNotEmpty(searchText)) { +
+ rule-node-config.no-message-types-found +
+ } @else { + + {{ 'rule-node-config.no-message-type-matching' | translate : + {messageType: truncate.transform(searchText, true, 6, '...')} + }} + + } + + rule-node-config.create-new-message-type +
- - - {{ 'rule-node-config.no-message-type-matching' | translate : - {messageType: truncate.transform(searchText, true, 6, '...')} - }} - - - - rule-node-config.create-new-message-type - -
- + + } help - - {{ 'rule-node-config.select-message-types-required' | translate }} - + matTooltip="{{ 'rule-node-config.chip-help' | translate: { inputName: 'rule-node-config.message-type' | translate } }}">help + @if (chipList.errorState) { + + {{ 'rule-node-config.select-message-types-required' | translate }} + + } diff --git a/ui-ngx/src/app/modules/home/components/rule-node/common/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..035a739918 100644 --- a/ui-ngx/src/app/modules/home/components/rule-node/common/output-message-type-autocomplete.component.html +++ b/ui-ngx/src/app/modules/home/components/rule-node/common/output-message-type-autocomplete.component.html @@ -16,35 +16,42 @@ -->
- + {{'rule-node-config.output-message-type' | translate}} - - {{msgType.name}} - + @for (msgType of messageTypes; track msgType) { + + {{msgType.name}} + + } - + {{'rule-node-config.message-type-value' | translate}} - - - {{ 'rule-node-config.message-type-value-required' | translate }} - - - {{ 'rule-node-config.message-type-value-max-length' | translate }} - + @if (messageTypeFormGroup.get('messageType').value) { + + } + @if (messageTypeFormGroup.get('messageType').hasError('required')) { + + {{ 'rule-node-config.message-type-value-required' | translate }} + + } + @if (messageTypeFormGroup.get('messageType').hasError('maxlength')) { + + {{ 'rule-node-config.message-type-value-max-length' | translate }} + + }
diff --git a/ui-ngx/src/app/modules/home/components/rule-node/common/output-message-type-autocomplete.component.ts b/ui-ngx/src/app/modules/home/components/rule-node/common/output-message-type-autocomplete.component.ts index 5cb9922fcb..9395f59d6a 100644 --- a/ui-ngx/src/app/modules/home/components/rule-node/common/output-message-type-autocomplete.component.ts +++ b/ui-ngx/src/app/modules/home/components/rule-node/common/output-message-type-autocomplete.component.ts @@ -24,7 +24,7 @@ import { Validator, Validators } from '@angular/forms'; -import { SubscriptSizing } from '@angular/material/form-field'; +import { MatFormFieldAppearance, SubscriptSizing } from '@angular/material/form-field'; import { coerceBoolean } from '@shared/public-api'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; @@ -61,6 +61,9 @@ export class OutputMessageTypeAutocompleteComponent implements ControlValueAcces @coerceBoolean() disabled: boolean; + @Input() + appearance: MatFormFieldAppearance = 'fill'; + @Input() @coerceBoolean() set required(value) { diff --git a/ui-ngx/src/app/modules/home/components/rule-node/common/relations-query-config-old.component.html b/ui-ngx/src/app/modules/home/components/rule-node/common/relations-query-config-old.component.html index bab5638bd6..e6fbcdc3df 100644 --- a/ui-ngx/src/app/modules/home/components/rule-node/common/relations-query-config-old.component.html +++ b/ui-ngx/src/app/modules/home/components/rule-node/common/relations-query-config-old.component.html @@ -23,9 +23,11 @@ relation.direction - - {{ directionTypeTranslations.get(type) | translate }} - + @for (type of directionTypes; track type) { + + {{ directionTypeTranslations.get(type) | translate }} + + } diff --git a/ui-ngx/src/app/modules/home/components/rule-node/common/relations-query-config-old.component.ts b/ui-ngx/src/app/modules/home/components/rule-node/common/relations-query-config-old.component.ts index 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..01ec5c5e9a 100644 --- a/ui-ngx/src/app/modules/home/components/rule-node/common/relations-query-config.component.html +++ b/ui-ngx/src/app/modules/home/components/rule-node/common/relations-query-config.component.html @@ -19,15 +19,17 @@
rule-node-config.relations-query
- + relation.direction - - {{ directionTypeTranslations.get(type) | translate }} rule-node-config.relations-query-config-direction-suffix - + @for (type of directionTypes; track type) { + + {{ directionTypeTranslations.get(type) | translate }} rule-node-config.relations-query-config-direction-suffix + + } - + rule-node-config.max-relation-level - - {{ 'rule-node-config.max-relation-level-error' | translate }} - - - {{ 'rule-node-config.max-relation-level-invalid' | translate }} - + @if (relationsQueryFormGroup.get('maxLevel').hasError('min')) { + + {{ 'rule-node-config.max-relation-level-error' | translate }} + + } + @if (relationsQueryFormGroup.get('maxLevel').invalid) { + + {{ 'rule-node-config.max-relation-level-invalid' | translate }} + + }
-
- - {{ 'alias.last-level-relation' | translate }} - -
+ @if (relationsQueryFormGroup.get('maxLevel').value > 1) { +
+ + {{ 'alias.last-level-relation' | translate }} + +
+ }
relation.relation-filters
diff --git a/ui-ngx/src/app/modules/home/components/rule-node/common/select-attributes.component.html b/ui-ngx/src/app/modules/home/components/rule-node/common/select-attributes.component.html index 9665fff5ce..ac4cc0eafa 100644 --- a/ui-ngx/src/app/modules/home/components/rule-node/common/select-attributes.component.html +++ b/ui-ngx/src/app/modules/home/components/rule-node/common/select-attributes.component.html @@ -17,43 +17,48 @@ -->
+ [popupHelpLink]="popupHelpLink"> -
- - {{ 'rule-node-config.fetch-latest-telemetry-with-timestamp' | translate }} - -
+ class="tb-form-row no-border no-padding"> + + {{ 'rule-node-config.fetch-latest-telemetry-with-timestamp' | translate }} + +
+ }
help + matTooltip="{{ 'rule-node-config.chip-help' | translate: { inputName: 'rule-node-config.field-name' | translate } }}">help diff --git a/ui-ngx/src/app/modules/home/components/rule-node/common/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..6011ea79c5 100644 --- a/ui-ngx/src/app/modules/home/components/rule-node/enrichment/calculate-delta-config.component.html +++ b/ui-ngx/src/app/modules/home/components/rule-node/enrichment/calculate-delta-config.component.html @@ -17,32 +17,40 @@ -->
- + {{ 'rule-node-config.input-value-key' | translate }} - - {{ 'rule-node-config.input-value-key-required' | translate }} - + @if (calculateDeltaConfigForm.get('inputValueKey').hasError('required') || + calculateDeltaConfigForm.get('inputValueKey').hasError('pattern')) { + + {{ 'rule-node-config.input-value-key-required' | translate }} + + } - + {{ 'rule-node-config.output-value-key' | translate }} - - {{ 'rule-node-config.output-value-key-required' | translate }} - + @if (calculateDeltaConfigForm.get('outputValueKey').hasError('required') || + calculateDeltaConfigForm.get('outputValueKey').hasError('pattern')) { + + {{ 'rule-node-config.output-value-key-required' | translate }} + + }
- + {{ 'rule-node-config.number-of-digits-after-floating-point' | translate }} - - {{ 'rule-node-config.number-of-digits-after-floating-point-range' | translate }} - - - {{ 'rule-node-config.number-of-digits-after-floating-point-range' | translate }} - + @if (calculateDeltaConfigForm.get('round').hasError('min')) { + + {{ 'rule-node-config.number-of-digits-after-floating-point-range' | translate }} + + } + @if (calculateDeltaConfigForm.get('round').hasError('max')) { + + {{ 'rule-node-config.number-of-digits-after-floating-point-range' | translate }} + + }
+ class="tb-form-row same-padding"> {{ 'rule-node-config.use-caching' | translate }} @@ -64,26 +72,30 @@ { periodValueKey: (calculateDeltaConfigForm.get('periodValueKey').valid && calculateDeltaConfigForm.get('addPeriodBetweenMsgs').value) ? calculateDeltaConfigForm.get('periodValueKey').value : 'periodInMs' } }}" - class="tb-form-row no-border no-padding"> + class="tb-form-row no-border no-padding"> {{ 'rule-node-config.add-time-difference-between-readings' | translate: { inputValueKey: calculateDeltaConfigForm.get('inputValueKey').valid ? - calculateDeltaConfigForm.get('inputValueKey').value : 'rule-node-config.input-value-key' | translate } }} + calculateDeltaConfigForm.get('inputValueKey').value : 'rule-node-config.input-value-key' | translate } }}
- - {{ 'rule-node-config.period-value-key' | translate }} - - - {{ 'rule-node-config.period-value-key-required' | translate }} - - + @if (calculateDeltaConfigForm.get('addPeriodBetweenMsgs').value) { + + {{ 'rule-node-config.period-value-key' | translate }} + + @if (calculateDeltaConfigForm.get('periodValueKey').hasError('required')) { + + {{ 'rule-node-config.period-value-key-required' | translate }} + + } + + }
+ class="tb-form-row same-padding"> {{ 'rule-node-config.exclude-zero-deltas' | translate }} diff --git a/ui-ngx/src/app/modules/home/components/rule-node/enrichment/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/entity-details-config.component.html b/ui-ngx/src/app/modules/home/components/rule-node/enrichment/entity-details-config.component.html index ff7939cab4..50af390ff8 100644 --- a/ui-ngx/src/app/modules/home/components/rule-node/enrichment/entity-details-config.component.html +++ b/ui-ngx/src/app/modules/home/components/rule-node/enrichment/entity-details-config.component.html @@ -18,6 +18,7 @@
@@ -26,7 +27,7 @@ tb-help-popup-placement="right" trigger-style="letter-spacing:0.25px; font-size:12px" [tb-help-popup-style]="{maxWidth: '820px'}" - trigger-text="{{ 'rule-node-config.key-val.see-examples' | translate }}">
+ trigger-text="{{ 'rule-node-config.key-val.see-examples' | translate }}">
help @@ -39,144 +40,179 @@ {{ 'rule-node-config.use-metadata-dynamic-interval' | translate }}
-
-
- - {{ 'rule-node-config.interval-start' | translate }} - - - {{ 'rule-node-config.start-interval-value-required' | translate }} - - - {{ 'rule-node-config.time-value-range' | translate }} - - - {{ 'rule-node-config.time-value-range' | translate }} - - - - {{ 'rule-node-config.time-unit' | translate }} - - - {{ timeUnitsTranslationMap.get(timeUnit) | translate }} - - - -
-
- - {{ 'rule-node-config.interval-end' | translate }} - - - {{ 'rule-node-config.end-interval-value-required' | translate }} - - - {{ 'rule-node-config.time-value-range' | translate }} - - - {{ 'rule-node-config.time-value-range' | translate }} - - - - {{ 'rule-node-config.time-unit' | translate }} - - - {{ timeUnitsTranslationMap.get(timeUnit) | translate }} - - - -
-
- error_outline -
- - {{ 'rule-node-config.fetch-timeseries-from-to' | translate: - { + @if (getTelemetryFromDatabaseConfigForm.get('useMetadataIntervalPatterns').value === false) { +
+
+ + {{ 'rule-node-config.interval-start' | translate }} + + @if (getTelemetryFromDatabaseConfigForm.get('interval.startInterval').hasError('required')) { + + {{ 'rule-node-config.start-interval-value-required' | translate }} + + } + @if (getTelemetryFromDatabaseConfigForm.get('interval.startInterval').hasError('min')) { + + {{ 'rule-node-config.time-value-range' | translate }} + + } + @if (getTelemetryFromDatabaseConfigForm.get('interval.startInterval').hasError('max')) { + + {{ 'rule-node-config.time-value-range' | translate }} + + } + + + {{ 'rule-node-config.time-unit' | translate }} + + @for (timeUnit of timeUnits; track timeUnit) { + + {{ timeUnitsTranslationMap.get(timeUnit) | translate }} + + } + + +
+
+ + {{ 'rule-node-config.interval-end' | translate }} + + @if (getTelemetryFromDatabaseConfigForm.get('interval.endInterval').hasError('required')) { + + {{ 'rule-node-config.end-interval-value-required' | translate }} + + } + @if (getTelemetryFromDatabaseConfigForm.get('interval.endInterval').hasError('min')) { + + {{ 'rule-node-config.time-value-range' | translate }} + + } + @if (getTelemetryFromDatabaseConfigForm.get('interval.endInterval').hasError('max')) { + + {{ 'rule-node-config.time-value-range' | translate }} + + } + + + {{ 'rule-node-config.time-unit' | translate }} + + @for (timeUnit of timeUnits; track timeUnit) { + + {{ timeUnitsTranslationMap.get(timeUnit) | translate }} + + } + + +
+
+ error_outline +
+ @if (getTelemetryFromDatabaseConfigForm.get('interval').valid) { + {{ 'rule-node-config.fetch-timeseries-from-to' | translate: + { startInterval: getTelemetryFromDatabaseConfigForm.get('interval.startInterval').value, endInterval: getTelemetryFromDatabaseConfigForm.get('interval.endInterval').value, startIntervalTimeUnit: getTelemetryFromDatabaseConfigForm.get('interval.startIntervalTimeUnit').value.toLowerCase(), endIntervalTimeUnit: getTelemetryFromDatabaseConfigForm.get('interval.endIntervalTimeUnit').value.toLowerCase() - } }} - - - {{ "rule-node-config.fetch-timeseries-from-to-invalid" | translate }} - + } }} + } @else { + {{ "rule-node-config.fetch-timeseries-from-to-invalid" | translate }} + } +
-
- + } @else {
- + {{ 'rule-node-config.start-interval' | translate }} - - {{ 'rule-node-config.start-interval-required' | translate }} - + @if (getTelemetryFromDatabaseConfigForm.get('startIntervalPattern').hasError('required') || + getTelemetryFromDatabaseConfigForm.get('startIntervalPattern').hasError('pattern')) { + + {{ 'rule-node-config.start-interval-required' | translate }} + + } - + {{ 'rule-node-config.end-interval' | translate }} - - {{ 'rule-node-config.end-interval-required' | translate }} - + @if (getTelemetryFromDatabaseConfigForm.get('endIntervalPattern').hasError('required') || + getTelemetryFromDatabaseConfigForm.get('endIntervalPattern').hasError('pattern')) { + + {{ 'rule-node-config.end-interval-required' | translate }} + + }
-
+ }
rule-node-config.fetch-strategy
- - {{ data.name }} - + @for (data of headerOptions; track data) { + + {{ data.name }} + + }
{{ deduplicationStrategiesHintTranslations.get(getTelemetryFromDatabaseConfigForm.get('fetchMode').value) | translate }}
-
- - {{ 'aggregation.function' | translate }} - - - {{ aggregationTypesTranslations.get(aggregationTypes[aggregation]) | translate }} - - - -
- - {{ "rule-node-config.order-by-timestamp" | translate }} - - - {{ samplingOrdersTranslate.get(order) | translate }} - + @if (getTelemetryFromDatabaseConfigForm.get('fetchMode').value === fetchMode.ALL) { +
+ + {{ 'aggregation.function' | translate }} + + @for (aggregation of aggregations; track aggregation) { + + {{ aggregationTypesTranslations.get(aggregationTypes[aggregation]) | translate }} + + } - - {{ "rule-node-config.limit" | translate }} - - {{ "rule-node-config.limit-hint" | translate }} - - {{ 'rule-node-config.limit-required' | translate }} - - - {{ 'rule-node-config.limit-range' | translate }} - - - {{ 'rule-node-config.limit-range' | translate }} - - + @if (getTelemetryFromDatabaseConfigForm.get('aggregation').value === aggregationTypes.NONE) { +
+ + {{ "rule-node-config.order-by-timestamp" | translate }} + + @for (order of samplingOrders; track order) { + + {{ samplingOrdersTranslate.get(order) | translate }} + + } + + + + {{ "rule-node-config.limit" | translate }} + + {{ "rule-node-config.limit-hint" | translate }} + @if (getTelemetryFromDatabaseConfigForm.get('limit').hasError('required')) { + + {{ 'rule-node-config.limit-required' | translate }} + + } + @if (getTelemetryFromDatabaseConfigForm.get('limit').hasError('min')) { + + {{ 'rule-node-config.limit-range' | translate }} + + } + @if (getTelemetryFromDatabaseConfigForm.get('limit').hasError('max')) { + + {{ 'rule-node-config.limit-range' | translate }} + + } + +
+ }
-
+ }
diff --git a/ui-ngx/src/app/modules/home/components/rule-node/enrichment/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 483d6be37a..fd866fe52a 100644 --- a/ui-ngx/src/app/modules/home/components/rule-node/external/ai-config.component.html +++ b/ui-ngx/src/app/modules/home/components/rule-node/external/ai-config.component.html @@ -50,28 +50,39 @@ rule-node-config.ai.system-prompt - - {{ 'rule-node-config.ai.system-prompt-max-length' | translate }} - - - {{ 'rule-node-config.ai.system-prompt-blank' | translate }} - + @if (aiConfigForm.get('systemPrompt').hasError('maxlength')) { + + {{ 'rule-node-config.ai.system-prompt-max-length' | translate }} + + } + @if (aiConfigForm.get('systemPrompt').hasError('pattern')) { + + {{ 'rule-node-config.ai.system-prompt-blank' | translate }} + + } rule-node-config.ai.user-prompt - - {{ 'rule-node-config.ai.user-prompt-required' | translate }} - - - {{ 'rule-node-config.ai.user-prompt-max-length' | translate }} - - - {{ 'rule-node-config.ai.user-prompt-blank' | translate }} - + @if (aiConfigForm.get('userPrompt').hasError('required')) { + + {{ 'rule-node-config.ai.user-prompt-required' | translate }} + + } + @if (aiConfigForm.get('userPrompt').hasError('maxlength')) { + + {{ 'rule-node-config.ai.user-prompt-max-length' | translate }} + + } + @if (aiConfigForm.get('userPrompt').hasError('pattern')) { + + {{ 'rule-node-config.ai.user-prompt-blank' | translate }} + + }
- + rule-node-config.topic - - {{ 'rule-node-config.topic-required' | translate }} - + @if (azureIotHubConfigForm.get('topicPattern').hasError('required')) { + + {{ 'rule-node-config.topic-required' | translate }} + + } rule-node-config.general-pattern-hint - + rule-node-config.hostname - - {{ 'rule-node-config.hostname-required' | translate }} - + @if (azureIotHubConfigForm.get('host').hasError('required')) { + + {{ 'rule-node-config.hostname-required' | translate }} + + } - + rule-node-config.device-id - - {{ 'rule-node-config.device-id-required' | translate }} - + @if (azureIotHubConfigForm.get('clientId').hasError('required')) { + + {{ 'rule-node-config.device-id-required' | translate }} + + } - @@ -50,74 +56,81 @@
- + rule-node-config.credentials-type - - {{ azureIotHubCredentialsTypeTranslationsMap.get(credentialsType) | translate }} - + @for (credentialsType of allAzureIotHubCredentialsTypes; track credentialsType) { + + {{ azureIotHubCredentialsTypeTranslationsMap.get(credentialsType) | translate }} + + } - - {{ 'rule-node-config.credentials-type-required' | translate }} - + @if (azureIotHubConfigForm.get('credentials.type').hasError('required')) { + + {{ 'rule-node-config.credentials-type-required' | translate }} + + } -
- - - - - rule-node-config.sas-key - - - - {{ 'rule-node-config.sas-key-required' | translate }} - - - - - - - - - - - - - - rule-node-config.private-key-password - - - - +
+ @switch (azureIotHubConfigForm.get('credentials.type').value) { + @case ('anonymous') {} + @case ('sas') { + + rule-node-config.sas-key + + + @if (azureIotHubConfigForm.get('credentials.sasKey').hasError('required')) { + + {{ 'rule-node-config.sas-key-required' | translate }} + + } + + + + } + @case ('cert.PEM') { + + + + + + + + rule-node-config.private-key-password + + + + } + }
diff --git a/ui-ngx/src/app/modules/home/components/rule-node/external/kafka-config.component.html b/ui-ngx/src/app/modules/home/components/rule-node/external/kafka-config.component.html index 965a0ca8b8..cc02018710 100644 --- a/ui-ngx/src/app/modules/home/components/rule-node/external/kafka-config.component.html +++ b/ui-ngx/src/app/modules/home/components/rule-node/external/kafka-config.component.html @@ -16,82 +16,101 @@ -->
- + rule-node-config.topic-pattern - - {{ 'rule-node-config.topic-pattern-required' | translate }} - + @if (kafkaConfigForm.get('topicPattern').hasError('required')) { + + {{ 'rule-node-config.topic-pattern-required' | translate }} + + } rule-node-config.general-pattern-hint - + rule-node-config.key-pattern rule-node-config.general-pattern-hint
rule-node-config.key-pattern-hint
- + rule-node-config.bootstrap-servers - - {{ 'rule-node-config.bootstrap-servers-required' | translate }} - + @if (kafkaConfigForm.get('bootstrapServers').hasError('required')) { + + {{ 'rule-node-config.bootstrap-servers-required' | translate }} + + } - + rule-node-config.retries - - {{ 'rule-node-config.min-retries-message' | translate }} - + @if (kafkaConfigForm.get('retries').hasError('min')) { + + {{ 'rule-node-config.min-retries-message' | translate }} + + } - + rule-node-config.batch-size-bytes - - {{ 'rule-node-config.min-batch-size-bytes-message' | translate }} - + @if (kafkaConfigForm.get('batchSize').hasError('min')) { + + {{ 'rule-node-config.min-batch-size-bytes-message' | translate }} + + } - + rule-node-config.linger-ms - - {{ 'rule-node-config.min-linger-ms-message' | translate }} - + @if (kafkaConfigForm.get('linger').hasError('min')) { + + {{ 'rule-node-config.min-linger-ms-message' | translate }} + + } - + rule-node-config.buffer-memory-bytes - - {{ 'rule-node-config.min-buffer-memory-bytes-message' | translate }} - + @if (kafkaConfigForm.get('bufferMemory').hasError('min')) { + + {{ 'rule-node-config.min-buffer-memory-bytes-message' | translate }} + + } - + rule-node-config.acks - - {{ ackValue }} - + @for (ackValue of ackValues; track ackValue) { + + {{ ackValue }} + + } - - + [keyText]="'rule-node-config.key' | translate" + [keyRequiredText]="'rule-node-config.key-required' | translate" + [valText]="'rule-node-config.value' | translate" + [valRequiredText]="'rule-node-config.value-required' | translate" + [emptyText]="'rule-node-config.other-properties-empty' | translate"> + {{ 'rule-node-config.add-metadata-key-values-as-kafka-headers' | translate }}
rule-node-config.add-metadata-key-values-as-kafka-headers-hint
- - rule-node-config.charset-encoding - - - {{ ToByteStandartCharsetTypeTranslationMap.get(charset) | translate }} - - - + @if (kafkaConfigForm.get('addMetadataKeyValuesAsKafkaHeaders').value) { + + rule-node-config.charset-encoding + + @for (charset of ToByteStandartCharsetTypesValues; track charset) { + + {{ ToByteStandartCharsetTypeTranslationMap.get(charset) | translate }} + + } + + + }
diff --git a/ui-ngx/src/app/modules/home/components/rule-node/external/lambda-config.component.html b/ui-ngx/src/app/modules/home/components/rule-node/external/lambda-config.component.html index 1839464bbe..4e80aad77b 100644 --- a/ui-ngx/src/app/modules/home/components/rule-node/external/lambda-config.component.html +++ b/ui-ngx/src/app/modules/home/components/rule-node/external/lambda-config.component.html @@ -24,14 +24,16 @@ popupHelpLink="rulenode/node-templatization-doc">
- + {{'rule-node-config.function-name' | translate}} - - {{'rule-node-config.function-name-required' | translate}} - + @if (lambdaConfigForm.get('functionName').hasError('required')) { + + {{'rule-node-config.function-name-required' | translate}} + + } - + {{'rule-node-config.qualifier' | translate}} rule-node-config.qualifier-hint @@ -45,26 +47,32 @@ rule-node-config.aws-credentials
- + rule-node-config.aws-access-key-id - - {{ 'rule-node-config.aws-access-key-id-required' | translate }} - + @if (lambdaConfigForm.get('accessKey').hasError('required')) { + + {{ 'rule-node-config.aws-access-key-id-required' | translate }} + + } - + rule-node-config.aws-secret-access-key - - {{ 'rule-node-config.aws-secret-access-key-required' | translate }} - + @if (lambdaConfigForm.get('secretKey').hasError('required')) { + + {{ 'rule-node-config.aws-secret-access-key-required' | translate }} + + } - + rule-node-config.aws-region - - {{ 'rule-node-config.aws-region-required' | translate }} - + @if (lambdaConfigForm.get('region').hasError('required')) { + + {{ 'rule-node-config.aws-region-required' | translate }} + + }
@@ -76,31 +84,39 @@
- + rule-node-config.connection-timeout - - {{ 'rule-node-config.connection-timeout-required' | translate }} - - - {{ 'rule-node-config.connection-timeout-min' | translate }} - + @if (lambdaConfigForm.get('connectionTimeout').hasError('required')) { + + {{ 'rule-node-config.connection-timeout-required' | translate }} + + } + @if (lambdaConfigForm.get('connectionTimeout').hasError('min')) { + + {{ 'rule-node-config.connection-timeout-min' | translate }} + + } help + matTooltip="{{ 'rule-node-config.connection-timeout-hint' | translate }}">help - + rule-node-config.request-timeout - - {{ 'rule-node-config.request-timeout-required' | translate }} - - - {{ 'rule-node-config.request-timeout-min' | translate }} - + @if (lambdaConfigForm.get('requestTimeout').hasError('required')) { + + {{ 'rule-node-config.request-timeout-required' | translate }} + + } + @if (lambdaConfigForm.get('requestTimeout').hasError('min')) { + + {{ 'rule-node-config.request-timeout-min' | translate }} + + } help + matTooltip="{{ 'rule-node-config.request-timeout-hint' | translate }}">help
- + rule-node-config.topic-pattern - - {{ 'rule-node-config.topic-pattern-required' | translate }} - + @if (mqttConfigForm.get('topicPattern').hasError('required')) { + + {{ 'rule-node-config.topic-pattern-required' | translate }} + + } rule-node-config.general-pattern-hint
- + rule-node-config.host - - {{ 'rule-node-config.host-required' | translate }} - + @if (mqttConfigForm.get('host').hasError('required')) { + + {{ 'rule-node-config.host-required' | translate }} + + } - + rule-node-config.port - - {{ 'rule-node-config.port-required' | translate }} - - - {{ 'rule-node-config.port-range' | translate }} - - - {{ 'rule-node-config.port-range' | translate }} - + @if (mqttConfigForm.get('port').hasError('required')) { + + {{ 'rule-node-config.port-required' | translate }} + + } + @if (mqttConfigForm.get('port').hasError('min')) { + + {{ 'rule-node-config.port-range' | translate }} + + } + @if (mqttConfigForm.get('port').hasError('max')) { + + {{ 'rule-node-config.port-range' | translate }} + + } - + rule-node-config.connect-timeout - - {{ 'rule-node-config.connect-timeout-required' | translate }} - - - {{ 'rule-node-config.connect-timeout-range' | translate }} - - - {{ 'rule-node-config.connect-timeout-range' | translate }} - + @if (mqttConfigForm.get('connectTimeoutSec').hasError('required')) { + + {{ 'rule-node-config.connect-timeout-required' | translate }} + + } + @if (mqttConfigForm.get('connectTimeoutSec').hasError('min')) { + + {{ 'rule-node-config.connect-timeout-range' | translate }} + + } + @if (mqttConfigForm.get('connectTimeoutSec').hasError('max')) { + + {{ 'rule-node-config.connect-timeout-range' | translate }} + + }
- + rule-node-config.client-id {{'rule-node-config.client-id-hint' | translate}} diff --git a/ui-ngx/src/app/modules/home/components/rule-node/external/notification-config.component.html b/ui-ngx/src/app/modules/home/components/rule-node/external/notification-config.component.html index ce937fe7d7..8101d2b2a6 100644 --- a/ui-ngx/src/app/modules/home/components/rule-node/external/notification-config.component.html +++ b/ui-ngx/src/app/modules/home/components/rule-node/external/notification-config.component.html @@ -19,12 +19,14 @@
- + rule-node-config.gcp-project-id - - {{ 'rule-node-config.gcp-project-id-required' | translate }} - + @if (pubSubConfigForm.get('projectId').hasError('required')) { + + {{ 'rule-node-config.gcp-project-id-required' | translate }} + + } - + rule-node-config.pubsub-topic-name - - {{ 'rule-node-config.pubsub-topic-name-required' | translate }} - + @if (pubSubConfigForm.get('topicName').hasError('required')) { + + {{ 'rule-node-config.pubsub-topic-name-required' | translate }} + + }
- - + [keyText]="'rule-node-config.name' | translate" + [keyRequiredText]="'rule-node-config.name-required' | translate" + [valText]="'rule-node-config.value' | translate" + [valRequiredText]="'rule-node-config.value-required' | translate" + [emptyText]="'rule-node-config.message-attributes-empty' | translate"> +
diff --git a/ui-ngx/src/app/modules/home/components/rule-node/external/rabbit-mq-config.component.html b/ui-ngx/src/app/modules/home/components/rule-node/external/rabbit-mq-config.component.html index ff4c4e71e7..71b53b53f6 100644 --- a/ui-ngx/src/app/modules/home/components/rule-node/external/rabbit-mq-config.component.html +++ b/ui-ngx/src/app/modules/home/components/rule-node/external/rabbit-mq-config.component.html @@ -16,53 +16,63 @@ -->
- + rule-node-config.exchange-name-pattern - + rule-node-config.routing-key-pattern - + rule-node-config.message-properties - - {{ property }} - + @for (property of messageProperties; track property) { + + {{ property }} + + }
- + rule-node-config.host - - {{ 'rule-node-config.host-required' | translate }} - + @if (rabbitMqConfigForm.get('host').hasError('required')) { + + {{ 'rule-node-config.host-required' | translate }} + + } - + rule-node-config.port - - {{ 'rule-node-config.port-required' | translate }} - - - {{ 'rule-node-config.port-range' | translate }} - - - {{ 'rule-node-config.port-range' | translate }} - + @if (rabbitMqConfigForm.get('port').hasError('required')) { + + {{ 'rule-node-config.port-required' | translate }} + + } + @if (rabbitMqConfigForm.get('port').hasError('min')) { + + {{ 'rule-node-config.port-range' | translate }} + + } + @if (rabbitMqConfigForm.get('port').hasError('max')) { + + {{ 'rule-node-config.port-range' | translate }} + + }
- + rule-node-config.virtual-host - + rule-node-config.username - + rule-node-config.password @@ -70,27 +80,32 @@ {{ 'rule-node-config.automatic-recovery' | translate }} - + rule-node-config.connection-timeout-ms - - {{ 'rule-node-config.min-connection-timeout-ms-message' | translate }} - + @if (rabbitMqConfigForm.get('connectionTimeout').hasError('min')) { + + {{ 'rule-node-config.min-connection-timeout-ms-message' | translate }} + + } - + rule-node-config.handshake-timeout-ms - - {{ 'rule-node-config.min-handshake-timeout-ms-message' | translate }} - + @if (rabbitMqConfigForm.get('handshakeTimeout').hasError('min')) { + + {{ 'rule-node-config.min-handshake-timeout-ms-message' | translate }} + + } - - + [keyText]="'rule-node-config.key' | translate" + [keyRequiredText]="'rule-node-config.key-required' | translate" + [valText]="'rule-node-config.value' | translate" + [valRequiredText]="'rule-node-config.value-required' | translate" + [emptyText]="'rule-node-config.client-properties-empty' | translate"> +
diff --git a/ui-ngx/src/app/modules/home/components/rule-node/external/rest-api-call-config.component.html b/ui-ngx/src/app/modules/home/components/rule-node/external/rest-api-call-config.component.html index 1a91cba5c7..f5ec1ba156 100644 --- a/ui-ngx/src/app/modules/home/components/rule-node/external/rest-api-call-config.component.html +++ b/ui-ngx/src/app/modules/home/components/rule-node/external/rest-api-call-config.component.html @@ -15,116 +15,176 @@ limitations under the License. --> -
- - rule-node-config.endpoint-url-pattern - - - {{ 'rule-node-config.endpoint-url-pattern-required' | translate }} - - rule-node-config.general-pattern-hint - - +
+
+
rule-node-config.request-url
+ + rule-node-config.url + + @if (restApiCallConfigForm.get('restEndpointUrlPattern').hasError('required')) { + + {{ 'rule-node-config.endpoint-url-pattern-required' | translate }} + + } + rule-node-config.url-hint + + + + {{ 'rule-node-config.query-params' | translate }} +
+
+ + + + +
+
+ rule-node-config.request-method - - {{ requestType }} - + @for (requestType of httpRequestTypes; track requestType) { + + {{ requestType }} + + } - - {{ 'rule-node-config.enable-proxy' | translate }} - - - {{ 'rule-node-config.use-simple-client-http-factory' | translate }} - - - {{ 'rule-node-config.parse-to-plain-text' | translate }} - -
rule-node-config.parse-to-plain-text-hint
- - {{ 'rule-node-config.ignore-request-body' | translate }} - -
- - {{ 'rule-node-config.use-system-proxy-properties' | translate }} - -
-
- - rule-node-config.proxy-scheme - - - {{ proxyScheme }} - - +
+ + + {{ 'rule-node-config.headers' | translate }} + + +
+ + +
+
+
+ +
+ + + {{ 'rule-node-config.advanced-settings' | translate }} + + +
+ + {{ 'rule-node-config.enable-proxy' | translate }} + + @if (restApiCallConfigForm.get('enableProxy').value) { +
+ + {{ 'rule-node-config.use-system-proxy-properties' | translate }} + + @if (!restApiCallConfigForm.get('useSystemProxyProperties').value) { +
+
+ + rule-node-config.proxy-host + + @if (restApiCallConfigForm.get('proxyHost').hasError('required')) { + + {{ 'rule-node-config.proxy-host-required' | translate }} + + } + + + rule-node-config.proxy-port + + @if (restApiCallConfigForm.get('proxyPort').hasError('required')) { + + {{ 'rule-node-config.proxy-port-required' | translate }} + + } + @if (restApiCallConfigForm.get('proxyPort').hasError('min') || restApiCallConfigForm.get('proxyPort').hasError('max')) { + + {{ 'rule-node-config.proxy-port-range' | translate }} + + } + +
+ + rule-node-config.proxy-user + + + + rule-node-config.proxy-password + + + +
+ } +
+ } +
+
+ +
+ {{ 'rule-node-config.parse-to-plain-text' | translate }} +
+
+
+
+ + {{ 'rule-node-config.ignore-request-body' | translate }} + + @if(!restApiCallConfigForm.get('ignoreRequestBody').value) { +
+ + rule-node-config.request-body-template + + + } +
+ + rule-node-config.read-timeout + + rule-node-config.read-timeout-hint + @if (restApiCallConfigForm.get('readTimeoutMs').hasError('max')) { + + {{ 'rule-node-config.int-range' | translate }} + + } - - rule-node-config.proxy-host - - - {{ 'rule-node-config.proxy-host-required' | translate }} - + + rule-node-config.max-parallel-requests-count + + rule-node-config.max-parallel-requests-count-hint + @if (restApiCallConfigForm.get('maxParallelRequestsCount').hasError('max')) { + + {{ 'rule-node-config.int-range' | translate }} + + } - - rule-node-config.proxy-port - - - {{ 'rule-node-config.proxy-port-required' | translate }} - - - {{ 'rule-node-config.proxy-port-range' | translate }} - + + rule-node-config.max-response-size + + rule-node-config.max-response-size-hint + @if (restApiCallConfigForm.get('maxInMemoryBufferSizeInKb').hasError('min') || restApiCallConfigForm.get('maxInMemoryBufferSizeInKb').hasError('max')) { + + {{ 'rule-node-config.memory-buffer-size-range' | translate: { max: MemoryBufferSizeInKbLimit } }} + + } -
- - rule-node-config.proxy-user - - - - rule-node-config.proxy-password - - -
+ +
- - rule-node-config.read-timeout - - rule-node-config.read-timeout-hint - - {{ 'rule-node-config.int-range' | translate }} - - - - rule-node-config.max-parallel-requests-count - - rule-node-config.max-parallel-requests-count-hint - - {{ 'rule-node-config.int-range' | translate }} - - - - rule-node-config.max-response-size - - rule-node-config.max-response-size-hint - - {{ 'rule-node-config.memory-buffer-size-range' | translate: { max: MemoryBufferSizeInKbLimit } }} - - - -
- - -
diff --git a/ui-ngx/src/app/modules/home/components/rule-node/external/rest-api-call-config.component.scss b/ui-ngx/src/app/modules/home/components/rule-node/external/rest-api-call-config.component.scss new file mode 100644 index 0000000000..a4ad3fe4f8 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/rule-node/external/rest-api-call-config.component.scss @@ -0,0 +1,32 @@ +/** + * Copyright © 2016-2026 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +:host ::ng-deep { + .mat-expansion-panel-content { + .mat-expansion-panel-body { + .tb-kv-map-config { + margin-bottom: 0; + .body { + .mdc-icon-button { + margin-bottom: 22px; + } + } + } + } + } + textarea.tb-enable-vertical-resize { + resize: vertical; + } +} diff --git a/ui-ngx/src/app/modules/home/components/rule-node/external/rest-api-call-config.component.ts b/ui-ngx/src/app/modules/home/components/rule-node/external/rest-api-call-config.component.ts index 4723a0c510..32ea3d57d9 100644 --- a/ui-ngx/src/app/modules/home/components/rule-node/external/rest-api-call-config.component.ts +++ b/ui-ngx/src/app/modules/home/components/rule-node/external/rest-api-call-config.component.ts @@ -18,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,11 @@ export class RestApiCallConfigComponent extends RuleNodeConfigurationComponent { this.restApiCallConfigForm = this.fb.group({ restEndpointUrlPattern: [configuration ? configuration.restEndpointUrlPattern : null, [Validators.required]], requestMethod: [configuration ? configuration.requestMethod : null, [Validators.required]], - useSimpleClientHttpFactory: [configuration ? configuration.useSimpleClientHttpFactory : false, []], parseToPlainText: [configuration ? configuration.parseToPlainText : false, []], ignoreRequestBody: [configuration ? configuration.ignoreRequestBody : false, []], + requestBodyTemplate: [configuration ? configuration.requestBodyTemplate : null, []], enableProxy: [configuration ? configuration.enableProxy : false, []], useSystemProxyProperties: [configuration ? configuration.enableProxy : false, []], - proxyScheme: [configuration ? configuration.proxyHost : null, []], proxyHost: [configuration ? configuration.proxyHost : null, []], proxyPort: [configuration ? configuration.proxyPort : null, []], proxyUser: [configuration ? configuration.proxyUser :null, []], @@ -60,16 +62,27 @@ export class RestApiCallConfigComponent extends RuleNodeConfigurationComponent { maxParallelRequestsCount: [configuration ? configuration.maxParallelRequestsCount : null, [Validators.min(0), Validators.max(IntLimit)]], headers: [configuration ? configuration.headers : null, []], credentials: [configuration ? configuration.credentials : null, []], - maxInMemoryBufferSizeInKb: [configuration ? configuration.maxInMemoryBufferSizeInKb : null, [Validators.min(1), Validators.max(this.MemoryBufferSizeInKbLimit)]] + maxInMemoryBufferSizeInKb: [configuration ? configuration.maxInMemoryBufferSizeInKb : null, [Validators.min(1), Validators.max(this.MemoryBufferSizeInKbLimit)]], + queryParams: [configuration ? configuration.queryParams : [], []], }); } protected validatorTriggers(): string[] { - return ['useSimpleClientHttpFactory', 'enableProxy', 'useSystemProxyProperties']; + return ['enableProxy', 'useSystemProxyProperties']; + } + + protected prepareInputConfig(configuration: RuleNodeConfiguration): RuleNodeConfiguration { + this.isQueryParamsExpanded = configuration && isDefinedAndNotNull(configuration.queryParams) && !!Object.keys(configuration.queryParams).length; + this.isHeadersExpanded = configuration && isDefinedAndNotNull(configuration.headers) && !!Object.keys(configuration.headers).length; + return super.prepareInputConfig(configuration); + } + + protected prepareOutputConfig(configuration: RuleNodeConfiguration): RuleNodeConfiguration { + configuration.queryParams = isDefinedAndNotNull(configuration.queryParams) ? configuration.queryParams : []; + return super.prepareOutputConfig(configuration); } protected updateValidators(emitEvent: boolean) { - const useSimpleClientHttpFactory: boolean = this.restApiCallConfigForm.get('useSimpleClientHttpFactory').value; const enableProxy: boolean = this.restApiCallConfigForm.get('enableProxy').value; const useSystemProxyProperties: boolean = this.restApiCallConfigForm.get('useSystemProxyProperties').value; @@ -80,12 +93,7 @@ export class RestApiCallConfigComponent extends RuleNodeConfigurationComponent { } else { this.restApiCallConfigForm.get('proxyHost').setValidators([]); this.restApiCallConfigForm.get('proxyPort').setValidators([]); - - if (useSimpleClientHttpFactory) { - this.restApiCallConfigForm.get('readTimeoutMs').setValidators([]); - } else { - this.restApiCallConfigForm.get('readTimeoutMs').setValidators([Validators.min(0), Validators.max(IntLimit)]); - } + this.restApiCallConfigForm.get('readTimeoutMs').setValidators([Validators.min(0), Validators.max(IntLimit)]); } this.restApiCallConfigForm.get('readTimeoutMs').updateValueAndValidity({emitEvent}); diff --git a/ui-ngx/src/app/modules/home/components/rule-node/external/send-email-config.component.html b/ui-ngx/src/app/modules/home/components/rule-node/external/send-email-config.component.html index 98e2c2f686..9f5f9a590f 100644 --- a/ui-ngx/src/app/modules/home/components/rule-node/external/send-email-config.component.html +++ b/ui-ngx/src/app/modules/home/components/rule-node/external/send-email-config.component.html @@ -19,98 +19,126 @@ {{ 'rule-node-config.use-system-smtp-settings' | translate }} -
- - rule-node-config.smtp-protocol - - - {{ smtpProtocol.toUpperCase() }} - - - -
- - rule-node-config.smtp-host - - - {{ 'rule-node-config.smtp-host-required' | translate }} - + @if (sendEmailConfigForm.get('useSystemSmtpSettings').value === false) { +
+ + rule-node-config.smtp-protocol + + @for (smtpProtocol of smtpProtocols; track smtpProtocol) { + + {{ smtpProtocol.toUpperCase() }} + + } + - - rule-node-config.smtp-port - - - {{ 'rule-node-config.smtp-port-required' | translate }} - - - {{ 'rule-node-config.smtp-port-range' | translate }} - - - {{ 'rule-node-config.smtp-port-range' | translate }} - - -
- - rule-node-config.timeout-msec - - - {{ 'rule-node-config.timeout-required' | translate }} - - - {{ 'rule-node-config.min-timeout-msec-message' | translate }} - - - - {{ 'rule-node-config.enable-tls' | translate }} - - - rule-node-config.tls-version - - - {{ tlsVersion }} - - - - - {{ 'rule-node-config.enable-proxy' | translate }} - -
- - rule-node-config.proxy-host - - - {{ 'rule-node-config.proxy-host-required' | translate }} - + + rule-node-config.smtp-host + + @if (sendEmailConfigForm.get('smtpHost').hasError('required')) { + + {{ 'rule-node-config.smtp-host-required' | translate }} + + } + + + rule-node-config.smtp-port + + @if (sendEmailConfigForm.get('smtpPort').hasError('required')) { + + {{ 'rule-node-config.smtp-port-required' | translate }} + + } + @if (sendEmailConfigForm.get('smtpPort').hasError('min')) { + + {{ 'rule-node-config.smtp-port-range' | translate }} + + } + @if (sendEmailConfigForm.get('smtpPort').hasError('max')) { + + {{ 'rule-node-config.smtp-port-range' | translate }} + + } - - rule-node-config.proxy-port - - - {{ 'rule-node-config.proxy-port-required' | translate }} +
+ + rule-node-config.timeout-msec + + @if (sendEmailConfigForm.get('timeout').hasError('required')) { + + {{ 'rule-node-config.timeout-required' | translate }} - - {{ 'rule-node-config.proxy-port-range' | translate }} + } + @if (sendEmailConfigForm.get('timeout').hasError('min')) { + + {{ 'rule-node-config.min-timeout-msec-message' | translate }} + } + + + {{ 'rule-node-config.enable-tls' | translate }} + + @if (sendEmailConfigForm.get('enableTls').value === true) { + + rule-node-config.tls-version + + @for (tlsVersion of tlsVersions; track tlsVersion) { + + {{ tlsVersion }} + + } + -
- - rule-node-config.proxy-user - + } + + {{ 'rule-node-config.enable-proxy' | translate }} + + @if (sendEmailConfigForm.get('enableProxy').value) { +
+
+ + rule-node-config.proxy-host + + @if (sendEmailConfigForm.get('proxyHost').hasError('required')) { + + {{ 'rule-node-config.proxy-host-required' | translate }} + + } + + + rule-node-config.proxy-port + + @if (sendEmailConfigForm.get('proxyPort').hasError('required')) { + + {{ 'rule-node-config.proxy-port-required' | translate }} + + } + @if (sendEmailConfigForm.get('proxyPort').hasError('min') || sendEmailConfigForm.get('proxyPort').hasError('max')) { + + {{ 'rule-node-config.proxy-port-range' | translate }} + + } + +
+ + rule-node-config.proxy-user + + + + rule-node-config.proxy-password + + +
+ } + + rule-node-config.username + - - rule-node-config.proxy-password - + + rule-node-config.password + + -
- - rule-node-config.username - - - - rule-node-config.password - - - -
+
+ } diff --git a/ui-ngx/src/app/modules/home/components/rule-node/external/send-sms-config.component.html b/ui-ngx/src/app/modules/home/components/rule-node/external/send-sms-config.component.html index 1f6762c082..d9136b3cb6 100644 --- a/ui-ngx/src/app/modules/home/components/rule-node/external/send-sms-config.component.html +++ b/ui-ngx/src/app/modules/home/components/rule-node/external/send-sms-config.component.html @@ -16,28 +16,33 @@ -->
- + rule-node-config.numbers-to-template - - {{ 'rule-node-config.numbers-to-template-required' | translate }} - + @if (sendSmsConfigForm.get('numbersToTemplate').hasError('required')) { + + {{ 'rule-node-config.numbers-to-template-required' | translate }} + + } - + rule-node-config.sms-message-template - - - {{ 'rule-node-config.sms-message-template-required' | translate }} - + + @if (sendSmsConfigForm.get('smsMessageTemplate').hasError('required')) { + + {{ 'rule-node-config.sms-message-template-required' | translate }} + + } rule-node-config.general-pattern-hint {{ 'rule-node-config.use-system-sms-settings' | translate }} - - + @if (sendSmsConfigForm.get('useSystemSmsSettings').value === false) { + + + }
diff --git a/ui-ngx/src/app/modules/home/components/rule-node/external/slack-config.component.html b/ui-ngx/src/app/modules/home/components/rule-node/external/slack-config.component.html index 2cf15b05e7..31f91ba86a 100644 --- a/ui-ngx/src/app/modules/home/components/rule-node/external/slack-config.component.html +++ b/ui-ngx/src/app/modules/home/components/rule-node/external/slack-config.component.html @@ -16,31 +16,40 @@ -->
- + rule-node-config.message-template - - {{ 'rule-node-config.message-template-required' | translate }} - + @if (slackConfigForm.get('messageTemplate').hasError('required')) { + + {{ 'rule-node-config.message-template-required' | translate }} + + } rule-node-config.general-pattern-hint {{ 'rule-node-config.use-system-slack-settings' | translate }} - - rule-node-config.slack-api-token - - - {{ 'rule-node-config.slack-api-token-required' | translate }} - - + @if (!slackConfigForm.get('useSystemSettings').value) { + + rule-node-config.slack-api-token + + @if (slackConfigForm.get('botToken').hasError('required')) { + + {{ 'rule-node-config.slack-api-token-required' | translate }} + + } + + } - - {{ slackChanelTypesTranslateMap.get(slackChanelType) | translate }} - + @for (slackChanelType of slackChanelTypes; track slackChanelType) { + + {{ slackChanelTypesTranslateMap.get(slackChanelType) | translate }} + + }
- + rule-node-config.topic-arn-pattern - - {{ 'rule-node-config.topic-arn-pattern-required' | translate }} - + @if (snsConfigForm.get('topicArnPattern').hasError('required')) { + + {{ 'rule-node-config.topic-arn-pattern-required' | translate }} + + } rule-node-config.general-pattern-hint - + rule-node-config.aws-access-key-id - - {{ 'rule-node-config.aws-access-key-id-required' | translate }} - + @if (snsConfigForm.get('accessKeyId').hasError('required')) { + + {{ 'rule-node-config.aws-access-key-id-required' | translate }} + + } - + rule-node-config.aws-secret-access-key - - {{ 'rule-node-config.aws-secret-access-key-required' | translate }} - + @if (snsConfigForm.get('secretAccessKey').hasError('required')) { + + {{ 'rule-node-config.aws-secret-access-key-required' | translate }} + + } - + rule-node-config.aws-region - - {{ 'rule-node-config.aws-region-required' | translate }} - + @if (snsConfigForm.get('region').hasError('required')) { + + {{ 'rule-node-config.aws-region-required' | translate }} + + }
diff --git a/ui-ngx/src/app/modules/home/components/rule-node/external/sqs-config.component.html b/ui-ngx/src/app/modules/home/components/rule-node/external/sqs-config.component.html index 8e3de40601..e803d37921 100644 --- a/ui-ngx/src/app/modules/home/components/rule-node/external/sqs-config.component.html +++ b/ui-ngx/src/app/modules/home/components/rule-node/external/sqs-config.component.html @@ -16,61 +16,78 @@ -->
- + rule-node-config.queue-type - - {{ sqsQueueTypeTranslationsMap.get(type) | translate }} - + @for (type of sqsQueueTypes; track type) { + + {{ sqsQueueTypeTranslationsMap.get(type) | translate }} + + } - + rule-node-config.queue-url-pattern - - {{ 'rule-node-config.queue-url-pattern-required' | translate }} - + @if (sqsConfigForm.get('queueUrlPattern').hasError('required')) { + + {{ 'rule-node-config.queue-url-pattern-required' | translate }} + + } rule-node-config.general-pattern-hint - - rule-node-config.delay-seconds - - - {{ 'rule-node-config.min-delay-seconds-message' | translate }} - - - {{ 'rule-node-config.max-delay-seconds-message' | translate }} - - + @if (sqsConfigForm.get('queueType').value === sqsQueueType.STANDARD) { + + rule-node-config.delay-seconds + + @if (sqsConfigForm.get('delaySeconds').hasError('min')) { + + {{ 'rule-node-config.min-delay-seconds-message' | translate }} + + } + @if (sqsConfigForm.get('delaySeconds').hasError('max')) { + + {{ 'rule-node-config.max-delay-seconds-message' | translate }} + + } + + }
- - - + [keyText]="'rule-node-config.name' | translate" + [keyRequiredText]="'rule-node-config.name-required' | translate" + [valText]="'rule-node-config.value' | translate" + [valRequiredText]="'rule-node-config.value-required' | translate" + [emptyText]="'rule-node-config.message-attributes-empty' | translate"> + + rule-node-config.aws-access-key-id - - {{ 'rule-node-config.aws-access-key-id-required' | translate }} - + @if (sqsConfigForm.get('accessKeyId').hasError('required')) { + + {{ 'rule-node-config.aws-access-key-id-required' | translate }} + + } - + rule-node-config.aws-secret-access-key - - {{ 'rule-node-config.aws-secret-access-key-required' | translate }} - + @if (sqsConfigForm.get('secretAccessKey').hasError('required')) { + + {{ 'rule-node-config.aws-secret-access-key-required' | translate }} + + } - + rule-node-config.aws-region - - {{ 'rule-node-config.aws-region-required' | translate }} - + @if (sqsConfigForm.get('region').hasError('required')) { + + {{ 'rule-node-config.aws-region-required' | translate }} + + }
diff --git a/ui-ngx/src/app/modules/home/components/rule-node/external/sqs-config.component.ts b/ui-ngx/src/app/modules/home/components/rule-node/external/sqs-config.component.ts index 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-message-config.component.html b/ui-ngx/src/app/modules/home/components/rule-node/filter/check-message-config.component.html index c4cd8f18ed..ae5a957bf6 100644 --- a/ui-ngx/src/app/modules/home/components/rule-node/filter/check-message-config.component.html +++ b/ui-ngx/src/app/modules/home/components/rule-node/filter/check-message-config.component.html @@ -23,6 +23,7 @@
@@ -30,6 +31,7 @@ matTooltip="{{ 'rule-node-config.chip-help' | translate: { inputName: 'rule-node-config.field-name' | translate } }}">help diff --git a/ui-ngx/src/app/modules/home/components/rule-node/filter/check-relation-config.component.html b/ui-ngx/src/app/modules/home/components/rule-node/filter/check-relation-config.component.html index 07a1d1068a..6aee9bf101 100644 --- a/ui-ngx/src/app/modules/home/components/rule-node/filter/check-relation-config.component.html +++ b/ui-ngx/src/app/modules/home/components/rule-node/filter/check-relation-config.component.html @@ -18,15 +18,18 @@
rule-node-config.relation-search-parameters
- + {{ 'relation.direction' | translate }} - - {{ entitySearchDirectionTranslationsMap.get(direction) | translate }} rule-node-config.relations-query-config-direction-suffix - + @for (direction of entitySearchDirection; track direction) { + + {{ entitySearchDirectionTranslationsMap.get(direction) | translate }} rule-node-config.relations-query-config-direction-suffix + + } @@ -36,20 +39,25 @@ {{ 'rule-node-config.check-relation-to-specific-entity' | translate }}
-
- - - - -
+ @if (checkRelationConfigForm.get('checkForSingleEntity').value) { +
+ + + @if (checkRelationConfigForm.get('entityType').value) { + + + } +
+ }
diff --git a/ui-ngx/src/app/modules/home/components/rule-node/filter/gps-geo-filter-config.component.html b/ui-ngx/src/app/modules/home/components/rule-node/filter/gps-geo-filter-config.component.html index ec2a5ba89a..13bc0b15dd 100644 --- a/ui-ngx/src/app/modules/home/components/rule-node/filter/gps-geo-filter-config.component.html +++ b/ui-ngx/src/app/modules/home/components/rule-node/filter/gps-geo-filter-config.component.html @@ -20,37 +20,43 @@
rule-node-config.coordinate-field-names
- + {{ 'rule-node-config.latitude-field-name' | translate }} - - {{ 'rule-node-config.latitude-field-name-required' | translate }} - + @if (geoFilterConfigForm.get('latitudeKeyName').hasError('required')) { + + {{ 'rule-node-config.latitude-field-name-required' | translate }} + + } - + {{ 'rule-node-config.longitude-field-name' | translate }} - - {{ 'rule-node-config.longitude-field-name-required' | translate }} - + @if (geoFilterConfigForm.get('longitudeKeyName').hasError('required')) { + + {{ 'rule-node-config.longitude-field-name-required' | translate }} + + }
-
rule-node-config.coordinate-field-hint
+
rule-node-config.coordinate-field-hint
rule-node-config.geofence-configuration
- + {{ 'rule-node-config.perimeter-type' | translate }} - - {{ perimeterTypeTranslationMap.get(type) | translate }} - + @for (type of perimeterTypes; track type) { + + {{ perimeterTypeTranslationMap.get(type) | translate }} + + }
+ class="tb-form-row no-border no-padding slide-toggle"> {{ 'rule-node-config.fetch-perimeter-info-from-metadata' | translate }}
- - {{ 'rule-node-config.perimeter-key-name' | translate }} - - - {{ 'rule-node-config.perimeter-key-name-required' | translate }} - - {{ 'rule-node-config.perimeter-key-name-hint' | translate }} - -
-
- - {{ 'rule-node-config.circle-center-latitude' | translate }} - - - {{ 'rule-node-config.circle-center-latitude-required' | translate }} + @if (geoFilterConfigForm.get('fetchPerimeterInfoFromMessageMetadata').value) { + + {{ 'rule-node-config.perimeter-key-name' | translate }} + + @if (geoFilterConfigForm.get('perimeterKeyName').hasError('required')) { + + {{ 'rule-node-config.perimeter-key-name-required' | translate }} - - - {{ 'rule-node-config.circle-center-longitude' | translate }} - - - {{ 'rule-node-config.circle-center-longitude-required' | translate }} - - + } + {{ 'rule-node-config.perimeter-key-name-hint' | translate }} + + } + @if (geoFilterConfigForm.get('perimeterType').value === perimeterType.CIRCLE && + !geoFilterConfigForm.get('fetchPerimeterInfoFromMessageMetadata').value) { +
+
+ + {{ 'rule-node-config.circle-center-latitude' | translate }} + + @if (geoFilterConfigForm.get('centerLatitude').hasError('required')) { + + {{ 'rule-node-config.circle-center-latitude-required' | translate }} + + } + + + {{ 'rule-node-config.circle-center-longitude' | translate }} + + @if (geoFilterConfigForm.get('centerLongitude').hasError('required')) { + + {{ 'rule-node-config.circle-center-longitude-required' | translate }} + + } + +
+
+ + {{ 'rule-node-config.range' | translate }} + + @if (geoFilterConfigForm.get('range').hasError('required')) { + + {{ 'rule-node-config.range-required' | translate }} + + } + + + {{ 'rule-node-config.range-units' | translate }} + + @for (type of rangeUnits; track type) { + + {{ rangeUnitTranslationMap.get(type) | translate }} + + } + + @if (geoFilterConfigForm.get('rangeUnit').hasError('required')) { + + {{ 'rule-node-config.range-units-required' | translate }} + + } + +
-
- - {{ 'rule-node-config.range' | translate }} - - - {{ 'rule-node-config.range-required' | translate }} + } + @if (geoFilterConfigForm.get('perimeterType').value === perimeterType.POLYGON && + !geoFilterConfigForm.get('fetchPerimeterInfoFromMessageMetadata').value) { + + {{ 'rule-node-config.polygon-definition' | translate }} + + {{ 'rule-node-config.polygon-definition-hint' | translate }} + @if (geoFilterConfigForm.get('polygonsDefinition').hasError('required')) { + + {{ 'rule-node-config.polygon-definition-required' | translate }} - - - {{ 'rule-node-config.range-units' | translate }} - - - {{ rangeUnitTranslationMap.get(type) | translate }} - - - - {{ 'rule-node-config.range-units-required' | translate }} - - -
-
- - {{ 'rule-node-config.polygon-definition' | translate }} - - {{ 'rule-node-config.polygon-definition-hint' | translate }} - - {{ 'rule-node-config.polygon-definition-required' | translate }} - - + } + + }
diff --git a/ui-ngx/src/app/modules/home/components/rule-node/filter/originator-type-config.component.html b/ui-ngx/src/app/modules/home/components/rule-node/filter/originator-type-config.component.html index a0d90bab4a..b94519d914 100644 --- a/ui-ngx/src/app/modules/home/components/rule-node/filter/originator-type-config.component.html +++ b/ui-ngx/src/app/modules/home/components/rule-node/filter/originator-type-config.component.html @@ -17,6 +17,7 @@ -->
- - - - - - - + @if (tbelEnabled) { + + } + @if (scriptConfigForm.get('scriptLang').value === scriptLanguage.JS) { + + + + } + @if (scriptConfigForm.get('scriptLang').value === scriptLanguage.TBEL) { + + + + } diff --git a/ui-ngx/src/app/modules/home/components/rule-node/filter/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/flow/rule-chain-input.component.html b/ui-ngx/src/app/modules/home/components/rule-node/flow/rule-chain-input.component.html index c4d813b757..8b8ff8ffa4 100644 --- a/ui-ngx/src/app/modules/home/components/rule-node/flow/rule-chain-input.component.html +++ b/ui-ngx/src/app/modules/home/components/rule-node/flow/rule-chain-input.component.html @@ -27,6 +27,7 @@ [excludeEntityIds]="[ruleChainId]" [entityType]="entityType.RULE_CHAIN" [entitySubtype]="ruleChainType" + appearance="outline" formControlName="ruleChainId">
diff --git a/ui-ngx/src/app/modules/home/components/rule-node/rule-node-config.models.ts b/ui-ngx/src/app/modules/home/components/rule-node/rule-node-config.models.ts index f326e7e9ce..7a2bec7e33 100644 --- a/ui-ngx/src/app/modules/home/components/rule-node/rule-node-config.models.ts +++ b/ui-ngx/src/app/modules/home/components/rule-node/rule-node-config.models.ts @@ -827,7 +827,7 @@ export enum AttributeScopeResult { SERVER_SCOPE = 'SERVER_SCOPE' } -export const AttributeScopeMap = new Map([ +export const AttributeScopeMap = new Map([ [AttributeScope.SHARED_SCOPE, 'rule-node-config.shared-scope'], [AttributeScope.SERVER_SCOPE, 'rule-node-config.server-scope'], [AttributeScope.CLIENT_SCOPE, 'rule-node-config.client-scope'] diff --git a/ui-ngx/src/app/modules/home/components/rule-node/transformation/change-originator-config.component.html b/ui-ngx/src/app/modules/home/components/rule-node/transformation/change-originator-config.component.html index aecafa4164..efcea2eb7b 100644 --- a/ui-ngx/src/app/modules/home/components/rule-node/transformation/change-originator-config.component.html +++ b/ui-ngx/src/app/modules/home/components/rule-node/transformation/change-originator-config.component.html @@ -16,51 +16,60 @@ -->
- + rule-node-config.new-originator - - {{ originatorSourceTranslationMap.get(changeOriginatorConfigForm.get('originatorSource').value) | translate }} - + @if (originatorSourceTranslationMap.get(changeOriginatorConfigForm.get('originatorSource').value)) { + + {{ originatorSourceTranslationMap.get(changeOriginatorConfigForm.get('originatorSource').value) | translate }} + + } - - - {{ originatorSourceTranslationMap.get(source) | translate }} - -
- - {{ originatorSourceDescTranslationMap.get(source) | translate }} - -
-
-
-
- - -
- + + {{ originatorSourceTranslationMap.get(source) | translate }} + +
+ + {{ originatorSourceDescTranslationMap.get(source) | translate }} + + + } + + + @if (changeOriginatorConfigForm.get('originatorSource').value === originatorSource.ENTITY) { +
+ + +
+ + + + rule-node-config.entity-name-pattern + + @if (changeOriginatorConfigForm.get('entityNamePattern').hasError('required') || + changeOriginatorConfigForm.get('entityNamePattern').hasError('pattern')) { + + {{ 'rule-node-config.entity-name-pattern-required' | translate }} + + } + +
+
+ } + @if (changeOriginatorConfigForm.get('originatorSource').value === originatorSource.RELATED) { + -
- - rule-node-config.entity-name-pattern - - - {{ 'rule-node-config.entity-name-pattern-required' | translate }} - - -
-
- - -
+ formControlName="relationsQuery"> + + } + diff --git a/ui-ngx/src/app/modules/home/components/rule-node/transformation/copy-keys-config.component.html b/ui-ngx/src/app/modules/home/components/rule-node/transformation/copy-keys-config.component.html index 0fc80e6197..a899a344b3 100644 --- a/ui-ngx/src/app/modules/home/components/rule-node/transformation/copy-keys-config.component.html +++ b/ui-ngx/src/app/modules/home/components/rule-node/transformation/copy-keys-config.component.html @@ -23,6 +23,7 @@
- + {{'rule-node-config.interval' | translate}} - - {{'rule-node-config.interval-required' | translate}} - - - {{'rule-node-config.interval-min-error' | translate}} - + @if (deduplicationConfigForm.get('interval').hasError('required')) { + + {{'rule-node-config.interval-required' | translate}} + + } + @if (deduplicationConfigForm.get('interval').hasError('min')) { + + {{'rule-node-config.interval-min-error' | translate}} + + } help + matTooltip="{{ 'rule-node-config.interval-hint' | translate }}">help
rule-node-config.strategy
- - {{ deduplicationStrategiesTranslations.get(strategy) | translate }} - + @for (strategy of deduplicationStrategies; track strategy) { + + {{ deduplicationStrategiesTranslations.get(strategy) | translate }} + + } - - - - - - -
- - -
+ @if (deduplicationConfigForm.get('strategy').value === 'ALL') { + + + } + @if (deduplicationConfigForm.get('strategy').value === 'FIRST') { + + + } + @if (deduplicationConfigForm.get('strategy').value === 'LAST') { + + + } + @if (deduplicationConfigForm.get('strategy').value === deduplicationStrategie.ALL) { +
+ + +
+ }
@@ -61,37 +73,49 @@ rule-node-config.advanced-settings
- + {{'rule-node-config.max-pending-msgs' | translate}} - - {{'rule-node-config.max-pending-msgs-required' | translate}} - - - {{'rule-node-config.max-pending-msgs-max-error' | translate}} - - - {{'rule-node-config.max-pending-msgs-min-error' | translate}} - + @if (deduplicationConfigForm.get('maxPendingMsgs').hasError('required')) { + + {{'rule-node-config.max-pending-msgs-required' | translate}} + + } + @if (deduplicationConfigForm.get('maxPendingMsgs').hasError('max')) { + + {{'rule-node-config.max-pending-msgs-max-error' | translate}} + + } + @if (deduplicationConfigForm.get('maxPendingMsgs').hasError('min')) { + + {{'rule-node-config.max-pending-msgs-min-error' | translate}} + + } help + matTooltip="{{ 'rule-node-config.max-pending-msgs-hint' | translate }}">help - + {{'rule-node-config.max-retries' | translate}} - - {{'rule-node-config.max-retries-required' | translate}} - - - {{'rule-node-config.max-retries-max-error' | translate}} - - - {{'rule-node-config.max-retries-min-error' | translate}} - + @if (deduplicationConfigForm.get('maxRetries').hasError('required')) { + + {{'rule-node-config.max-retries-required' | translate}} + + } + @if (deduplicationConfigForm.get('maxRetries').hasError('max')) { + + {{'rule-node-config.max-retries-max-error' | translate}} + + } + @if (deduplicationConfigForm.get('maxRetries').hasError('min')) { + + {{'rule-node-config.max-retries-min-error' | translate}} + + } help + matTooltip="{{ 'rule-node-config.max-retries-hint' | translate }}">help
diff --git a/ui-ngx/src/app/modules/home/components/rule-node/transformation/deduplication-config.component.ts b/ui-ngx/src/app/modules/home/components/rule-node/transformation/deduplication-config.component.ts index 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/delete-keys-config.component.html b/ui-ngx/src/app/modules/home/components/rule-node/transformation/delete-keys-config.component.html index c5d94739d6..4da3fdf45c 100644 --- a/ui-ngx/src/app/modules/home/components/rule-node/transformation/delete-keys-config.component.html +++ b/ui-ngx/src/app/modules/home/components/rule-node/transformation/delete-keys-config.component.html @@ -22,6 +22,7 @@
- + {{ 'rule-node-config.json-path-expression' | translate }} {{ 'rule-node-config.json-path-expression-hint' | translate }} - {{ 'rule-node-config.json-path-expression-required' | translate }} + @if (jsonPathConfigForm.get('jsonPath').hasError('required')) { + {{ 'rule-node-config.json-path-expression-required' | translate }} + }
diff --git a/ui-ngx/src/app/modules/home/components/rule-node/transformation/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) { + + + + }
@@ -46,94 +48,106 @@
rule-node-config.recipients
+ popupHelpLink="rulenode/to_email_node_fields_templatization">
- + rule-node-config.to-template - - {{ 'rule-node-config.to-template-required' | translate }} - + cdkAutosizeMinRows="1"> + @if (toEmailConfigForm.get('toTemplate').hasError('required')) { + + {{ 'rule-node-config.to-template-required' | translate }} + + } - + rule-node-config.cc-template + cdkAutosizeMinRows="1"> - + rule-node-config.bcc-template + cdkAutosizeMinRows="1">
rule-node-config.message-subject-and-content
+ popupHelpLink="rulenode/to_email_node_fields_templatization">
- + rule-node-config.subject-template - - {{ 'rule-node-config.subject-template-required' | translate }} - + cdkAutosizeMinRows="1"> + @if (toEmailConfigForm.get('subjectTemplate').hasError('required')) { + + {{ 'rule-node-config.subject-template-required' | translate }} + + } - + rule-node-config.mail-body-type - - {{ getBodyTypeName() | translate }} - + @if (toEmailConfigForm.get('mailBodyType').value) { + + {{ getBodyTypeName() | translate }} + + } - - - {{ type.name | translate }} - -
- - {{ type.description | translate }} - -
-
-
- - rule-node-config.body-type-template - - rule-node-config.mail-body-types.after-template-evaluation-hint - - - rule-node-config.body-template - - - {{ 'rule-node-config.body-template-required' | translate }} - - -
-
-
+ @for (type of mailBodyTypes; track type) { + + + {{ type.name | translate }} + +
+ + {{ type.description | translate }} + +
+ } + +
+ @if (toEmailConfigForm.get('mailBodyType').value === 'dynamic') { + + rule-node-config.body-type-template + + rule-node-config.mail-body-types.after-template-evaluation-hint + + } + + rule-node-config.body-template + + @if (toEmailConfigForm.get('bodyTemplate').hasError('required')) { + + {{ 'rule-node-config.body-template-required' | translate }} + + } + + +
+ diff --git a/ui-ngx/src/app/modules/home/components/sms/aws-sns-provider-configuration.component.html b/ui-ngx/src/app/modules/home/components/sms/aws-sns-provider-configuration.component.html index 3beaa93ed1..f48f8b2191 100644 --- a/ui-ngx/src/app/modules/home/components/sms/aws-sns-provider-configuration.component.html +++ b/ui-ngx/src/app/modules/home/components/sms/aws-sns-provider-configuration.component.html @@ -16,26 +16,32 @@ --> - + admin.aws-access-key-id - - {{ 'admin.aws-access-key-id-required' | translate }} - + @if (awsSnsProviderConfigurationFormGroup.get('accessKeyId').hasError('required')) { + + {{ 'admin.aws-access-key-id-required' | translate }} + + } - + admin.aws-secret-access-key - - {{ 'admin.aws-secret-access-key-required' | translate }} - + @if (awsSnsProviderConfigurationFormGroup.get('secretAccessKey').hasError('required')) { + + {{ 'admin.aws-secret-access-key-required' | translate }} + + } - + admin.aws-region - - {{ 'admin.aws-region-required' | translate }} - + @if (awsSnsProviderConfigurationFormGroup.get('region').hasError('required')) { + + {{ 'admin.aws-region-required' | translate }} + + } diff --git a/ui-ngx/src/app/modules/home/components/sms/smpp-sms-provider-configuration.component.html b/ui-ngx/src/app/modules/home/components/sms/smpp-sms-provider-configuration.component.html index bc1e9430c7..92b986f0c8 100644 --- a/ui-ngx/src/app/modules/home/components/sms/smpp-sms-provider-configuration.component.html +++ b/ui-ngx/src/app/modules/home/components/sms/smpp-sms-provider-configuration.component.html @@ -17,44 +17,54 @@ -->
- + admin.smpp-provider.smpp-version - - {{smppVersion.value}} - + @for (smppVersion of smppVersions; track smppVersion) { + + {{smppVersion.value}} + + } - + admin.smpp-provider.smpp-host - - {{'admin.smpp-provider.smpp-host-required' | translate}} - + @if (smppSmsProviderConfigurationFormGroup.get('host').hasError('required')) { + + {{'admin.smpp-provider.smpp-host-required' | translate}} + + } - + admin.smpp-provider.smpp-port - - {{'admin.smpp-provider.smpp-port-required' | translate}} - + @if (smppSmsProviderConfigurationFormGroup.get('port').hasError('required')) { + + {{'admin.smpp-provider.smpp-port-required' | translate}} + + }
- + admin.smpp-provider.system-id - - {{'admin.smpp-provider.system-id-required' | translate}} - + @if (smppSmsProviderConfigurationFormGroup.get('systemId').hasError('required')) { + + {{'admin.smpp-provider.system-id-required' | translate}} + + } - + admin.smpp-provider.password - - {{'admin.smpp-provider.password-required' | translate}} - + @if (smppSmsProviderConfigurationFormGroup.get('password').hasError('required')) { + + {{'admin.smpp-provider.password-required' | translate}} + + }
@@ -65,19 +75,21 @@ - + admin.smpp-provider.system-type - + admin.smpp-provider.bind-type - - {{bindTypesTranslation.get(bindType) | translate}} - + @for (bindType of bindTypes; track bindType) { + + {{bindTypesTranslation.get(bindType) | translate}} + + } - + admin.smpp-provider.service-type @@ -90,24 +102,28 @@ - + admin.smpp-provider.source-address - + admin.smpp-provider.source-ton - - {{typeOfNumberMap.get(sourceTon).name | translate}} - + @for (sourceTon of typeOfNumber; track sourceTon) { + + {{typeOfNumberMap.get(sourceTon).name | translate}} + + } - + admin.smpp-provider.source-npi - - {{numberingPlanIdentificationMap.get(sourceNpi).name | translate}} - + @for (sourceNpi of numberingPlanIdentification; track sourceNpi) { + + {{numberingPlanIdentificationMap.get(sourceNpi).name | translate}} + + } @@ -119,20 +135,24 @@ - + admin.smpp-provider.destination-ton - - {{typeOfNumberMap.get(destinationTon).name | translate}} - + @for (destinationTon of typeOfNumber; track destinationTon) { + + {{typeOfNumberMap.get(destinationTon).name | translate}} + + } - + admin.smpp-provider.destination-npi - - {{numberingPlanIdentificationMap.get(destinationNpi).name | translate}} - + @for (destinationNpi of numberingPlanIdentification; track destinationNpi) { + + {{numberingPlanIdentificationMap.get(destinationNpi).name | translate}} + + } @@ -144,16 +164,18 @@ - + admin.smpp-provider.address-range - + admin.smpp-provider.coding-scheme - - {{codingSchemesMap.get(codingScheme).name | translate}} - + @for (codingScheme of codingSchemes; track codingScheme) { + + {{codingSchemesMap.get(codingScheme).name | translate}} + + } diff --git a/ui-ngx/src/app/modules/home/components/sms/smpp-sms-provider-configuration.component.ts b/ui-ngx/src/app/modules/home/components/sms/smpp-sms-provider-configuration.component.ts index 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..9d4d540a04 100644 --- a/ui-ngx/src/app/modules/home/components/sms/sms-provider-configuration.component.html +++ b/ui-ngx/src/app/modules/home/components/sms/sms-provider-configuration.component.html @@ -16,35 +16,41 @@ -->
- + admin.sms-provider-type - - {{smsProviderTypeTranslations.get(type) | translate}} - + @for (type of smsProviderTypes; track type) { + + {{smsProviderTypeTranslations.get(type) | translate}} + + } - - {{ 'admin.sms-provider-type-required' | translate }} - + @if (smsProviderConfigurationFormGroup.get('type').hasError('required')) { + + {{ 'admin.sms-provider-type-required' | translate }} + + } -
- - - - - - - - - - - - +
+ @switch (smsProviderConfigurationFormGroup.get('type').value) { + @case (smsProviderType.AWS_SNS) { + + + } + @case (smsProviderType.TWILIO) { + + + } + @case (smsProviderType.SMPP) { + + + } + }
diff --git a/ui-ngx/src/app/modules/home/components/sms/sms-provider-configuration.component.ts b/ui-ngx/src/app/modules/home/components/sms/sms-provider-configuration.component.ts index 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..b82e951fe2 100644 --- a/ui-ngx/src/app/modules/home/components/sms/twilio-sms-provider-configuration.component.html +++ b/ui-ngx/src/app/modules/home/components/sms/twilio-sms-provider-configuration.component.html @@ -16,30 +16,38 @@ --> - + admin.number-from - - {{ 'admin.number-from-required' | translate }} - - - {{ 'admin.phone-number-pattern-twilio' | translate }} - + @if (twilioSmsProviderConfigurationFormGroup.get('numberFrom').hasError('required')) { + + {{ 'admin.number-from-required' | translate }} + + } + @if (twilioSmsProviderConfigurationFormGroup.get('numberFrom').hasError('pattern')) { + + {{ 'admin.phone-number-pattern-twilio' | translate }} + + } - + admin.twilio-account-sid - - {{ 'admin.twilio-account-sid-required' | translate }} - + @if (twilioSmsProviderConfigurationFormGroup.get('accountSid').hasError('required')) { + + {{ 'admin.twilio-account-sid-required' | translate }} + + } - + admin.twilio-account-token - - {{ 'admin.twilio-account-token-required' | translate }} - + @if (twilioSmsProviderConfigurationFormGroup.get('accountToken').hasError('required')) { + + {{ 'admin.twilio-account-token-required' | translate }} + + } diff --git a/ui-ngx/src/app/modules/home/components/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/command-button-basic-config.component.html b/ui-ngx/src/app/modules/home/components/widget/config/basic/button/command-button-basic-config.component.html index 34ce199e65..e39d57b64e 100644 --- a/ui-ngx/src/app/modules/home/components/widget/config/basic/button/command-button-basic-config.component.html +++ b/ui-ngx/src/app/modules/home/components/widget/config/basic/button/command-button-basic-config.component.html @@ -16,7 +16,7 @@ --> - +
widgets.command-button.behavior
diff --git a/ui-ngx/src/app/modules/home/components/widget/config/basic/button/power-button-basic-config.component.html b/ui-ngx/src/app/modules/home/components/widget/config/basic/button/power-button-basic-config.component.html index 51ebab7a92..8d05bad66d 100644 --- a/ui-ngx/src/app/modules/home/components/widget/config/basic/button/power-button-basic-config.component.html +++ b/ui-ngx/src/app/modules/home/components/widget/config/basic/button/power-button-basic-config.component.html @@ -16,7 +16,7 @@ --> - +
widgets.power-button.behavior
@@ -30,7 +30,7 @@ [aliasController]="aliasController" [targetDevice]="targetDevice" [widgetType]="widgetType" - formControlName="initialState"> + formControlName="initialState">
widgets.power-button.power-on
@@ -39,7 +39,7 @@ [aliasController]="aliasController" [targetDevice]="targetDevice" [widgetType]="widgetType" - formControlName="onUpdateState"> + formControlName="onUpdateState">
widgets.power-button.power-off
@@ -48,7 +48,7 @@ [aliasController]="aliasController" [targetDevice]="targetDevice" [widgetType]="widgetType" - formControlName="offUpdateState"> + formControlName="offUpdateState">
widgets.rpc-state.disabled-state
@@ -59,7 +59,7 @@ [aliasController]="aliasController" [targetDevice]="targetDevice" [widgetType]="widgetType" - formControlName="disabledState"> + formControlName="disabledState">
@@ -70,12 +70,14 @@ 'lt-sm': 1, 'lt-md': 2 }}" - label="{{ 'widgets.power-button.layout' | translate }}" formControlName="layout"> - - {{ powerButtonLayoutTranslationMap.get(layout) | translate }} - + label="{{ 'widgets.power-button.layout' | translate }}" formControlName="layout"> + @for (layout of powerButtonLayouts; track layout) { + + {{ powerButtonLayoutTranslationMap.get(layout) | translate }} + + }
diff --git a/ui-ngx/src/app/modules/home/components/widget/config/basic/button/segmented-button-basic-config.component.html b/ui-ngx/src/app/modules/home/components/widget/config/basic/button/segmented-button-basic-config.component.html index f748c13f81..fd4790749c 100644 --- a/ui-ngx/src/app/modules/home/components/widget/config/basic/button/segmented-button-basic-config.component.html +++ b/ui-ngx/src/app/modules/home/components/widget/config/basic/button/segmented-button-basic-config.component.html @@ -16,14 +16,15 @@ --> - - + @if (!widgetEditMode) { + + + }
widgets.action-button.behavior
@@ -37,7 +38,7 @@ [aliasController]="aliasController" [targetDevice]="targetDevice" [widgetType]="widgetType" - formControlName="initialState"> + formControlName="initialState">
@@ -68,7 +69,7 @@ [aliasController]="aliasController" [targetDevice]="targetDevice" [widgetType]="widgetType" - formControlName="disabledState"> + formControlName="disabledState">
@@ -80,12 +81,14 @@ breakpoints: { 'lt-sm': 1 }}" - label="{{ 'widgets.segmented-button.layout' | translate }}" formControlName="layout"> - - {{ segmentedButtonLayoutTranslationMap.get(layout) | translate }} - + label="{{ 'widgets.segmented-button.layout' | translate }}" formControlName="layout"> + @for (layout of segmentedButtonLayouts; track layout) { + + {{ segmentedButtonLayoutTranslationMap.get(layout) | translate }} + + }
@@ -224,16 +227,18 @@ -
-
{{ widgetButtonToggleStatesTranslationsMap.get(state) | translate }}
- - -
+ @for (state of widgetButtonToggleStates; track state) { +
+
{{ widgetButtonToggleStatesTranslationsMap.get(state) | translate }}
+ + +
+ }
@@ -265,16 +270,18 @@ -
-
{{ widgetButtonToggleStatesTranslationsMap.get(state) | translate }}
- - -
+ @for (state of widgetButtonToggleStates; track state) { +
+
{{ widgetButtonToggleStatesTranslationsMap.get(state) | translate }}
+ + +
+ }
diff --git a/ui-ngx/src/app/modules/home/components/widget/config/basic/button/toggle-button-basic-config.component.html b/ui-ngx/src/app/modules/home/components/widget/config/basic/button/toggle-button-basic-config.component.html index d506fe4e8b..905e9e3bac 100644 --- a/ui-ngx/src/app/modules/home/components/widget/config/basic/button/toggle-button-basic-config.component.html +++ b/ui-ngx/src/app/modules/home/components/widget/config/basic/button/toggle-button-basic-config.component.html @@ -16,7 +16,7 @@ --> - +
widgets.toggle-button.behavior
diff --git a/ui-ngx/src/app/modules/home/components/widget/config/basic/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..61398972c4 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 @@ -98,8 +98,13 @@ {{ 'widgets.time-series-chart.data-zoom' | translate }}
+
+ + {{ 'widgets.time-series-chart.data-zoom-update-timewindow' | translate }} + +
+ formControlName="grid">
@@ -167,41 +172,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 +222,11 @@
{{ 'legend.position' | translate }}
- - {{ legendPositionTranslationMap.get(pos) | translate }} - + @for (pos of legendPositions; track pos) { + + {{ legendPositionTranslationMap.get(pos) | translate }} + + }
@@ -280,7 +287,7 @@
+ formControlName="tooltipDateFormat"> @@ -316,7 +323,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 d569bcf37b..bfd430ad36 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 @@ -116,6 +116,7 @@ export class BarChartWithLabelsBasicConfigComponent extends BasicWidgetConfigCom iconColor: [configData.config.iconColor, []], dataZoom: [settings.dataZoom, []], + dataZoomUpdateTimewindow: [settings.dataZoomUpdateTimewindow, []], showBarLabel: [settings.showBarLabel, []], barLabelFont: [settings.barLabelFont, []], @@ -193,6 +194,7 @@ export class BarChartWithLabelsBasicConfigComponent extends BasicWidgetConfigCom this.widgetConfig.config.settings = this.widgetConfig.config.settings || {}; this.widgetConfig.config.settings.dataZoom = config.dataZoom; + this.widgetConfig.config.settings.dataZoomUpdateTimewindow = config.dataZoomUpdateTimewindow; this.widgetConfig.config.settings.showBarLabel = config.showBarLabel; this.widgetConfig.config.settings.barLabelFont = config.barLabelFont; @@ -248,10 +250,11 @@ export class BarChartWithLabelsBasicConfigComponent extends BasicWidgetConfigCom } protected validatorTriggers(): string[] { - return ['showTitle', 'showIcon', 'showBarLabel', 'showBarValue', 'showBarBorder', 'showLegend', 'showTooltip', 'tooltipShowDate']; + return ['dataZoom', 'showTitle', 'showIcon', 'showBarLabel', 'showBarValue', 'showBarBorder', 'showLegend', 'showTooltip', 'tooltipShowDate']; } protected updateValidators(emitEvent: boolean, trigger?: string) { + const dataZoom: boolean = this.barChartWidgetConfigForm.get('dataZoom').value; const showTitle: boolean = this.barChartWidgetConfigForm.get('showTitle').value; const showIcon: boolean = this.barChartWidgetConfigForm.get('showIcon').value; const showBarLabel: boolean = this.barChartWidgetConfigForm.get('showBarLabel').value; @@ -261,6 +264,12 @@ export class BarChartWithLabelsBasicConfigComponent extends BasicWidgetConfigCom const showTooltip: boolean = this.barChartWidgetConfigForm.get('showTooltip').value; const tooltipShowDate: boolean = this.barChartWidgetConfigForm.get('tooltipShowDate').value; + if (dataZoom) { + this.barChartWidgetConfigForm.get('dataZoomUpdateTimewindow').enable(); + } else { + this.barChartWidgetConfigForm.get('dataZoomUpdateTimewindow').disable(); + } + if (showTitle) { this.barChartWidgetConfigForm.get('title').enable(); this.barChartWidgetConfigForm.get('titleFont').enable(); 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/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..fcba48e87a 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 }} + + + + + + + - + +
-
- - + + }
- - + + }
+
+ + {{ 'widgets.time-series-chart.data-zoom-update-timewindow' | translate }} + +
+ formControlName="grid">
@@ -193,10 +203,11 @@ axisType="xAxis">
- - + @if (chartType === TimeSeriesChartType.default || chartType === TimeSeriesChartType.bar) { + + + }
@@ -268,9 +279,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 +316,7 @@
+ formControlName="tooltipDateFormat"> @@ -325,7 +336,7 @@
- {{ 'tooltip.hide-zero-tooltip-values' | translate }} + {{ 'tooltip.hide-zero-tooltip-values' | translate }}
@@ -380,6 +391,6 @@
+ formControlName="actions"> diff --git a/ui-ngx/src/app/modules/home/components/widget/config/basic/chart/time-series-chart-basic-config.component.ts b/ui-ngx/src/app/modules/home/components/widget/config/basic/chart/time-series-chart-basic-config.component.ts index 5b1410e5d7..143e7a682e 100644 --- a/ui-ngx/src/app/modules/home/components/widget/config/basic/chart/time-series-chart-basic-config.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/config/basic/chart/time-series-chart-basic-config.component.ts @@ -96,7 +96,7 @@ export class TimeSeriesChartBasicConfigComponent extends BasicWidgetConfigCompon seriesMode = 'series'; predefinedValues = widgetTitleAutocompleteValues; - + constructor(protected store: Store, protected widgetConfigComponent: WidgetConfigComponent, private $injector: Injector, @@ -164,6 +164,7 @@ export class TimeSeriesChartBasicConfigComponent extends BasicWidgetConfigCompon iconColor: [configData.config.iconColor, []], dataZoom: [settings.dataZoom, []], + dataZoomUpdateTimewindow: [settings.dataZoomUpdateTimewindow, []], stack: [settings.stack, []], grid: [settings.grid, []], @@ -241,6 +242,7 @@ export class TimeSeriesChartBasicConfigComponent extends BasicWidgetConfigCompon this.widgetConfig.config.settings.thresholds = config.thresholds; this.widgetConfig.config.settings.dataZoom = config.dataZoom; + this.widgetConfig.config.settings.dataZoomUpdateTimewindow = config.dataZoomUpdateTimewindow; this.widgetConfig.config.settings.stack = config.stack; this.widgetConfig.config.settings.grid = config.grid; @@ -292,11 +294,12 @@ export class TimeSeriesChartBasicConfigComponent extends BasicWidgetConfigCompon } protected validatorTriggers(): string[] { - return ['comparisonEnabled', 'showTitle', 'showIcon', 'showLegend', 'showTooltip', 'tooltipShowDate', 'stack']; + return ['comparisonEnabled', 'dataZoom', 'showTitle', 'showIcon', 'showLegend', 'showTooltip', 'tooltipShowDate', 'stack']; } protected updateValidators(emitEvent: boolean, trigger?: string) { const comparisonEnabled: boolean = this.timeSeriesChartWidgetConfigForm.get('comparisonEnabled').value; + const dataZoom: boolean = this.timeSeriesChartWidgetConfigForm.get('dataZoom').value; const showTitle: boolean = this.timeSeriesChartWidgetConfigForm.get('showTitle').value; const showIcon: boolean = this.timeSeriesChartWidgetConfigForm.get('showIcon').value; const showLegend: boolean = this.timeSeriesChartWidgetConfigForm.get('showLegend').value; @@ -304,6 +307,12 @@ export class TimeSeriesChartBasicConfigComponent extends BasicWidgetConfigCompon const tooltipShowDate: boolean = this.timeSeriesChartWidgetConfigForm.get('tooltipShowDate').value; const stack: boolean = this.timeSeriesChartWidgetConfigForm.get('stack').value; + if (dataZoom) { + this.timeSeriesChartWidgetConfigForm.get('dataZoomUpdateTimewindow').enable(); + } else { + this.timeSeriesChartWidgetConfigForm.get('dataZoomUpdateTimewindow').disable(); + } + if (comparisonEnabled) { this.timeSeriesChartWidgetConfigForm.get('timeForComparison').enable(); this.timeSeriesChartWidgetConfigForm.get('comparisonCustomIntervalValue').enable(); 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 500d974726..043edd53f4 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..c64a4e23d9 100644 --- a/ui-ngx/src/app/modules/home/components/widget/config/basic/indicator/status-widget-basic-config.component.html +++ b/ui-ngx/src/app/modules/home/components/widget/config/basic/indicator/status-widget-basic-config.component.html @@ -16,7 +16,7 @@ --> - +
widgets.status-widget.behavior
@@ -30,7 +30,7 @@ [aliasController]="aliasController" [targetDevice]="targetDevice" [widgetType]="widgetType" - formControlName="initialState"> + formControlName="initialState">
widgets.rpc-state.disabled-state
@@ -41,7 +41,7 @@ [aliasController]="aliasController" [targetDevice]="targetDevice" [widgetType]="widgetType" - formControlName="disabledState"> + formControlName="disabledState">
@@ -52,12 +52,14 @@ 'lt-sm': 1, 'lt-md': 2 }}" - label="{{ 'widgets.status-widget.layout' | translate }}" formControlName="layout"> - - {{ statusWidgetLayoutTranslationMap.get(layout) | translate }} - + label="{{ 'widgets.status-widget.layout' | translate }}" formControlName="layout"> + @for (layout of statusWidgetLayouts; track layout) { + + {{ statusWidgetLayoutTranslationMap.get(layout) | translate }} + + }
@@ -69,16 +71,18 @@ {{ 'widgets.status-widget.off' | translate }}
- - - + } + @if (cardStyleMode === 'off') { + - + + }
widget-config.card-appearance
@@ -102,6 +106,6 @@
+ formControlName="actions"> diff --git a/ui-ngx/src/app/modules/home/components/widget/config/basic/map/map-basic-config.component.html b/ui-ngx/src/app/modules/home/components/widget/config/basic/map/map-basic-config.component.html index 3d1f05fb4b..bfbdc25d9e 100644 --- a/ui-ngx/src/app/modules/home/components/widget/config/basic/map/map-basic-config.component.html +++ b/ui-ngx/src/app/modules/home/components/widget/config/basic/map/map-basic-config.component.html @@ -16,10 +16,12 @@ --> - - + @if (displayTimewindowConfig) { + + + } - +
widgets.single-switch.behavior
@@ -30,7 +30,7 @@ [aliasController]="aliasController" [targetDevice]="targetDevice" [widgetType]="widgetType" - formControlName="initialState"> + formControlName="initialState">
widgets.rpc-state.turn-on
@@ -39,7 +39,7 @@ [aliasController]="aliasController" [targetDevice]="targetDevice" [widgetType]="widgetType" - formControlName="onUpdateState"> + formControlName="onUpdateState">
widgets.rpc-state.turn-off
@@ -48,7 +48,7 @@ [aliasController]="aliasController" [targetDevice]="targetDevice" [widgetType]="widgetType" - formControlName="offUpdateState"> + formControlName="offUpdateState">
widgets.rpc-state.disabled-state
@@ -59,7 +59,7 @@ [aliasController]="aliasController" [targetDevice]="targetDevice" [widgetType]="widgetType" - formControlName="disabledState"> + formControlName="disabledState">
@@ -70,12 +70,14 @@ 'lt-sm': 1, 'lt-md': 2 }}" - label="{{ 'widgets.single-switch.layout' | translate }}" formControlName="layout"> - - {{ singleSwitchLayoutTranslationMap.get(layout) | translate }} - + label="{{ 'widgets.single-switch.layout' | translate }}" formControlName="layout"> + @for (layout of singleSwitchLayouts; track layout) { + + {{ singleSwitchLayoutTranslationMap.get(layout) | translate }} + + }
diff --git a/ui-ngx/src/app/modules/home/components/widget/config/basic/rpc/slider-basic-config.component.html b/ui-ngx/src/app/modules/home/components/widget/config/basic/rpc/slider-basic-config.component.html index b6566c181e..ff5397ea16 100644 --- a/ui-ngx/src/app/modules/home/components/widget/config/basic/rpc/slider-basic-config.component.html +++ b/ui-ngx/src/app/modules/home/components/widget/config/basic/rpc/slider-basic-config.component.html @@ -16,7 +16,7 @@ --> - +
widgets.slider.behavior
@@ -27,7 +27,7 @@ [aliasController]="aliasController" [targetDevice]="targetDevice" [widgetType]="widgetType" - formControlName="initialState"> + formControlName="initialState">
widgets.slider.on-value-change
@@ -37,7 +37,7 @@ [aliasController]="aliasController" [targetDevice]="targetDevice" [widgetType]="widgetType" - formControlName="valueChange"> + formControlName="valueChange">
widgets.rpc-state.disabled-state
@@ -48,7 +48,7 @@ [aliasController]="aliasController" [targetDevice]="targetDevice" [widgetType]="widgetType" - formControlName="disabledState"> + formControlName="disabledState">
@@ -59,12 +59,14 @@ 'lt-sm': 1, 'lt-md': 2 }}" - label="{{ 'widgets.slider.layout' | translate }}" formControlName="layout"> - - {{ sliderLayoutTranslationMap.get(layout) | translate }} - + label="{{ 'widgets.slider.layout' | translate }}" formControlName="layout"> + @for (layout of sliderLayouts; track layout) { + + {{ sliderLayoutTranslationMap.get(layout) | translate }} + + }
@@ -117,25 +119,27 @@
-
- - {{ 'widgets.slider.value' | translate }} - -
- - - -
widget-config.decimals-suffix
-
- - - - + @if (sliderWidgetConfigForm.get('layout').value !== sliderLayout.simplified) { +
+ + {{ 'widgets.slider.value' | translate }} + +
+ + + +
widget-config.decimals-suffix
+
+ + + + +
-
+ }
{{ 'widgets.slider.range' | translate }}
@@ -213,44 +217,48 @@
-
-
- {{ 'widgets.slider.left-icon' | translate }} -
-
- - - - - - - - -
-
-
-
- {{ 'widgets.slider.right-icon' | translate }} + @if (sliderWidgetConfigForm.get('layout').value === sliderLayout.extended) { +
+
+ {{ 'widgets.slider.left-icon' | translate }} +
+
+ + + + + + + + +
-
- - - - - - - - + } + @if (sliderWidgetConfigForm.get('layout').value === sliderLayout.extended) { +
+
+ {{ 'widgets.slider.right-icon' | translate }} +
+
+ + + + + + + + +
-
+ }
widget-config.card-appearance
@@ -279,6 +287,6 @@
+ formControlName="actions">
diff --git a/ui-ngx/src/app/modules/home/components/widget/config/basic/rpc/value-stepper-basic-config.component.html b/ui-ngx/src/app/modules/home/components/widget/config/basic/rpc/value-stepper-basic-config.component.html index d9c654f17b..986af5d80e 100644 --- a/ui-ngx/src/app/modules/home/components/widget/config/basic/rpc/value-stepper-basic-config.component.html +++ b/ui-ngx/src/app/modules/home/components/widget/config/basic/rpc/value-stepper-basic-config.component.html @@ -16,7 +16,7 @@ --> - +
widgets.value-stepper.behavior
@@ -27,7 +27,7 @@ [aliasController]="aliasController" [targetDevice]="targetDevice" [widgetType]="widgetType" - formControlName="initialState"> + formControlName="initialState">
widgets.value-stepper.left-button-click
@@ -37,7 +37,7 @@ [aliasController]="aliasController" [targetDevice]="targetDevice" [widgetType]="widgetType" - formControlName="leftButtonClick"> + formControlName="leftButtonClick">
widgets.value-stepper.right-button-click
@@ -47,7 +47,7 @@ [aliasController]="aliasController" [targetDevice]="targetDevice" [widgetType]="widgetType" - formControlName="rightButtonClick"> + formControlName="rightButtonClick">
widgets.button-state.disabled-state
@@ -58,7 +58,7 @@ [aliasController]="aliasController" [targetDevice]="targetDevice" [widgetType]="widgetType" - formControlName="disabledState"> + formControlName="disabledState">
@@ -69,12 +69,14 @@ 'lt-sm': 1, 'lt-md': 2 }}" - label="{{ 'widgets.button.layout' | translate }}" formControlName="type"> - - {{ valueStepperTypeTranslationMap.get(type) | translate }} - + label="{{ 'widgets.button.layout' | translate }}" formControlName="type"> + @for (type of valueStepperTypes; track type) { + + {{ valueStepperTypeTranslationMap.get(type) | translate }} + + }
diff --git a/ui-ngx/src/app/modules/home/components/widget/config/basic/scada/scada-symbol-basic-config.component.html b/ui-ngx/src/app/modules/home/components/widget/config/basic/scada/scada-symbol-basic-config.component.html index 03cdee6b95..fa9780cd06 100644 --- a/ui-ngx/src/app/modules/home/components/widget/config/basic/scada/scada-symbol-basic-config.component.html +++ b/ui-ngx/src/app/modules/home/components/widget/config/basic/scada/scada-symbol-basic-config.component.html @@ -16,7 +16,7 @@ --> - +
scada.symbol.symbol
- - + @if (displayTimewindowConfig) { + + + } - - - - + @if (!windSpeedDirectionWidgetConfigForm.get('centerValueKey').value) { + + + } + @if (!windSpeedDirectionWidgetConfigForm.get('centerValueKey').value) { + + + }
@@ -64,15 +70,19 @@ hideDataKeyLabel hideDataKeyColor> - - - - + @if (windSpeedDirectionWidgetConfigForm.get('centerValueKey').value) { + + + } + @if (windSpeedDirectionWidgetConfigForm.get('centerValueKey').value) { + + + }
@@ -83,12 +93,14 @@ breakpoints: { 'lt-md': 2 }}" - label="{{ 'widgets.wind-speed-direction.layout' | translate }}" formControlName="layout"> - - {{ windSpeedDirectionLayoutTranslationMap.get(layout) | translate }} - + label="{{ 'widgets.wind-speed-direction.layout' | translate }}" formControlName="layout"> + @for (layout of windSpeedDirectionLayouts; track layout) { + + {{ windSpeedDirectionLayoutTranslationMap.get(layout) | translate }} + + }
diff --git a/ui-ngx/src/app/modules/home/components/widget/config/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..1d47c35261 100644 --- a/ui-ngx/src/app/modules/home/components/widget/config/target-device.component.html +++ b/ui-ngx/src/app/modules/home/components/widget/config/target-device.component.html @@ -23,16 +23,21 @@ {{ 'entity.entity-alias' | translate }}
- - - - + @if (targetDeviceFormGroup.get('type').value === targetDeviceType.device) { + + + } + @if (targetDeviceFormGroup.get('type').value === targetDeviceType.entity) { + + + }
diff --git a/ui-ngx/src/app/modules/home/components/widget/config/target-device.component.ts b/ui-ngx/src/app/modules/home/components/widget/config/target-device.component.ts index bcabcf9099..8336cf338a 100644 --- a/ui-ngx/src/app/modules/home/components/widget/config/target-device.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/config/target-device.component.ts @@ -33,6 +33,7 @@ import { EntityType } from '@shared/models/entity-type.models'; import { IAliasController } from '@core/api/widget-api.models'; import { EntityAliasSelectCallbacks } from '@home/components/widget/lib/settings/common/alias/entity-alias-select.component.models'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; +import { MatFormFieldAppearance } from '@angular/material/form-field'; @Component({ selector: 'tb-target-device', @@ -73,6 +74,9 @@ export class TargetDeviceComponent implements ControlValueAccessor, OnInit, Vali @Input() disabled: boolean; + @Input() + appearance: MatFormFieldAppearance = 'fill'; + widgetEditMode = this.utils.widgetEditMode; targetDeviceFormGroup: UntypedFormGroup; diff --git a/ui-ngx/src/app/modules/home/components/widget/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/api-usage-widget.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/cards/api-usage-widget.component.ts index b41f5ed02a..97efac2cc8 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/cards/api-usage-widget.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/cards/api-usage-widget.component.ts @@ -104,12 +104,16 @@ export class ApiUsageWidgetComponent implements OnInit, OnDestroy { }; this.ctx.subscriptionApi.createSubscription(apiUsageSubscriptionOptions, true).subscribe(); - this.currentState = this.ctx.stateController.getStateId(); - this.ctx.stateController.stateId().subscribe((state) => { - this.ctx.updateParamsFromData(true); - this.currentState = state; - this.cd.markForCheck(); - }); + const simulated = this.ctx.utilsService.widgetEditMode || this.ctx.isPreview; + + if (!simulated) { + this.currentState = this.ctx.stateController.getStateId(); + this.ctx.stateController.stateId().subscribe((state) => { + this.ctx.updateParamsFromData(true); + this.currentState = state; + this.cd.markForCheck(); + }); + } this.backgroundStyle$ = backgroundStyle(this.settings.background, this.imagePipe, this.sanitizer); this.overlayStyle = overlayStyle(this.settings.background.overlay); this.padding = this.settings.background.overlay.enabled ? undefined : this.settings.padding; @@ -121,7 +125,7 @@ export class ApiUsageWidgetComponent implements OnInit, OnDestroy { updateState($event: MouseEvent, stateName: string) { $event?.preventDefault(); - if (stateName?.length) { + if (stateName?.length && this.ctx.stateController) { this.ctx.stateController.updateState(stateName, this.ctx.stateController.getStateParams(), this.ctx.isMobile); } } diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/cards/label-card-widget.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/cards/label-card-widget.component.html index c5084fd21a..fd56942e6c 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/cards/label-card-widget.component.html +++ b/ui-ngx/src/app/modules/home/components/widget/lib/cards/label-card-widget.component.html @@ -23,7 +23,9 @@
- {{ icon }} + @if (showIcon) { + {{ icon }} + }
diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/cards/label-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 0287b775c3..36ba031c8f 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 @@ -114,6 +114,7 @@ export class BarChartWithLabelsWidgetComponent implements OnInit, OnDestroy, Aft ngAfterViewInit() { const settings = barChartWithLabelsTimeSeriesSettings(this.settings); this.timeSeriesChart = new TbTimeSeriesChart(this.ctx, settings, this.chartShape.nativeElement, this.renderer); + this.ctx.widgetActions = this.timeSeriesChart.getWidgetActions(); } ngOnDestroy() { diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/chart/bar-chart-with-labels-widget.models.ts b/ui-ngx/src/app/modules/home/components/widget/lib/chart/bar-chart-with-labels-widget.models.ts index b8b879bf70..b045396f2c 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/chart/bar-chart-with-labels-widget.models.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/chart/bar-chart-with-labels-widget.models.ts @@ -53,6 +53,7 @@ import { export interface BarChartWithLabelsWidgetSettings extends TimeSeriesChartTooltipWidgetSettings { dataZoom: boolean; + dataZoomUpdateTimewindow: boolean; showBarLabel: boolean; barLabelFont: Font; barLabelColor: string; @@ -79,6 +80,7 @@ export interface BarChartWithLabelsWidgetSettings extends TimeSeriesChartTooltip export const barChartWithLabelsDefaultSettings: BarChartWithLabelsWidgetSettings = { dataZoom: false, + dataZoomUpdateTimewindow: false, showBarLabel: true, barLabelFont: { family: 'Roboto', @@ -181,6 +183,7 @@ export const barChartWithLabelsDefaultSettings: BarChartWithLabelsWidgetSettings export const barChartWithLabelsTimeSeriesSettings = (settings: BarChartWithLabelsWidgetSettings): DeepPartial => ({ dataZoom: settings.dataZoom, + dataZoomUpdateTimewindow: settings.dataZoomUpdateTimewindow, grid: settings.grid, yAxes: { default: settings.yAxis diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/chart/echarts-widget.models.ts b/ui-ngx/src/app/modules/home/components/widget/lib/chart/echarts-widget.models.ts index 04f3b5d49b..ad57250036 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/chart/echarts-widget.models.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/chart/echarts-widget.models.ts @@ -262,3 +262,9 @@ export const getFocusedSeriesIndex = (chart: ECharts): number => { } return -1; }; + +export interface DataZoomEvent { + start: number; + end: number; + batch?: Array<{ start: number; end: number }>; +} 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/time-series-chart-tooltip.models.ts b/ui-ngx/src/app/modules/home/components/widget/lib/chart/time-series-chart-tooltip.models.ts index fe8c51bcee..91ce230539 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/chart/time-series-chart-tooltip.models.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/chart/time-series-chart-tooltip.models.ts @@ -293,7 +293,9 @@ private static mapTooltipParams(params: CallbackDataParams[] | CallbackDataParam TimeSeriesChartTooltip.appendTooltipItem(result, seriesParams, series); } else if (Array.isArray(params)) { for (seriesParams of params) { - TimeSeriesChartTooltip.appendTooltipItem(result, seriesParams, series); + if (seriesParams.value) { + TimeSeriesChartTooltip.appendTooltipItem(result, seriesParams, series); + } } } return result; diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/chart/time-series-chart-widget.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/chart/time-series-chart-widget.component.html index a7d1d6eb6e..7ae4145eb6 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/chart/time-series-chart-widget.component.html +++ b/ui-ngx/src/app/modules/home/components/widget/lib/chart/time-series-chart-widget.component.html @@ -28,83 +28,107 @@
-
- - - - - -
+ @if (showLegend) { +
+ @if (displayLegendValues) { + @if (horizontalLegendPosition) { + + } @else { + + + + + @if (legendConfig.showMin === true) { + + } + @if (legendConfig.showMax === true) { + + } + @if (legendConfig.showAvg === true) { + + } + @if (legendConfig.showTotal === true) { + + } + @if (legendConfig.showLatest === true) { + + } + + + + @for (legendKey of legendKeys; track legendKey) { + + + @if (legendConfig.showMin === true) { + + } + @if (legendConfig.showMax === true) { + + } + @if (legendConfig.showAvg === true) { + + } + @if (legendConfig.showTotal === true) { + + } + @if (legendConfig.showLatest === true) { + + } + + } + +
{{ 'legend.Min' | translate }}{{ 'legend.Max' | translate }}{{ 'legend.Avg' | translate }}{{ 'legend.Total' | translate }}{{ 'legend.Latest' | translate }}
+ + + + + + +
+ } + } @else { + @for (legendKey of legendKeys; track legendKey) { + + } + } +
+ } - - - - - - - - - - - - - - - - - - - - - - -
- -
-
- - - - - - - - - - - + + + @for (legendKey of legendKeys; track legendKey) { + + } + - - - - - - - - + @if (legendConfig.showMin === true) { + + } + @if (legendConfig.showMax === true) { + + } + @if (legendConfig.showAvg === true) { + + } + @if (legendConfig.showTotal === true) { + + } + @if (legendConfig.showLatest === true) { + + }
{{ 'legend.Min' | translate }}{{ 'legend.Max' | translate }}{{ 'legend.Avg' | translate }}{{ 'legend.Total' | translate }}{{ 'legend.Latest' | translate }}
+ +
- - - - - - -
- - - - -
@@ -120,9 +144,11 @@ {{ label | translate }} - - + @for (legendKey of legendKeys; track legendKey) { + + + }
diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/chart/time-series-chart-widget.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/chart/time-series-chart-widget.component.ts index aa94ee717c..dc9ddf7cb8 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/chart/time-series-chart-widget.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/chart/time-series-chart-widget.component.ts @@ -137,6 +137,7 @@ export class TimeSeriesChartWidgetComponent implements OnInit, OnDestroy, AfterV ngAfterViewInit() { this.timeSeriesChart = new TbTimeSeriesChart(this.ctx, this.settings, this.chartShape.nativeElement, this.renderer); + this.ctx.widgetActions = this.timeSeriesChart.getWidgetActions(); } ngOnDestroy() { 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 e815eefe11..902e2189e2 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 @@ -699,6 +699,7 @@ export interface TimeSeriesChartSettings extends TimeSeriesChartTooltipWidgetSet thresholds: TimeSeriesChartThreshold[]; darkMode: boolean; dataZoom: boolean; + dataZoomUpdateTimewindow: boolean; stack: boolean; grid: TimeSeriesChartGridSettings; yAxes: TimeSeriesChartYAxes; @@ -714,6 +715,7 @@ export const timeSeriesChartDefaultSettings: TimeSeriesChartSettings = { thresholds: [], darkMode: false, dataZoom: true, + dataZoomUpdateTimewindow: false, stack: false, grid: mergeDeep({} as TimeSeriesChartGridSettings, timeSeriesChartGridDefaultSettings), 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 d1928d75f0..7f9f144b87 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 @@ -14,7 +14,7 @@ /// limitations under the License. /// -import { WidgetContext } from '@home/models/widget-component.models'; +import { WidgetAction, WidgetContext } from '@home/models/widget-component.models'; import { adjustTimeAxisExtentToData, calculateThresholdsOffset, @@ -23,7 +23,8 @@ import { createTimeSeriesYAxis, defaultTimeSeriesChartYAxisSettings, generateChartData, - LineSeriesStepType, normalizeAxisLimit, + LineSeriesStepType, + normalizeAxisLimit, parseThresholdData, TimeSeriesChartAxis, TimeSeriesChartDataItem, @@ -47,9 +48,11 @@ import { } from '@home/components/widget/lib/chart/time-series-chart.models'; import { calculateAxisSize, + DataZoomEvent, ECharts, echartsModule, EChartsOption, + getAxis, getAxisExtent, getFocusedSeriesIndex, measureAxisNameSize @@ -141,6 +144,13 @@ export class TbTimeSeriesChart { private hasVisualMap = false; private visualMapSelectedRanges: {[key: number]: boolean}; + private dataZoomResetting = false; + private dataZoomUpdatePending = false; + private dataZoomDebounce: ReturnType | null = null; + private lastDataZoomStart = 0; + private lastDataZoomEnd = 100; + private timewindowChanged = false; + private timeSeriesChart: ECharts; private timeSeriesChartOptions: EChartsOption; @@ -265,6 +275,12 @@ export class TbTimeSeriesChart { if (this.highlightedDataKey) { this.keyEnter(this.highlightedDataKey); } + if (this.dataZoomUpdatePending) { + this.dataZoomUpdatePending = false; + this.dataZoomResetting = true; + this.timeSeriesChart.dispatchAction({ type: 'dataZoom', start: 0, end: 100 }); + this.dataZoomResetting = false; + } } } @@ -386,6 +402,10 @@ export class TbTimeSeriesChart { } public destroy(): void { + if (this.dataZoomDebounce !== null) { + clearTimeout(this.dataZoomDebounce); + this.dataZoomDebounce = null; + } if (this.shapeResize$) { this.shapeResize$.disconnect(); } @@ -417,6 +437,25 @@ export class TbTimeSeriesChart { return this.darkMode; } + public getWidgetActions(): WidgetAction[] { + if (this.settings.dataZoom && this.settings.dataZoomUpdateTimewindow) { + return [{ + name: 'widgets.time-series-chart.reset-timewindow', + icon: 'restore', + onAction: () => { + if (this.dataZoomDebounce !== null) { + clearTimeout(this.dataZoomDebounce); + this.dataZoomDebounce = null; + } + this.timewindowChanged = false; + this.ctx.defaultSubscription.onResetTimewindow(); + }, + show: ()=> this.timewindowChanged + }]; + } + return []; + } + private setupData(): void { const noAggregationBarWidthSettings = this.settings.noAggregationBarWidthSettings; const targetBarWidth = noAggregationBarWidthSettings.strategy === TimeSeriesChartNoAggregationBarWidthStrategy.group ? @@ -846,8 +885,39 @@ export class TbTimeSeriesChart { this.updateAxes(false); if (this.settings.dataZoom) { - this.timeSeriesChart.on('datazoom', () => { + this.timeSeriesChart.on('datazoom', (event: DataZoomEvent) => { this.updateAxes(); + if (this.settings.dataZoomUpdateTimewindow && !this.dataZoomResetting) { + const evStart = event.batch?.length ? event.batch[0].start : event.start; + const evEnd = event.batch?.length ? event.batch[0].end : event.end; + this.lastDataZoomStart = evStart; + this.lastDataZoomEnd = evEnd; + if (this.dataZoomDebounce !== null) { + clearTimeout(this.dataZoomDebounce); + } + this.dataZoomDebounce = setTimeout(() => { + this.dataZoomDebounce = null; + if (this.timeSeriesChart?.isDisposed()) { + return; + } + if (Math.round(this.lastDataZoomStart) === 0 && Math.round(this.lastDataZoomEnd) === 100) { + this.ctx.defaultSubscription.onResetTimewindow(); + this.timewindowChanged = false; + } else { + const axis = getAxis(this.timeSeriesChart, 'xAxis', 'main'); + if (axis) { + const extent = axis.scale.getExtent(); + const startMs = Math.round(extent[0]); + const endMs = Math.round(extent[1]); + if (startMs < endMs) { + this.dataZoomUpdatePending = true; + this.timewindowChanged = true; + this.ctx.defaultSubscription.onUpdateTimewindow(startMs, endMs); + } + } + } + }, 500); + } }); } } 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..ec0071243b 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/date-range-navigator/date-range-navigator-panel.component.html +++ b/ui-ngx/src/app/modules/home/components/widget/lib/date-range-navigator/date-range-navigator-panel.component.html @@ -16,20 +16,24 @@ -->
- - - + @if (settings.showTemplate) { + + + + } -
- - -
+ @if (!settings.autoConfirm) { +
+ + +
+ }
diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/date-range-navigator/date-range-navigator.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/date-range-navigator/date-range-navigator.component.html index f5f43a5346..5cdc878b57 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/date-range-navigator/date-range-navigator.component.html +++ b/ui-ngx/src/app/modules/home/components/widget/lib/date-range-navigator/date-range-navigator.component.html @@ -19,23 +19,31 @@ [class.short-mode]="ctx.width < 400" [class.labels-hidden]="settings.hideLabels" [class.long-mode]="ctx.width >= 400"> - - widgets.date-range-navigator.localizationMap.Date picker + + @if (!settings.hideLabels) { + widgets.date-range-navigator.localizationMap.Date picker + } {{advancedModel.chosenLabel}} - - widgets.date-range-navigator.localizationMap.Interval + + @if (!settings.hideLabels) { + widgets.date-range-navigator.localizationMap.Interval + } - - {{'widgets.date-range-navigator.localizationMap.' + customInterval.label | translate}} - - - {{'widgets.date-range-navigator.localizationMap.' + date.value.label | translate}} - + @if (customInterval) { + + {{'widgets.date-range-navigator.localizationMap.' + customInterval.label | translate}} + + } + @for (date of datesMap | keyvalue: originalOrder; track date) { + + {{'widgets.date-range-navigator.localizationMap.' + date.value.label | translate}} + + } @@ -43,12 +51,16 @@ - - widgets.date-range-navigator.localizationMap.Step size + + @if (!settings.hideLabels) { + widgets.date-range-navigator.localizationMap.Step size + } - - {{'widgets.date-range-navigator.localizationMap.' + date.value.label | translate}} - + @for (date of datesMap | keyvalue: originalOrder; track date) { + + {{'widgets.date-range-navigator.localizationMap.' + date.value.label | translate}} + + } - +@if (addMode || editMode) { +
+ + @if (editMode) { +
+ + +
+ }
-
- - -
-
- +} @else {
- +} diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/home-page/doc-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 7428f076ed..5dad73f09f 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..beb03851a3 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/home-page/quick-link.component.html +++ b/ui-ngx/src/app/modules/home/components/widget/lib/home-page/quick-link.component.html @@ -15,52 +15,69 @@ limitations under the License. --> -
-
-
-
-
- - widgets.version-info.available-version - widgets.version-info.available - + @if (updateMessage?.updateAvailable) { +
+ -
{{ updateMessage?.latestVersion }}
- -
- {{ 'widgets.version-info.upgrade' | translate }} + } @else { +
+
check
+
widgets.version-info.version-is-up-to-date
-
+ }
- -
-
check
-
widgets.version-info.version-is-up-to-date
-
-
diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/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/map-layer.ts b/ui-ngx/src/app/modules/home/components/widget/lib/maps/map-layer.ts index 4bc2dbf231..3c1ef92451 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/maps/map-layer.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/maps/map-layer.ts @@ -20,12 +20,15 @@ import { defaultGoogleMapLayerSettings, defaultHereMapLayerSettings, defaultLayerTitle, + defaultOpenFreeMapLayerSettings, defaultOpenStreetMapLayerSettings, defaultTencentMapLayerSettings, GoogleMapLayerSettings, HereMapLayerSettings, MapLayerSettings, MapProvider, + OpenFreeMapLayerSettings, + OpenFreeMapStyleType, OpenStreetMapLayerSettings, ReferenceLayerType, TencentMapLayerSettings, WEBGL_ERROR_EVENT @@ -61,6 +64,8 @@ export abstract class TbMapLayer { inputSettings: DeepPartial) { switch (inputSettings.provider) { + case MapProvider.openfreemap: + return new TbOpenFreeMapLayer(ctx, inputSettings); case MapProvider.openstreet: return new TbOpenStreetMapLayer(ctx, inputSettings); case MapProvider.google: @@ -226,6 +231,32 @@ class TbOpenStreetMapLayer extends TbMapLayer { } +const openFreeMapStyleUrl = (style: OpenFreeMapStyleType): string => + `https://tiles.openfreemap.org/styles/${style}`; + +class TbOpenFreeMapLayer extends TbMapLayer { + + constructor(protected ctx: WidgetContext, + protected inputSettings: DeepPartial) { + super(ctx, inputSettings); + } + + protected defaultSettings(): OpenFreeMapLayerSettings { + return defaultOpenFreeMapLayerSettings; + } + + protected createLayer(): Observable { + const styleUrl = openFreeMapStyleUrl(this.settings.layerType); + const layer = L.TB.MapLibreGL.mapLibreGLLayer({ + style: styleUrl, + attributionControl: { + customAttribution: 'OpenFreeMap, © OpenMapTiles, Data from OpenStreetMap' + } + }); + return of(layer); + } +} + class TbGoogleMapLayer extends TbMapLayer { static loadedApiKeysGlobal: {[key: string]: boolean} = {}; @@ -325,7 +356,20 @@ class TbCustomMapLayer extends TbMapLayer { } protected createLayer(): Observable { - const layer = L.tileLayer(this.settings.tileUrl); + if (this.settings.vectorTiles) { + const options: any = { + style: this.settings.tileUrl + }; + if (this.settings.customAttribution) { + options.attributionControl = { + customAttribution: this.settings.customAttribution + }; + } + return of(L.TB.MapLibreGL.mapLibreGLLayer(options)); + } + const layer = L.tileLayer(this.settings.tileUrl, this.settings.customAttribution ? { + attribution: this.settings.customAttribution + } : undefined); return of(layer); } diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/maps/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..f98ef7e8c8 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/rpc/persistent-add-dialog.component.html +++ b/ui-ngx/src/app/modules/home/components/widget/lib/rpc/persistent-add-dialog.component.html @@ -25,9 +25,12 @@ close - - -
+ @if (isLoading$ | async) { + + + } @else { +
+ }
@@ -35,18 +38,22 @@ {{ rpcMessageTypeText }}
-
- +
+ widgets.persistent-table.method - - {{'widgets.persistent-table.method-error' | translate}} - - - {{'widgets.persistent-table.white-space-error' | translate}} - + @if (persistentFormGroup.get('method').hasError('required')) { + + {{'widgets.persistent-table.method-error' | translate}} + + } + @if (persistentFormGroup.get('method').hasError('pattern')) { + + {{'widgets.persistent-table.white-space-error' | translate}} + + } - + widgets.persistent-table.retries @@ -71,7 +78,9 @@
-
+ @if (!(isLoading$ | async)) { +
+ }
- - -
+ @if (isLoading$ | async) { + + + } @else { +
+ }
- + widgets.persistent-table.created-time - + widgets.persistent-table.expiration-time
- + widgets.persistent-table.message-type - + widgets.persistent-table.status - + widgets.persistent-table.method - - widgets.persistent-table.retries - - + @if (persistentFormGroup.get('retries').value) { + + widgets.persistent-table.retries + + + }
@@ -69,7 +73,7 @@ {{ 'widgets.persistent-table.response' | translate }} - + @@ -98,14 +102,15 @@
- + @if (allowDelete) { + + }
- -
-
- - - - - - + }
+ @if (actionCellButtonAction.length) { +
+ + + @for (actionDescriptor of actionCellButtonAction; track actionDescriptor) { + @if (actionDescriptor.icon) { + + } + } + +
+ } + [class.!hidden]="persistentDatasource.dataLoading"> {{ noDataDisplayMessageText }} + class="no-data-found flex items-center justify-center">{{ noDataDisplayMessageText }} {{ 'common.loading' | translate }} -
- - + class="no-data-found flex items-center justify-center">{{ 'common.loading' | translate }} +
+ @if (displayPagination) { + + } + @if (displayPagination) { + + }
diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/rpc/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 }} -
widgets.dashboard-state.dashboard-state-settings - + - + @if (dashboardStateWidgetSettingsForm.get('stateId').value) { + + } - - - + @for (state of filteredDashboardStates | async; track state) { + + + + } @@ -47,12 +51,13 @@ {{ 'widgets.dashboard-state.autofill-state-layout' | translate }} - + widgets.dashboard-state.default-margin diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/cards/label-widget-label.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/settings/cards/label-widget-label.component.html index ae477eeb6d..dc5bd4eab3 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/cards/label-widget-label.component.html +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/cards/label-widget-label.component.html @@ -24,35 +24,39 @@
- + @if (!disabled) { + + }
- + widgets.label-widget.label-pattern - - {{ 'widgets.label-widget.label-pattern-required' | translate }} - + @if (labelWidgetLabelFormGroup.get('pattern').hasError('required')) { + + {{ 'widgets.label-widget.label-pattern-required' | translate }} + + }
widgets.label-widget.label-position -
- +
+ widgets.label-widget.x-pos - + widgets.label-widget.y-pos @@ -60,6 +64,7 @@
diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/cards/label-widget-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.use-qr-code-text-function' | translate }} - + widgets.qr-code.qr-code-text-pattern {{ 'widgets.qr-code.qr-code-text-pattern-hint' | translate }} - - {{ 'widgets.qr-code.qr-code-text-pattern-required' | translate }} - + @if (qrCodeWidgetSettingsForm.get('qrCodeTextPattern').hasError('required')) { + + {{ 'widgets.qr-code.qr-code-text-pattern-required' | translate }} + + } - - warning - + class="tb-error"> + warning + + }
widgets.table.page-step-count
- - warning - + class="tb-error"> + warning + + }
@@ -103,9 +105,11 @@
widgets.table.default-page-size
- - {{ size }} - + @for (size of pageStepSizeValues; track size) { + + {{ size }} + + }
@@ -115,23 +119,23 @@ {{ 'widgets.table.use-entity-label-tab-name' | translate }} -
-
widgets.table.sort-by
-
- - - {{ entityFields.createdTime.name | translate }} - {{ entityFields.name.name | translate }} - - - - - {{ 'common.sort-asc' | translate }} - {{ 'common.sort-desc' | translate }} - - -
-
+
+
widgets.table.sort-by
+
+ + + {{ entityFields.createdTime.name | translate }} + {{ entityFields.name.name | translate }} + + + + + {{ 'common.sort-asc' | translate }} + {{ 'common.sort-desc' | translate }} + + +
+
widgets.table.rows
diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/cards/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..793921b4dc 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 @@ -23,8 +23,13 @@ {{ 'widgets.time-series-chart.data-zoom' | translate }}
+
+ + {{ 'widgets.time-series-chart.data-zoom-update-timewindow' | translate }} + +
+ formControlName="grid">
widgets.bar-chart.bar-appearance
@@ -78,43 +83,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 +135,11 @@
{{ 'legend.position' | translate }}
- - {{ legendPositionTranslationMap.get(pos) | translate }} - + @for (pos of legendPositions; track pos) { + + {{ legendPositionTranslationMap.get(pos) | translate }} + + }
@@ -193,7 +200,7 @@
+ formControlName="tooltipDateFormat"> @@ -229,7 +236,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 55ed3d5035..9cb686cf78 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 @@ -78,6 +78,7 @@ export class BarChartWithLabelsWidgetSettingsComponent extends WidgetSettingsCom this.barChartWidgetSettingsForm = this.fb.group({ dataZoom: [settings.dataZoom, []], + dataZoomUpdateTimewindow: [settings.dataZoomUpdateTimewindow, []], showBarLabel: [settings.showBarLabel, []], barLabelFont: [settings.barLabelFont, []], @@ -130,10 +131,11 @@ export class BarChartWithLabelsWidgetSettingsComponent extends WidgetSettingsCom } protected validatorTriggers(): string[] { - return ['showBarLabel', 'showBarValue', 'showBarBorder', 'showLegend', 'showTooltip', 'tooltipShowDate']; + return ['dataZoom', 'showBarLabel', 'showBarValue', 'showBarBorder', 'showLegend', 'showTooltip', 'tooltipShowDate']; } protected updateValidators(emitEvent: boolean) { + const dataZoom: boolean = this.barChartWidgetSettingsForm.get('dataZoom').value; const showBarLabel: boolean = this.barChartWidgetSettingsForm.get('showBarLabel').value; const showBarValue: boolean = this.barChartWidgetSettingsForm.get('showBarValue').value; const showBarBorder: boolean = this.barChartWidgetSettingsForm.get('showBarBorder').value; @@ -141,6 +143,12 @@ export class BarChartWithLabelsWidgetSettingsComponent extends WidgetSettingsCom const showTooltip: boolean = this.barChartWidgetSettingsForm.get('showTooltip').value; const tooltipShowDate: boolean = this.barChartWidgetSettingsForm.get('tooltipShowDate').value; + if (dataZoom) { + this.barChartWidgetSettingsForm.get('dataZoomUpdateTimewindow').enable(); + } else { + this.barChartWidgetSettingsForm.get('dataZoomUpdateTimewindow').disable(); + } + if (showBarLabel) { this.barChartWidgetSettingsForm.get('barLabelFont').enable(); this.barChartWidgetSettingsForm.get('barLabelColor').enable(); 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/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..c51bc07eb1 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
@@ -85,8 +86,13 @@ {{ 'widgets.time-series-chart.data-zoom' | translate }}
+
+ + {{ 'widgets.time-series-chart.data-zoom-update-timewindow' | translate }} + +
+ formControlName="grid">
@@ -98,15 +104,16 @@
widgets.time-series-chart.axis.x-axis
+ formControlName="xAxis" + advanced + axisType="xAxis">
- - + @if (chartType === TimeSeriesChartType.default || chartType === TimeSeriesChartType.bar) { + + + }
@@ -178,9 +185,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 +217,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 +229,7 @@
+ formControlName="tooltipDateFormat"> diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/chart/time-series-chart-widget-settings.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/settings/chart/time-series-chart-widget-settings.component.ts index 8a96f31445..f20320a773 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/chart/time-series-chart-widget-settings.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/chart/time-series-chart-widget-settings.component.ts @@ -135,6 +135,7 @@ export class TimeSeriesChartWidgetSettingsComponent extends WidgetSettingsCompon thresholds: [settings.thresholds, []], dataZoom: [settings.dataZoom, []], + dataZoomUpdateTimewindow: [settings.dataZoomUpdateTimewindow, []], stack: [settings.stack, []], grid: [settings.grid, []], @@ -181,16 +182,23 @@ export class TimeSeriesChartWidgetSettingsComponent extends WidgetSettingsCompon } protected validatorTriggers(): string[] { - return ['comparisonEnabled', 'showLegend', 'showTooltip', 'tooltipShowDate', 'stack']; + return ['comparisonEnabled', 'dataZoom', 'showLegend', 'showTooltip', 'tooltipShowDate', 'stack']; } protected updateValidators(emitEvent: boolean) { const comparisonEnabled: boolean = this.timeSeriesChartWidgetSettingsForm.get('comparisonEnabled').value; + const dataZoom: boolean = this.timeSeriesChartWidgetSettingsForm.get('dataZoom').value; const showLegend: boolean = this.timeSeriesChartWidgetSettingsForm.get('showLegend').value; const showTooltip: boolean = this.timeSeriesChartWidgetSettingsForm.get('showTooltip').value; const tooltipShowDate: boolean = this.timeSeriesChartWidgetSettingsForm.get('tooltipShowDate').value; const stack: boolean = this.timeSeriesChartWidgetSettingsForm.get('stack').value; + if (dataZoom) { + this.timeSeriesChartWidgetSettingsForm.get('dataZoomUpdateTimewindow').enable(); + } else { + this.timeSeriesChartWidgetSettingsForm.get('dataZoomUpdateTimewindow').disable(); + } + if (comparisonEnabled) { this.timeSeriesChartWidgetSettingsForm.get('timeForComparison').enable(); this.timeSeriesChartWidgetSettingsForm.get('comparisonCustomIntervalValue').enable(); 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/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-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-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.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 334ace9886..9a26856883 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/dynamic-form/dynamic-form-property-panel.component.html +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/dynamic-form/dynamic-form-property-panel.component.html @@ -46,42 +46,50 @@
dynamic-form.property.type
- - {{ formPropertyTypeTranslations.get(type) | translate }} - + @for (type of formPropertyTypes; track type) { + + {{ formPropertyTypeTranslations.get(type) | translate }} + + }
-
-
dynamic-form.property.array-item
-
-
dynamic-form.property.item-type
- - - - {{ formPropertyTypeTranslations.get(type) | translate }} - - - -
-
-
dynamic-form.property.item-name
- - - + @if (isArray) { +
+
dynamic-form.property.array-item
+
+
dynamic-form.property.item-type
+ + + @for (type of arrayItemFormPropertyTypes; track type) { + + {{ formPropertyTypeTranslations.get(type) | translate }} + + } + + +
+
+
dynamic-form.property.item-name
+ + + +
+ +
+ } + @if (!isArray) { -
- - - - -
- - {{ 'dynamic-form.property.value-required' | translate }} - -
+ } + @if (![FormPropertyType.fieldset, FormPropertyType.htmlSection, FormPropertyType.array].includes(propertyFormGroup.get('type').value)) { +
+ + {{ 'dynamic-form.property.value-required' | translate }} + +
+ }
@@ -90,7 +98,7 @@ - + @if (isPropertyTypeAllowedForRow(propertyFormGroup.get('type').value)) {
dynamic-form.property.sub-label
-
-
-
dynamic-form.property.input-field-suffix
- - - -
-
-
dynamic-form.property.disable-on-property
- - - {{ 'dynamic-form.property.disable-on-property-none' | translate }} - - {{ prop }} - - - -
+ } + @if ([FormPropertyType.number, FormPropertyType.text, FormPropertyType.password].includes(propertyItemType)) { +
+
dynamic-form.property.input-field-suffix
+ + + +
+ } + @if (propertyFormGroup.get('type').value !== FormPropertyType.htmlSection) { +
+
dynamic-form.property.disable-on-property
+ + + {{ 'dynamic-form.property.disable-on-property-none' | translate }} + @for (prop of booleanPropertyIds; track prop) { + + {{ prop }} + + } + + +
+ }
- - - - + @if (isPropertyTypeAllowedForRow(propertyFormGroup.get('type').value)) { + + + } + @if (isPropertyTypeAllowedForRow(propertyFormGroup.get('type').value) && FormPropertyType.switch !== propertyFormGroup.get('type').value) { + + + }
@@ -164,154 +182,174 @@ (click)="cancel()"> {{ (disabled ? 'action.close' : 'action.cancel') | translate }} - + @if (!disabled) { + + }
-
-
dynamic-form.property.textarea-rows
- - - -
-
-
dynamic-form.property.number-settings
-
-
dynamic-form.property.min
- - - - -
dynamic-form.property.max
- - - - -
dynamic-form.property.step
- - +
dynamic-form.property.textarea-rows
+ +
-
-
- - - - {{ 'dynamic-form.property.properties' | translate }} - - - - - - - -
-
- - - - {{ 'dynamic-form.property.html-section-settings' | translate }} - - - -
- - -
- - -
-
-
- + } + @if (propertyItemType === FormPropertyType.number) { +
+
dynamic-form.property.number-settings
+
+
dynamic-form.property.min
+ + + + +
dynamic-form.property.max
+ + + + +
dynamic-form.property.step
+ + + +
+
+ } + @if (propertyItemType === FormPropertyType.fieldset) {
- {{ (propertyFormGroup.get('type').value === FormPropertyType.select ? 'dynamic-form.property.select-options' : 'dynamic-form.property.radio-button-options') | translate }} + {{ 'dynamic-form.property.properties' | translate }} -
-
dynamic-form.property.buttons-direction
- - {{ 'dynamic-form.property.direction-column' | translate }} - {{ 'dynamic-form.property.direction-row' | translate }} - -
-
- - {{ 'dynamic-form.property.enable-multiple-select' | translate }} - -
-
- - {{ 'dynamic-form.property.allow-empty-select-option' | translate }} - + + + + +
+ } + @if (propertyItemType === FormPropertyType.htmlSection) { +
+ + + + {{ 'dynamic-form.property.html-section-settings' | translate }} + + + +
+ +
-
-
dynamic-form.property.selected-options-limit
-
-
dynamic-form.property.min
- - - - -
dynamic-form.property.max
- - - + + + + +
+ } + @if ([FormPropertyType.select, FormPropertyType.radios].includes(propertyItemType)) { +
+ + + + {{ (propertyFormGroup.get('type').value === FormPropertyType.select ? 'dynamic-form.property.select-options' : 'dynamic-form.property.radio-button-options') | translate }} + + + + @if (propertyItemType === FormPropertyType.radios) { +
+
dynamic-form.property.buttons-direction
+ + {{ 'dynamic-form.property.direction-column' | translate }} + {{ 'dynamic-form.property.direction-row' | translate }} +
-
+ } + @if (propertyItemType === FormPropertyType.select) { +
+ + {{ 'dynamic-form.property.enable-multiple-select' | translate }} + +
+ } + @if (propertyItemType === FormPropertyType.select && !propertyFormGroup.get('multiple').value) { +
+ + {{ 'dynamic-form.property.allow-empty-select-option' | translate }} + +
+ } + @if (propertyItemType === FormPropertyType.select && propertyFormGroup.get('multiple').value) { +
+
dynamic-form.property.selected-options-limit
+
+
dynamic-form.property.min
+ + + + +
dynamic-form.property.max
+ + + +
+
+ }
- -
-
dynamic-form.property.help-id
- - - -
- + } + @if ([FormPropertyType.javascript, FormPropertyType.markdown].includes(propertyItemType)) { +
+
dynamic-form.property.help-id
+ + + +
+ } + @if (propertyItemType === FormPropertyType.datetime) {
dynamic-form.property.datetime-type
{{ 'dynamic-form.property.datetime-type-date' | translate }} + value="date">{{ 'dynamic-form.property.datetime-type-date' | translate }} {{ 'dynamic-form.property.datetime-type-time' | translate }} + value="time">{{ 'dynamic-form.property.datetime-type-time' | translate }} {{ 'dynamic-form.property.datetime-type-datetime' | translate }} + value="datetime">{{ 'dynamic-form.property.datetime-type-datetime' | translate }}
@@ -319,165 +357,221 @@ {{ 'dynamic-form.property.enable-clear-button' | translate }}
-
-
- - dynamic-form.property.default-value - - -
-
- - -
-
- - -
-
- - -
-
- - -
-
- - -
-
- - -
-
-
dynamic-form.property.default-value
- - -
{{ item.label | customTranslate }}
-
-
-
-
- - {{ 'dynamic-form.property.support-unit-conversion' | translate }} - -
-
-
dynamic-form.property.default-value
- - - - - - - - - - - - - - - - - - - - - {{ item.label | customTranslate }} - - - - - {{ item.label | customTranslate }} - - - - - - - - -
+ } + @if (propertyItemType === FormPropertyType.textarea) { +
+ + dynamic-form.property.default-value + + +
+ } + @if (propertyItemType === FormPropertyType.javascript) { +
+ + +
+ } + @if (propertyItemType === FormPropertyType.json) { +
+ + +
+ } + @if (propertyItemType === FormPropertyType.html) { +
+ + +
+ } + @if (propertyItemType === FormPropertyType.css) { +
+ + +
+ } + @if (propertyItemType === FormPropertyType.markdown) { +
+ + +
+ } + @if (propertyItemType === FormPropertyType.image && !isArray) { +
+ + +
+ } + @if (propertyItemType === FormPropertyType.radios) { +
+
dynamic-form.property.default-value
+ + @for (item of propertyFormGroup.get('items').value; track item) { + +
{{ item.label | customTranslate }}
+
+ } +
+
+ } + @if (propertyItemType === FormPropertyType.units) { +
+ + {{ 'dynamic-form.property.support-unit-conversion' | translate }} + +
+ } + @if (isPropertyTypeAllowedForRow(propertyItemType)) { +
+
dynamic-form.property.default-value
+ @if ([FormPropertyType.text, FormPropertyType.password].includes(propertyItemType)) { + + + + } + @if (propertyItemType === FormPropertyType.color) { + + + } + @if (propertyItemType === FormPropertyType.color_settings) { + + + } + @if (propertyItemType === FormPropertyType.number) { + + + + } + @if (propertyItemType === FormPropertyType.font) { + + + } + @if (propertyItemType === FormPropertyType.units) { + + } + @if (propertyItemType === FormPropertyType.switch) { + + } + @if (propertyItemType === FormPropertyType.icon) { + + + } + @if (propertyItemType === FormPropertyType.select) { + + @if (!propertyFormGroup.get('multiple').value) { + + @if (propertyFormGroup.get('allowEmptyOption').value) { + + } + @for (item of propertyFormGroup.get('items').value; track item) { + + {{ item.label | customTranslate }} + + } + + } + @if (propertyFormGroup.get('multiple').value) { + + @for (item of propertyFormGroup.get('items').value; track item) { + + {{ item.label | customTranslate }} + + } + + } + + } + @if (propertyItemType === FormPropertyType.datetime) { + @if (propertyFormGroup.get('dateTimeType').value === 'date') { + + } + @if (propertyFormGroup.get('dateTimeType').value === 'time') { + + } + @if (propertyFormGroup.get('dateTimeType').value === 'datetime') { + + } + } +
+ }
diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/dynamic-form/dynamic-form-property-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..c8bec5a971 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/key/data-key-config.component.html +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/key/data-key-config.component.html @@ -15,185 +15,218 @@ limitations under the License. --> -
-
-
-
datakey.general
- - {{ 'entity.key' | translate }} - - - - - - - - - - datakey.label - - - -
-
widget-config.units-short
- - -
-
-
widget-config.decimals-short
- - - -
-
-
-
{{ 'datakey.color' | translate }}
- - -
-
- datakey.data-generation-func -
- - -
- -
-
-
{{ 'datakey.aggregation' | translate }}
- - - - {{ aggregationTypesTranslations.get(aggregationTypes[aggregation]) | translate }} + close + + } + + @for (key of filteredKeys | async; track key) { + + - - -
-
- {{ dataKeyFormGroup.get('aggregationType').value ? (dataKeyAggregationTypeHintTranslations.get(aggregationTypes[dataKeyFormGroup.get('aggregationType').value]) | translate) : '' }} -
- {{ 'datakey.aggregation-type-hint-common' | translate }} -
-
-
-
-
-
-
datakey.delta-calculation
-
- - - - - {{ 'datakey.enable-delta-calculation' | translate }} - - {{ 'datakey.enable-delta-calculation-hint' | translate }} - - - - + } + + + } + @if (!hideDataKeyLabel) { + + datakey.label + + + } + @if (modelValue.type !== dataKeyTypes.alarm) { + @if (!hideDataKeyUnits) {
-
{{ 'widgets.chart.time-for-comparison' | translate }}
- - - - {{ 'widgets.chart.time-for-comparison-previous-interval' | translate }} - - - {{ 'widgets.chart.time-for-comparison-days' | translate }} - - - {{ 'widgets.chart.time-for-comparison-weeks' | translate }} - - - {{ 'widgets.chart.time-for-comparison-months' | translate }} - - - {{ 'widgets.chart.time-for-comparison-years' | translate }} - - - {{ 'widgets.chart.time-for-comparison-custom-interval' | translate }} - - - +
widget-config.units-short
+ +
-
-
{{ 'widgets.chart.custom-interval-value' | translate }}
- - + } + @if (!hideDataKeyDecimals) { +
+
widget-config.decimals-short
+ +
-
-
{{ 'datakey.delta-calculation-result' | translate }}
+ } + } + @if (!hideDataKeyColor) { +
+
{{ 'datakey.color' | translate }}
+ + +
+ } + @if (modelValue.type === dataKeyTypes.function) { +
+ datakey.data-generation-func +
+ + +
+ } + @if (!hideDataKeyAggregation && widgetType === widgetTypes.latest && modelValue.type === dataKeyTypes.timeseries) { +
+
+
{{ 'datakey.aggregation' | translate }}
- - - {{ comparisonResultTypeTranslations.get(comparisonResultTypes[comparisonResultType]) | translate }} - + + @for (aggregation of aggregations; track aggregation) { + + {{ aggregationTypesTranslations.get(aggregationTypes[aggregation]) | translate }} + + }
- - - +
+ {{ dataKeyFormGroup.get('aggregationType').value ? (dataKeyAggregationTypeHintTranslations.get(aggregationTypes[dataKeyFormGroup.get('aggregationType').value]) | translate) : '' }} + @if (dataKeyFormGroup.get('aggregationType').value !== aggregationTypes.NONE) { +
+ {{ 'datakey.aggregation-type-hint-common' | translate }} +
+ } +
+
+ }
-
-
- - - - - {{ 'datakey.use-data-post-processing-func' | translate }} - - - - - - - - - + @if (dataKeyFormGroup.get('aggregationType').value && dataKeyFormGroup.get('aggregationType').value !== aggregationTypes.NONE) { +
+
datakey.delta-calculation
+
+ + + + + {{ 'datakey.enable-delta-calculation' | translate }} + + {{ 'datakey.enable-delta-calculation-hint' | translate }} + + + + @if (dataKeyFormGroup.get('comparisonEnabled').value) { +
+
{{ 'widgets.chart.time-for-comparison' | translate }}
+ + + + {{ 'widgets.chart.time-for-comparison-previous-interval' | translate }} + + + {{ 'widgets.chart.time-for-comparison-days' | translate }} + + + {{ 'widgets.chart.time-for-comparison-weeks' | translate }} + + + {{ 'widgets.chart.time-for-comparison-months' | translate }} + + + {{ 'widgets.chart.time-for-comparison-years' | translate }} + + + {{ 'widgets.chart.time-for-comparison-custom-interval' | translate }} + + + +
+ @if (dataKeyFormGroup.get('timeForComparison').value === 'customInterval') { +
+
{{ 'widgets.chart.custom-interval-value' | translate }}
+ + + +
+ } +
+
{{ 'datakey.delta-calculation-result' | translate }}
+ + + @for (comparisonResultType of comparisonResults; track comparisonResultType) { + + {{ comparisonResultTypeTranslations.get(comparisonResultTypes[comparisonResultType]) | translate }} + + } + + +
+ } +
+
+
+
+ } + @if ((modelValue.type === dataKeyTypes.timeseries || modelValue.type === dataKeyTypes.attribute || modelValue.type === dataKeyTypes.count) && showPostProcessing) { +
+ + + + + {{ 'datakey.use-data-post-processing-func' | translate }} + + + + + @if (dataKeyFormGroup.get('usePostProcessing').value) { + + + } + @if (dataKeyFormGroup.get('usePostProcessing').value) { + + } + + +
+ }
-
- +} @else {
-
+} diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/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..69d1abeab2 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/map-layer-row.component.html +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/map-layer-row.component.html @@ -21,47 +21,79 @@ - - {{ mapProviderTranslationMap.get(provider) | translate }} - + @for (provider of mapProviders; track provider) { + + {{ mapProviderTranslationMap.get(provider) | translate }} + + } - - - - {{ openStreetMapLayerTranslationMap.get(layerType) | translate }} - - - - - - - {{ googleMapLayerTranslationMap.get(layerType) | translate }} - - - - - - - {{ hereLayerTranslationMap.get(layerType) | translate }} - - - - - - - {{ tencentLayerTranslationMap.get(layerType) | translate }} - - - - - - + @if (MapProvider.openfreemap === layerFormGroup.get('provider').value) { + + + @for (styleType of openFreeMapStyleTypes; track styleType) { + + {{ openFreeMapStyleTranslationMap.get(styleType) | translate }} + + } + + + } + @if (MapProvider.openstreet === layerFormGroup.get('provider').value) { + + + @for (layerType of openStreetLayerTypes; track layerType) { + + {{ openStreetMapLayerTranslationMap.get(layerType) | translate }} + + } + + + } + @if (MapProvider.google === layerFormGroup.get('provider').value) { + + + @for (layerType of googleMapLayerTypes; track layerType) { + + {{ googleMapLayerTranslationMap.get(layerType) | translate }} + + } + + + } + @if (MapProvider.here === layerFormGroup.get('provider').value) { + + + @for (layerType of hereLayerTypes; track layerType) { + + {{ hereLayerTranslationMap.get(layerType) | translate }} + + } + + + } + @if (MapProvider.tencent === layerFormGroup.get('provider').value) { + + + @for (layerType of tencentLayerTypes; track layerType) { + + {{ tencentLayerTranslationMap.get(layerType) | translate }} + + } + + + } + @if (MapProvider.custom === layerFormGroup.get('provider').value) { + + + + }
- - {{ 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
+ @if (MapProvider.custom === layerFormGroup.get('provider').value) {
-
widgets.maps.layer.credentials.api-key
- - - + +
+ {{ 'widgets.maps.layer.provider.custom.vector-tiles' | translate }} +
+
+ } +
+
{{ (MapProvider.custom === layerFormGroup.get('provider').value ? (layerFormGroup.get('vectorTiles').value ? 'widgets.maps.layer.provider.custom.style-url' : 'widgets.maps.layer.provider.custom.tile-url') : 'widgets.maps.layer.layer') | translate }}
+ @if (MapProvider.openfreemap === layerFormGroup.get('provider').value) { + + + @for (styleType of openFreeMapStyleTypes; track styleType) { + + {{ openFreeMapStyleTranslationMap.get(styleType) | translate }} + + } + + + } + @if (MapProvider.openstreet === layerFormGroup.get('provider').value) { + + + @for (layerType of openStreetLayerTypes; track layerType) { + + {{ openStreetMapLayerTranslationMap.get(layerType) | translate }} + + } + + + } + @if (MapProvider.google === layerFormGroup.get('provider').value) { + + + @for (layerType of googleMapLayerTypes; track layerType) { + + {{ googleMapLayerTranslationMap.get(layerType) | translate }} + + } + + + } + @if (MapProvider.here === layerFormGroup.get('provider').value) { + + + @for (layerType of hereLayerTypes; track layerType) { + + {{ hereLayerTranslationMap.get(layerType) | translate }} + + } + + + } + @if (MapProvider.tencent === layerFormGroup.get('provider').value) { + + + @for (layerType of tencentLayerTypes; track layerType) { + + {{ tencentLayerTranslationMap.get(layerType) | translate }} + + } + + + } + @if (MapProvider.custom === layerFormGroup.get('provider').value) { + + + + }
+ @if ([MapProvider.google, MapProvider.here].includes(layerFormGroup.get('provider').value)) { +
+
widgets.maps.layer.credentials.credentials
+
+
widgets.maps.layer.credentials.api-key
+ + + +
+
+ }
widgets.maps.layer.reference.reference-layer
{{ 'widgets.maps.layer.reference.no-layer' | translate }} - - {{ referenceLayerTypeTranslationMap.get(layer) | translate }} - + @for (layer of referenceLayerTypes; track layer) { + + {{ referenceLayerTypeTranslationMap.get(layer) | translate }} + + }
+ @if (MapProvider.custom === layerFormGroup.get('provider').value) { +
+
widgets.maps.layer.provider.custom.attribution
+ + + +
+ }
-
-
- -
- -
+ @if (layersFormArray().controls.length) { +
+ @for (layerControl of layersFormArray().controls; track trackByLayer($index, layerControl)) { +
+ +
+ +
+
+ }
-
+ } @else { + {{ 'widgets.maps.layer.no-layers' | translate }} + }
- - {{ 'widgets.maps.layer.no-layers' | translate }} - diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/map-settings.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/map-settings.component.html index 083178ddb3..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,46 +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 }} {{ '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">
@@ -91,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) { + + } @@ -127,7 +145,7 @@
{{ 'widgets.maps.common.common-map-settings' | translate }}
- + @if (MapType.geoMap === mapSettingsFormGroup.get('mapType').value) {
{{ 'widgets.maps.common.fit-map-bounds' | translate }} @@ -147,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..4e1e5e460c 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/widget-font.component.html +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/widget-font.component.html @@ -15,19 +15,19 @@ limitations under the License. --> -
+
- + widgets.widget-font.font-family - + {{ sizeTitle }}
- + widgets.widget-font.font-style @@ -41,7 +41,7 @@ - + widgets.widget-font.font-weight @@ -71,16 +71,19 @@
- - + @if (hasShadowColor) { + + + }
diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/widget/widget-settings.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/widget/widget-settings.component.html index 401c5d258a..e3b048772e 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/widget/widget-settings.component.html +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/widget/widget-settings.component.html @@ -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..764651379d 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/control/device-key-autocomplete.component.html +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/control/device-key-autocomplete.component.html @@ -15,43 +15,52 @@ limitations under the License. --> - - {{ (keyType === dataKeyType.attribute - ? attributeLabel + [formGroup]="deviceKeyFormGroup"> + @if (!inlineField) { + {{ (keyType === dataKeyType.attribute + ? attributeLabel : timeseriesLabel) | translate }} + } - - - warning - + @if (deviceKeyFormGroup.get('key').value && !deviceKeyFormGroup.get('key').disabled) { + + } + @if (inlineField && requiredText && deviceKeyFormGroup.get('key').hasError('required') + && deviceKeyFormGroup.get('key').touched) { + + warning + + } - - - + @for (key of filteredKeys | async; track key) { + + + + } - - {{ requiredText | translate }} - + @if (!inlineField && requiredText && deviceKeyFormGroup.get('key').hasError('required')) { + + {{ requiredText | translate }} + + } diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/control/device-key-autocomplete.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/settings/control/device-key-autocomplete.component.ts index f02de5fa8d..d1c215a668 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/control/device-key-autocomplete.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/control/device-key-autocomplete.component.ts @@ -47,6 +47,7 @@ import { EntityFilter, singleEntityFilterFromDeviceId } from '@shared/models/que import { AliasFilterType } from '@shared/models/alias.models'; import { coerceBoolean } from '@shared/decorators/coercion'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; +import { MatFormFieldAppearance } from '@angular/material/form-field'; @Component({ selector: 'tb-device-key-autocomplete', @@ -97,6 +98,9 @@ export class DeviceKeyAutocompleteComponent extends PageComponent implements OnI @coerceBoolean() inlineField: boolean; + @Input() + appearance: MatFormFieldAppearance; + dataKeyType = DataKeyType; private modelValue: string; diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/control/led-indicator-widget-settings.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/settings/control/led-indicator-widget-settings.component.html index b4f79384ce..aa5b32162c 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/control/led-indicator-widget-settings.component.html +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/control/led-indicator-widget-settings.component.html @@ -18,12 +18,13 @@
widgets.rpc.common-settings - + widgets.rpc.led-title @@ -38,7 +39,7 @@ {{ 'widgets.rpc.perform-rpc-status-check' | translate }} - + widgets.rpc.retrieve-led-status-value-method @@ -53,13 +54,14 @@ [class.!hidden]="ledIndicatorWidgetSettingsForm.get('performCheckStatus').value" [aliasController]="aliasController" [targetDevice]="targetDevice" + appearance="outline" [keyType]="ledIndicatorWidgetSettingsForm.get('retrieveValueMethod').value === 'attribute' ? dataKeyType.attribute : dataKeyType.timeseries" attributeLabel="widgets.rpc.led-status-value-attribute" timeseriesLabel="widgets.rpc.led-status-value-timeseries" required formControlName="valueAttribute"> - + widgets.rpc.check-status-method @@ -73,7 +75,7 @@
widgets.rpc.rpc-settings - + widgets.rpc.request-timeout @@ -92,7 +94,7 @@ - + widgets.rpc.persistent-polling-interval diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/control/persistent-table-widget-settings.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/settings/control/persistent-table-widget-settings.component.html index 76929a5394..4765dabde3 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/control/persistent-table-widget-settings.component.html +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/control/persistent-table-widget-settings.component.html @@ -55,34 +55,36 @@ - - warning - + class="tb-error"> + warning + + }
widgets.table.page-step-count
- - warning - + class="tb-error"> + warning + + } @@ -90,14 +92,16 @@
widgets.table.default-page-size
- - {{ size }} - + @for (size of pageStepSizeValues; track size) { + + {{ size }} + + } - + widgets.table.default-sort-order @@ -105,18 +109,20 @@
widgets.persistent-table.columns-settings - + widgets.persistent-table.display-columns - - {{ displayColumnFromValue(column)?.name }} - cancel - + @for (column of persistentTableWidgetSettingsForm.get('displayColumns').value; track column) { + + {{ displayColumnFromValue(column)?.name }} + cancel + + } - - - - -
-
- widgets.persistent-table.no-columns-found -
- + @for (column of filteredDisplayColumns | async; track column) { + + + + } + @if ((filteredDisplayColumns | async)?.length === 0) { + +
+ @if (!textIsNotEmpty(columnSearchText)) { +
+ widgets.persistent-table.no-columns-found +
+ } @else { {{ translate.get('widgets.persistent-table.no-columns-matching', {column: truncate.transform(columnSearchText, true, 6, '...')}) | async }} - -
-
+ } +
+
+ }
diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/control/round-switch-widget-settings.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/settings/control/round-switch-widget-settings.component.html index 15dd118737..9e6b354ab0 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/control/round-switch-widget-settings.component.html +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/control/round-switch-widget-settings.component.html @@ -18,7 +18,7 @@
widgets.rpc.common-settings - + widgets.rpc.switch-title diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/control/rpc-button-style.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/settings/control/rpc-button-style.component.html index c6a9618641..9a988b763f 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/control/rpc-button-style.component.html +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/control/rpc-button-style.component.html @@ -26,12 +26,14 @@
diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/control/rpc-shell-widget-settings.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/settings/control/rpc-shell-widget-settings.component.html index 608dd81aed..00eef97a77 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/control/rpc-shell-widget-settings.component.html +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/control/rpc-shell-widget-settings.component.html @@ -18,7 +18,7 @@
widgets.rpc.rpc-settings - + widgets.rpc.request-timeout diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/control/rpc-terminal-widget-settings.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/settings/control/rpc-terminal-widget-settings.component.html index 15fcefcf9a..9fd436701a 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/control/rpc-terminal-widget-settings.component.html +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/control/rpc-terminal-widget-settings.component.html @@ -18,7 +18,7 @@
widgets.rpc.rpc-settings - + widgets.rpc.request-timeout @@ -37,7 +37,7 @@ - + widgets.rpc.persistent-polling-interval diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/control/send-rpc-widget-settings.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/settings/control/send-rpc-widget-settings.component.html index 72bc8d8219..2a22fdd74b 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/control/send-rpc-widget-settings.component.html +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/control/send-rpc-widget-settings.component.html @@ -18,11 +18,11 @@
widgets.rpc.common-settings - + widgets.rpc.widget-title - + widgets.rpc.button-label @@ -32,7 +32,7 @@ {{ 'widgets.rpc.is-one-way-command' | translate }} - + widgets.rpc.rpc-method @@ -42,7 +42,7 @@ validateOnChange formControlName="methodParams"> - + widgets.rpc.request-timeout @@ -64,7 +64,7 @@ - + widgets.rpc.persistent-polling-interval diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/control/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/slide-toggle-widget-settings.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/settings/control/slide-toggle-widget-settings.component.html index 51af51985d..4527111874 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/control/slide-toggle-widget-settings.component.html +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/control/slide-toggle-widget-settings.component.html @@ -18,8 +18,8 @@
widgets.rpc.common-settings - + formControlName="initialState">
widgets.slider.on-value-change
@@ -36,7 +36,7 @@ [aliasController]="aliasController" [targetDevice]="targetDevice" [widgetType]="widgetType" - formControlName="valueChange"> + formControlName="valueChange">
widgets.rpc-state.disabled-state
@@ -47,7 +47,7 @@ [aliasController]="aliasController" [targetDevice]="targetDevice" [widgetType]="widgetType" - formControlName="disabledState"> + formControlName="disabledState">
@@ -58,12 +58,14 @@ 'lt-sm': 1, 'lt-md': 2 }}" - label="{{ 'widgets.slider.layout' | translate }}" formControlName="layout"> - - {{ sliderLayoutTranslationMap.get(layout) | translate }} - + label="{{ 'widgets.slider.layout' | translate }}" formControlName="layout"> + @for (layout of sliderLayouts; track layout) { + + {{ sliderLayoutTranslationMap.get(layout) | translate }} + + }
@@ -84,25 +86,27 @@
widgets.slider.slider
-
- - {{ 'widgets.slider.value' | translate }} - -
- - - -
widget-config.decimals-suffix
-
- - - - + @if (sliderWidgetSettingsForm.get('layout').value !== sliderLayout.simplified) { +
+ + {{ 'widgets.slider.value' | translate }} + +
+ + + +
widget-config.decimals-suffix
+
+ + + + +
-
+ }
{{ 'widgets.slider.range' | translate }}
@@ -180,43 +184,47 @@
-
-
- {{ 'widgets.slider.left-icon' | translate }} -
-
- - - - - - - - -
-
-
-
- {{ 'widgets.slider.right-icon' | translate }} + @if (sliderWidgetSettingsForm.get('layout').value === sliderLayout.extended) { +
+
+ {{ 'widgets.slider.left-icon' | translate }} +
+
+ + + + + + + + +
-
- - - - - - - - + } + @if (sliderWidgetSettingsForm.get('layout').value === sliderLayout.extended) { +
+
+ {{ 'widgets.slider.right-icon' | translate }} +
+
+ + + + + + + + +
-
+ }
diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/control/switch-control-widget-settings.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/settings/control/switch-control-widget-settings.component.html index 145459382d..19ab4f24f8 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/control/switch-control-widget-settings.component.html +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/control/switch-control-widget-settings.component.html @@ -18,7 +18,7 @@
widgets.rpc.common-settings - + widgets.rpc.switch-title diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/control/switch-rpc-settings.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/settings/control/switch-rpc-settings.component.html index 3536341211..7ab592ef24 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/control/switch-rpc-settings.component.html +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/control/switch-rpc-settings.component.html @@ -23,7 +23,7 @@
widgets.rpc.retrieve-value-settings - + widgets.rpc.retrieve-value-method @@ -45,11 +45,12 @@ switchRpcSettingsFormGroup.get('retrieveValueMethod').value !== 'timeseries'" [aliasController]="aliasController" [targetDevice]="targetDevice" + appearance="outline" [keyType]="switchRpcSettingsFormGroup.get('retrieveValueMethod').value === 'attribute' ? dataKeyType.attribute : dataKeyType.timeseries" required formControlName="valueKey"> - + widgets.rpc.get-value-method @@ -63,7 +64,7 @@
widgets.rpc.update-value-settings - + widgets.rpc.set-value-method @@ -78,7 +79,7 @@
widgets.rpc.rpc-settings - + widgets.rpc.request-timeout @@ -97,7 +98,7 @@ - + widgets.rpc.persistent-polling-interval diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/control/update-device-attribute-widget-settings.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/settings/control/update-device-attribute-widget-settings.component.html index 192e5a7be7..e881157fa9 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/control/update-device-attribute-widget-settings.component.html +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/control/update-device-attribute-widget-settings.component.html @@ -18,19 +18,20 @@
widgets.rpc.common-settings - - + widgets.rpc.button-label - + widgets.rpc.device-attribute-scope diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/control/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/date/date-range-navigator-widget-settings.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/settings/date/date-range-navigator-widget-settings.component.html index 3921e3655b..417ca8d436 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/date/date-range-navigator-widget-settings.component.html +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/date/date-range-navigator-widget-settings.component.html @@ -41,7 +41,7 @@ {{ 'widgets.date-range-navigator.picker-show-template' | translate }} - + widgets.date-range-navigator.first-day-of-week @@ -64,7 +64,7 @@ - + widgets.date-range-navigator.initial-interval @@ -108,7 +108,7 @@ - + widgets.date-range-navigator.initial-step-size diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/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-config-widget-settings.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/settings/gateway/gateway-config-widget-settings.component.html index c525c67b56..3b35789e65 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/gateway/gateway-config-widget-settings.component.html +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/gateway/gateway-config-widget-settings.component.html @@ -18,31 +18,32 @@
widgets.gateway.general-settings - - + widgets.gateway.default-archive-file-name - + widgets.gateway.device-type-for-new-gateway
widgets.gateway.messages-settings - + widgets.gateway.save-config-success-message - + widgets.gateway.device-name-exists-message diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/gateway/gateway-events-widget-settings.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/settings/gateway/gateway-events-widget-settings.component.html index 1bebce0b7d..41e6a93d65 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/gateway/gateway-events-widget-settings.component.html +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/gateway/gateway-events-widget-settings.component.html @@ -16,25 +16,27 @@ -->
- - + widgets.gateway.events-filter - - {{eventFilter}} - cancel - + @for (eventFilter of gatewayEventsWidgetSettingsForm.get('eventsReg').value; track eventFilter) { + + {{eventFilter}} + cancel + + } - - + @switch (gaugeType) { + @case ('radial') { - - + } + @case ('linear') { - - + } + }
widgets.gauge.ticks-settings
diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/gauge/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..c995a68414 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/gpio/gpio-control-widget-settings.component.html +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/gpio/gpio-control-widget-settings.component.html @@ -21,6 +21,7 @@ @@ -28,19 +29,22 @@ widgets.gpio.gpio-switches
-
- - -
+ @for (gpioItemControl of typedSelectOptions; track trackByGpioItem($index, gpioItemControl); let isLast = $last) { +
+ + +
+ }
-
+ @if (!gpioListFormArray().controls.length) { +
widgets.gpio.no-gpio-switches -
+ class="tb-prompt flex items-center justify-center">widgets.gpio.no-gpio-switches +
+ }
-
-
-
+ @if (hasColor) { +
+
+
+ }
- + @if (!disabled) { + + }
@@ -46,32 +50,34 @@
- + widgets.gpio.pin - + widgets.gpio.label
- + widgets.gpio.row - + widgets.gpio.column
- - + @if (hasColor) { + + + }
diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/gpio/gpio-panel-widget-settings.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/settings/gpio/gpio-panel-widget-settings.component.html index 1b7ff3d4e5..f002322bfe 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/gpio/gpio-panel-widget-settings.component.html +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/gpio/gpio-panel-widget-settings.component.html @@ -28,20 +28,23 @@ widgets.gpio.gpio-leds
-
- - -
+ @for (gpioItemControl of typedSelectOptions; track trackByGpioItem($index, gpioItemControl); let isLast = $last) { +
+ + +
+ }
-
+ @if (!gpioListFormArray().controls.length) { +
widgets.gpio.no-gpio-leds -
+ class="tb-prompt flex items-center justify-center">widgets.gpio.no-gpio-leds +
+ }
+ @if (!disabled) { + + }
@@ -41,11 +45,11 @@
- + widgets.input-widgets.option-value - + widgets.input-widgets.option-label diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/input/device-claiming-widget-settings.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/settings/input/device-claiming-widget-settings.component.html index f11b16200f..53a508a038 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/input/device-claiming-widget-settings.component.html +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/input/device-claiming-widget-settings.component.html @@ -18,11 +18,11 @@
widgets.input-widgets.general-settings - + widgets.input-widgets.widget-title - + widgets.input-widgets.claim-button-label @@ -46,11 +46,11 @@
- + widgets.input-widgets.device-name-label - + widgets.input-widgets.secret-key-label @@ -60,23 +60,23 @@
widgets.input-widgets.messages-settings - + widgets.input-widgets.claim-device-success-message - + widgets.input-widgets.claim-device-not-found-message - + widgets.input-widgets.claim-device-failed-message - + widgets.input-widgets.claim-device-name-required-message - + widgets.input-widgets.claim-device-secret-key-required-message diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/input/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-image-attribute-widget-settings.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/settings/input/update-image-attribute-widget-settings.component.html index 2c811a6ce1..195e95bb97 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/input/update-image-attribute-widget-settings.component.html +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/input/update-image-attribute-widget-settings.component.html @@ -18,9 +18,10 @@
widgets.input-widgets.general-settings -
widgets.input-widgets.general-settings -
- + widgets.input-widgets.latitude-key-name - + widgets.input-widgets.longitude-key-name @@ -53,16 +54,16 @@ {{ 'widgets.input-widgets.show-label' | translate }}
- + widgets.input-widgets.latitude-label - + widgets.input-widgets.longitude-label
- + widgets.input-widgets.input-fields-alignment @@ -81,7 +82,7 @@ {{ 'widgets.input-widgets.longitude-field-required' | translate }}
- + widgets.input-widgets.required-error-message diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/input/update-multiple-attributes-key-settings.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/settings/input/update-multiple-attributes-key-settings.component.html index 6419028708..faadbd6581 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/input/update-multiple-attributes-key-settings.component.html +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/input/update-multiple-attributes-key-settings.component.html @@ -23,7 +23,7 @@ widgets.input-widgets.general-settings
- + widgets.input-widgets.datakey-type @@ -37,7 +37,7 @@ - + widgets.input-widgets.datakey-value-type @@ -83,7 +83,7 @@ {{ 'widgets.input-widgets.value-is-required' | translate }}
- + widgets.input-widgets.ability-to-edit-attribute @@ -97,42 +97,44 @@ - + widgets.input-widgets.disable-on-datakey-name
-
- - widgets.input-widgets.field-appearance - - - {{ 'widgets.input-widgets.appearance-fill' | translate }} - - - {{ 'widgets.input-widgets.appearance-outline' | translate }} - - - - - widgets.input-widgets.subscript-sizing - - - {{ 'widgets.input-widgets.subscript-sizing-fixed' | translate }} - - - {{ 'widgets.input-widgets.subscript-sizing-dynamic' | translate }} - - - -
+ @if (!['booleanSwitch', 'booleanCheckbox', 'radio'].includes(updateMultipleAttributesKeySettingsForm.get('dataKeyValueType').value)) { +
+ + widgets.input-widgets.field-appearance + + + {{ 'widgets.input-widgets.appearance-fill' | translate }} + + + {{ 'widgets.input-widgets.appearance-outline' | translate }} + + + + + widgets.input-widgets.subscript-sizing + + + {{ 'widgets.input-widgets.subscript-sizing-fixed' | translate }} + + + {{ 'widgets.input-widgets.subscript-sizing-dynamic' | translate }} + + + +
+ }
widgets.input-widgets.slide-toggle-settings - + widgets.input-widgets.slide-toggle-label-position @@ -152,20 +154,22 @@
-
- - -
-
-
- - {{ (updateMultipleAttributesKeySettingsForm.get('dataKeyValueType').value === 'select' ? 'widgets.input-widgets.no-select-options' : 'widgets.input-widgets.no-radio-options') | translate }} - + @for (selectOptionControl of typedSelectOptions; track trackBySelectOption($index, selectOptionControl); let isLast = $last) { +
+ + +
+ }
+ @if (!selectOptionsFormArray().controls.length) { +
+ + {{ (updateMultipleAttributesKeySettingsForm.get('dataKeyValueType').value === 'select' ? 'widgets.input-widgets.no-select-options' : 'widgets.input-widgets.no-radio-options') | translate }} + +
+ }
@@ -212,15 +217,15 @@ updateMultipleAttributesKeySettingsForm.get('dataKeyValueType').value !== 'double')" class="fields-group"> widgets.input-widgets.numeric-field-settings
- + widgets.input-widgets.step-interval - + widgets.input-widgets.min-value - + widgets.input-widgets.max-value @@ -228,29 +233,30 @@
widgets.input-widgets.error-messages - + widgets.input-widgets.required-error-message
- + class="flex flex-row gap-2"> + widgets.input-widgets.min-value-error-message - + widgets.input-widgets.max-value-error-message
+ updateMultipleAttributesKeySettingsForm.get('dataKeyValueType').value !== 'time'" class="mat-block flex-1" + appearance="outline"> widgets.input-widgets.invalid-date-error-message - + widgets.input-widgets.invalid-JSON-error-message @@ -258,16 +264,16 @@
widgets.input-widgets.dialog-editor-settings - + widgets.input-widgets.title
- + widgets.input-widgets.save-button-label - + widgets.input-widgets.cancel-button-label @@ -275,7 +281,7 @@
+ class="fields-group"> widgets.input-widgets.icon-settings {{ 'widgets.input-widgets.use-custom-icon' | translate }} @@ -283,11 +289,12 @@ + formControlName="icon" + appearance="outline"> + label="{{ 'widgets.input-widgets.input-cell-icon' | translate }}" + formControlName="customIcon">
diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/input/update-multiple-attributes-key-settings.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/settings/input/update-multiple-attributes-key-settings.component.ts index 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/input/update-multiple-attributes-widget-settings.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/settings/input/update-multiple-attributes-widget-settings.component.html index b9def80dfd..4c0d9bf4f2 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/input/update-multiple-attributes-widget-settings.component.html +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/input/update-multiple-attributes-widget-settings.component.html @@ -18,10 +18,11 @@
widgets.input-widgets.general-settings -
- + widgets.input-widgets.save-button-label - + widgets.input-widgets.reset-button-label @@ -70,7 +71,7 @@ {{ 'widgets.input-widgets.show-group-title' | translate }} - + widgets.input-widgets.group-title @@ -79,7 +80,7 @@
widgets.input-widgets.layout
- + widgets.input-widgets.fields-alignment @@ -90,17 +91,17 @@ - + widgets.input-widgets.fields-in-row
- + widgets.input-widgets.row-gap - + widgets.input-widgets.column-gap diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/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-card-widget-settings.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/settings/navigation/navigation-card-widget-settings.component.html index e5176cd05b..e7dab2597f 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/navigation/navigation-card-widget-settings.component.html +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/navigation/navigation-card-widget-settings.component.html @@ -16,16 +16,17 @@ -->
- + widgets.navigation.title - + widgets.navigation.navigation-path diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/navigation/navigation-cards-widget-settings.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/settings/navigation/navigation-cards-widget-settings.component.html index d8545078c1..18284f79b5 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/navigation/navigation-cards-widget-settings.component.html +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/navigation/navigation-cards-widget-settings.component.html @@ -24,14 +24,16 @@ {{ 'widgets.navigation.filter-type-exclude' | translate }}
- + widgets.navigation.items - - {{ filterItem }} - cancel - + @for (filterItem of navigationCardsWidgetSettingsForm.get('filter').value; track filterItem) { + + {{ filterItem }} + cancel + + } - - - + @for (filterItem of filteredFilterItems | async; track filterItem) { + + + + }
diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/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/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/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 641755b810..1ed4d777ef 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..be2ef9d8cf 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..f3dda139be 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 @@ -49,6 +49,7 @@ import { WidgetHeaderActionButtonType } from '@shared/models/widget.models'; import ITooltipsterInstance = JQueryTooltipster.ITooltipsterInstance; import ITooltipsterGeoHelper = JQueryTooltipster.ITooltipsterGeoHelper; import { WidgetComponent } from '@home/components/widget/widget.component'; +import { WidgetAction } from '@home/models/widget-component.models'; export enum WidgetComponentActionType { MOUSE_DOWN, @@ -294,6 +295,10 @@ export class WidgetContainerComponent extends PageComponent implements OnInit, O } } + actionVisible(action: WidgetAction): boolean { + return typeof action.show === 'function' ? action.show() : action.show; + } + private initEditWidgetActionTooltip(parent: HTMLElement) { let componentRef: ComponentRef; from(import('tooltipster')).subscribe(() => { @@ -397,37 +402,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..01a83e5d11 100644 --- a/ui-ngx/src/app/modules/home/components/wizard/device-wizard-dialog.component.html +++ b/ui-ngx/src/app/modules/home/components/wizard/device-wizard-dialog.component.html @@ -25,8 +25,11 @@ close - - +@if (isLoading$ | async) { + +} @else { +
+}
- {{ 'device.wizard.device-details' | translate}} -
- + {{ 'device.wizard.device-details' | translate }} +
+ device.name - - {{ 'device.name-required' | translate }} - - - {{ 'device.name-max-length' | translate }} - + @if (deviceWizardFormGroup.get('name').hasError('required')) { + {{ 'device.name-required' | translate }} + } + @if (deviceWizardFormGroup.get('name').hasError('maxlength')) { + {{ 'device.name-max-length' | translate }} + } - + device.label - - {{ 'device.label-max-length' | translate }} - + @if (deviceWizardFormGroup.get('label').hasError('maxlength')) { + {{ 'device.label-max-length' | translate }} + } @@ -68,20 +72,22 @@ {{ 'device.is-gateway' | translate }} - - {{ 'device.overwrite-activity-time' | translate }} - + @if (deviceWizardFormGroup.get('gateway').value) { + + {{ 'device.overwrite-activity-time' | translate }} + + }
- + device.description - +
@@ -100,15 +106,18 @@
- + @if (selectedIndex > 0) { + + } - + @if (showNext) { + + }
diff --git a/ui-ngx/src/app/modules/home/components/wizard/device-wizard-dialog.component.scss b/ui-ngx/src/app/modules/home/components/wizard/device-wizard-dialog.component.scss index 9f4c873f35..19883b75ff 100644 --- a/ui-ngx/src/app/modules/home/components/wizard/device-wizard-dialog.component.scss +++ b/ui-ngx/src/app/modules/home/components/wizard/device-wizard-dialog.component.scss @@ -74,6 +74,7 @@ max-height: 100%; overflow-y: auto; scrollbar-gutter: stable; + padding-top: 4px; } } } diff --git a/ui-ngx/src/app/modules/home/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..4aed4a41b8 100644 --- a/ui-ngx/src/app/modules/home/dialogs/add-entities-to-edge-dialog.component.html +++ b/ui-ngx/src/app/modules/home/dialogs/add-entities-to-edge-dialog.component.html @@ -25,15 +25,19 @@ close - - -
+ @if (isLoading$ | async) { + + + } @else { +
+ }
{{ assignToEdgeText | translate }} diff --git a/ui-ngx/src/app/modules/home/dialogs/assign-to-customer-dialog.component.html b/ui-ngx/src/app/modules/home/dialogs/assign-to-customer-dialog.component.html index 4af3aa07a1..efa6ef3c1c 100644 --- a/ui-ngx/src/app/modules/home/dialogs/assign-to-customer-dialog.component.html +++ b/ui-ngx/src/app/modules/home/dialogs/assign-to-customer-dialog.component.html @@ -25,14 +25,18 @@ close - - -
+ @if (isLoading$ | async) { + + + } @else { +
+ }
{{ assignToCustomerText | translate }}
diff --git a/ui-ngx/src/app/modules/home/home.component.html b/ui-ngx/src/app/modules/home/home.component.html index 991b2f1a9f..5255a565c6 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 1be09e0ddd..a62ca48f24 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 @@ -17,6 +17,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 ca379c6e73..2223a99062 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 @@ -17,13 +17,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/datasource/attribute-datasource.ts b/ui-ngx/src/app/modules/home/models/datasource/attribute-datasource.ts index eaeade89ae..cc3112442f 100644 --- a/ui-ngx/src/app/modules/home/models/datasource/attribute-datasource.ts +++ b/ui-ngx/src/app/modules/home/models/datasource/attribute-datasource.ts @@ -115,9 +115,11 @@ export class AttributeDatasource implements DataSource { } isAllSelected(): Observable { - const numSelected = this.selection.selected.length; return this.attributesSubject.pipe( - map((attributes) => numSelected === attributes.length) + map((attributes) => { + const numSelected = this.selection.selected.length; + return attributes.length > 0 && numSelected === attributes.length + }) ); } diff --git a/ui-ngx/src/app/modules/home/models/entity/entities-table-config.models.ts b/ui-ngx/src/app/modules/home/models/entity/entities-table-config.models.ts index 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 92cfd901c1..80ed3cba9d 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 @@ -151,7 +151,7 @@ export interface WidgetHeaderAction extends IWidgetAction { } export interface WidgetAction extends IWidgetAction { - show: boolean; + show: boolean | (()=> boolean); } export interface IDashboardWidget { 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..0142c83a85 100644 --- a/ui-ngx/src/app/modules/home/pages/admin/general-settings.component.html +++ b/ui-ngx/src/app/modules/home/pages/admin/general-settings.component.html @@ -23,19 +23,24 @@
- - -
+ @if (isLoading$ | async) { + + + } @else { +
+ }
- + admin.base-url - - {{ 'admin.base-url-required' | translate }} - + @if (generalSettings.get('baseUrl').hasError('required')) { + + {{ 'admin.base-url-required' | translate }} + + }
@@ -63,9 +68,12 @@
admin.device-connectivity.device-connectivity
- - -
+ @if (isLoading$ | async) { + + + } @else { +
+ }
@@ -78,38 +86,42 @@
- + @if (protocol === 'http') { - - + } + @if (protocol === 'mqtt') { - - + } + @if (protocol === 'coap') { - + }
{{ 'admin.device-connectivity.' + protocol | translate }}
- + admin.device-connectivity.host - + admin.device-connectivity.port - - {{ 'admin.device-connectivity.port-pattern' | translate }} - - - {{ 'admin.device-connectivity.port-range' | translate }} - + @if (deviceConnectivitySettingsForm.get(protocol + '.port').hasError('pattern')) { + + {{ 'admin.device-connectivity.port-pattern' | translate }} + + } + @if (deviceConnectivitySettingsForm.get(protocol + '.port').hasError('min') || + deviceConnectivitySettingsForm.get(protocol + '.port').hasError('max')) { + + {{ 'admin.device-connectivity.port-range' | translate }} + + }
diff --git a/ui-ngx/src/app/modules/home/pages/admin/home-settings.component.html b/ui-ngx/src/app/modules/home/pages/admin/home-settings.component.html index 9ebfd0369b..25c9adfea0 100644 --- a/ui-ngx/src/app/modules/home/pages/admin/home-settings.component.html +++ b/ui-ngx/src/app/modules/home/pages/admin/home-settings.component.html @@ -22,9 +22,13 @@ admin.home-settings - - -
+ @if (isLoading$ | async) { + + + } + @if ((isLoading$ | async) === false) { +
+ }
@@ -32,6 +36,7 @@
- - -
+ @if (isLoading$ | async) { + + + } + @if (!(isLoading$ | async)) { +
+ }
- + admin.mail-from - - {{ 'admin.mail-from-required' | translate }} - + @if (mailSettings.get('mailFrom').hasError('required')) { + + {{ 'admin.mail-from-required' | translate }} + + } - + admin.oauth2.smtp-provider - - {{ templates.get(provider)?.name || 'Custom' }} - + @for (provider of templateProvider; track provider) { + + {{ templates.get(provider)?.name || 'Custom' }} + + } @@ -54,99 +62,123 @@ - + admin.smtp-protocol - - {{protocol.toUpperCase()}} - + @for (protocol of smtpProtocols; track protocol) { + + {{protocol.toUpperCase()}} + + }
- + admin.smtp-host - - {{ 'admin.smtp-host-required' | translate }} - + @if (mailSettings.get('smtpHost').hasError('required')) { + + {{ 'admin.smtp-host-required' | translate }} + + } - + admin.smtp-port {{smtpPortInput.value?.length || 0}}/5 - - {{ 'admin.smtp-port-required' | translate }} - - - {{ 'admin.smtp-port-invalid' | translate }} - + @if (mailSettings.get('smtpPort').hasError('required')) { + + {{ 'admin.smtp-port-required' | translate }} + + } + @if (mailSettings.get('smtpPort').hasError('pattern') || mailSettings.get('smtpPort').hasError('maxlength')) { + + {{ 'admin.smtp-port-invalid' | translate }} + + }
- + admin.timeout-msec {{timeoutInput.value?.length || 0}}/6 - - {{ 'admin.timeout-required' | translate }} - - - {{ 'admin.timeout-invalid' | translate }} - + @if (mailSettings.get('timeout').hasError('required')) { + + {{ 'admin.timeout-required' | translate }} + + } + @if (mailSettings.get('timeout').hasError('pattern') || mailSettings.get('timeout').hasError('maxlength')) { + + {{ 'admin.timeout-invalid' | translate }} + + } {{ 'admin.enable-tls' | translate }} - - admin.tls-version - - - {{ tlsVersion }} - - - + @if (mailSettings.get('enableTls').value) { + + admin.tls-version + + @for (tlsVersion of tlsVersions; track tlsVersion) { + + {{ tlsVersion }} + + } + + + } {{ 'admin.enable-proxy' | translate }} -
-
- - admin.proxy-host - - - {{ 'admin.proxy-host-required' | translate }} - - - - admin.proxy-port - - - {{ 'admin.proxy-port-required' | translate }} - - - {{ 'admin.proxy-port-range' | translate }} - - -
-
- - admin.proxy-user - - - - admin.proxy-password - - - + @if (mailSettings.get('enableProxy').value) { +
+
+ + admin.proxy-host + + @if (mailSettings.get('proxyHost').hasError('required')) { + + {{ 'admin.proxy-host-required' | translate }} + + } + + + admin.proxy-port + + @if (mailSettings.get('proxyPort').hasError('required')) { + + {{ 'admin.proxy-port-required' | translate }} + + } + @if (mailSettings.get('proxyPort').hasError('pattern') + || mailSettings.get('proxyPort').hasError('min') + || mailSettings.get('proxyPort').hasError('max')) { + + {{ 'admin.proxy-port-range' | translate }} + + } + +
+
+ + admin.proxy-user + + + + admin.proxy-password + + + +
-
+ }
admin.oauth2.authentication - + common.username @@ -156,161 +188,197 @@ {{ 'admin.oauth2.basic' | translate }} {{ 'admin.oauth2.oauth2' | translate }} -
-
-
+ @if (helpLink && mailSettings.get('enableOauth2').value) { +
+
+
+ }
-
- - {{ 'admin.change-password' | translate }} - - - common.password - - - -
-
-
- - admin.oauth2.client-id - - - {{ 'admin.oauth2.client-id-required' | translate }} - - - {{ 'admin.oauth2.client-id-max-length' | translate }} - - - - - admin.oauth2.client-secret - - - {{ 'admin.oauth2.client-secret-required' | translate }} - - - {{ 'admin.oauth2.client-secret-max-length' | translate }} - - -
- - - admin.oauth2.microsoft-tenant-id - - - {{ 'admin.oauth2.microsoft-tenant-id-required' | translate }} - - - - - - tenant-profile.advanced-settings - - - -
- - admin.oauth2.authorization-uri - - - - {{ 'admin.oauth2.access-token-uri-required' | translate }} + @if (!mailSettings.get('enableOauth2').value) { +
+ @if (showChangePassword) { + + {{ 'admin.change-password' | translate }} + + } + @if (mailSettings.get('changePassword').value || !showChangePassword) { + + common.password + + + + } +
+ } + @if (mailSettings.get('enableOauth2').value) { +
+
+ + admin.oauth2.client-id + + @if (mailSettings.get('clientId').hasError('required')) { + + {{ 'admin.oauth2.client-id-required' | translate }} - - {{ 'admin.oauth2.uri-pattern-error' | translate }} + } + @if (mailSettings.get('clientId').hasError('maxlen gth')) { + + {{ 'admin.oauth2.client-id-max-length' | translate }} - - - - admin.oauth2.token-uri - - - - {{ 'admin.oauth2.access-token-uri-required' | translate }} + } + + + admin.oauth2.client-secret + + @if (mailSettings.get('clientSecret').hasError('required')) { + + {{ 'admin.oauth2.client-secret-required' | translate }} - - {{ 'admin.oauth2.uri-pattern-error' | translate }} + } + @if (mailSettings.get('clientSecret').hasError('maxlength')) { + + {{ 'admin.oauth2.client-secret-max-length' | translate }} - -
- - - - - -
- admin.oauth2.redirect-uri -
-
-
- - admin.oauth2.protocol - - - {{ domainSchemaTranslations.get(protocol) | translate | uppercase }} - - + } + +
+ @if (mailSettings.get('providerId').value === mailServerOauth2Provider.OFFICE_365) { + + admin.oauth2.microsoft-tenant-id + + @if (mailSettings.get('providerTenantId').hasError('required')) { + + {{ 'admin.oauth2.microsoft-tenant-id-required' | translate }} + + } + + } + + + + tenant-profile.advanced-settings + + + +
+ + admin.oauth2.authorization-uri + + + @if (mailSettings.get('authUri').hasError('required')) { + + {{ 'admin.oauth2.access-token-uri-required' | translate }} + + } + @if (mailSettings.get('authUri').hasError('pattern')) { + + {{ 'admin.oauth2.uri-pattern-error' | translate }} + + } - - admin.domain-name - - - {{ 'admin.error-verification-url' | translate }} - - - {{ 'admin.domain-name-max-length' | translate }} + + admin.oauth2.token-uri + + + @if (mailSettings.get('tokenUri').hasError('required')) { + + {{ 'admin.oauth2.access-token-uri-required' | translate }} + + } + @if (mailSettings.get('tokenUri').hasError('pattern')) { + + {{ 'admin.oauth2.uri-pattern-error' | translate }} + + } + +
+ + +
+
+
+ admin.oauth2.redirect-uri +
+
+
+ + admin.oauth2.protocol + + @for (protocol of protocols; track protocol) { + + {{ domainSchemaTranslations.get(protocol) | translate | uppercase }} + + } + + + + admin.domain-name + + @if (domainForm.get('name').hasError('pattern')) { + + {{ 'admin.error-verification-url' | translate }} + + } + @if (domainForm.get('name').hasError('maxlength')) { + + {{ 'admin.domain-name-max-length' | translate }} + + } + +
+ @if (domainForm.hasError('unique')) { + + {{ 'admin.domain-name-unique' | translate }} + } +
+
+ + admin.oauth2.redirect-uri-template + + +
- - {{ 'admin.domain-name-unique' | translate }} - -
- -
- - admin.oauth2.redirect-uri-template - - - -
+
+
+ } + @if (mailSettings.get('enableOauth2').value) { +
+
+ admin.oauth2.access-token-status + + {{ accessTokenStatus }} +
-
-
-
-
- admin.oauth2.access-token-status - - {{ accessTokenStatus }} - -
- -
+ +
+ }
- - -
+ @if (isLoading$ | async) { + + + } @else { +
+ }
diff --git a/ui-ngx/src/app/modules/home/pages/admin/oauth2/clients/client.component.html b/ui-ngx/src/app/modules/home/pages/admin/oauth2/clients/client.component.html index 12f522de96..3e02fae438 100644 --- a/ui-ngx/src/app/modules/home/pages/admin/oauth2/clients/client.component.html +++ b/ui-ngx/src/app/modules/home/pages/admin/oauth2/clients/client.component.html @@ -17,57 +17,73 @@ -->
- + admin.oauth2.title - - {{ 'admin.oauth2.client-title-required' | translate }} - - - {{ 'admin.oauth2.client-title-max-length' | translate }} - + @if (entityForm.get('title').hasError('required')) { + + {{ 'admin.oauth2.client-title-required' | translate }} + + } + @if (entityForm.get('title').hasError('maxlength')) { + + {{ 'admin.oauth2.client-title-max-length' | translate }} + + }
- + admin.oauth2.provider - - {{ provider }} - + @for (provider of templateProvider; track provider) { + + {{ provider }} + + }
- + admin.oauth2.allowed-platforms - - {{ platformTypeTranslations.get(platform) | translate }} - + @for (platform of platformTypes; track platform) { + + {{ platformTypeTranslations.get(platform) | translate }} + + }
- + admin.oauth2.client-id - - {{ 'admin.oauth2.client-id-required' | translate }} - - - {{ 'admin.oauth2.client-id-max-length' | translate }} - + @if (entityForm.get('clientId').hasError('required')) { + + {{ 'admin.oauth2.client-id-required' | translate }} + + } + @if (entityForm.get('clientId').hasError('maxlength')) { + + {{ 'admin.oauth2.client-id-max-length' | translate }} + + } - + admin.oauth2.client-secret - - {{ 'admin.oauth2.client-secret-required' | translate }} - - - {{ 'admin.oauth2.client-secret-max-length' | translate }} - + @if (entityForm.get('clientSecret').hasError('required')) { + + {{ 'admin.oauth2.client-secret-required' | translate }} + + } + @if (entityForm.get('clientSecret').hasError('maxlength')) { + + {{ 'admin.oauth2.client-secret-max-length' | translate }} + + }
@@ -85,237 +101,291 @@ {{ 'admin.oauth2.mapper' | translate }}
-
-
- - admin.oauth2.access-token-uri - - - {{ 'admin.oauth2.access-token-uri-required' | translate }} - - - {{ 'admin.oauth2.uri-pattern-error' | translate }} - - - - admin.oauth2.authorization-uri - - - {{ 'admin.oauth2.authorization-uri-required' | translate }} - - - {{ 'admin.oauth2.uri-pattern-error' | translate }} - - -
-
- - admin.oauth2.jwk-set-uri - - - {{ 'admin.oauth2.uri-pattern-error' | translate }} - - - - admin.oauth2.user-info-uri - - - {{ 'admin.oauth2.uri-pattern-error' | translate }} - - -
-
- - admin.oauth2.client-authentication-method - - - {{ clientAuthenticationMethod | uppercase }} - - - -
-
- - admin.oauth2.login-button-label - - - {{ 'admin.oauth2.login-button-label-required' | translate }} - - -
-
admin.oauth2.login-button-icon
- - -
-
-
-
- - {{ 'admin.oauth2.allow-user-creation' | translate }} - -
-
- - {{ 'admin.oauth2.activate-user' | translate }} - -
-
- - -
-
-
- - admin.oauth2.user-name-attribute-name - - - {{ 'admin.oauth2.user-name-attribute-name-required' | translate }} - - -
-
-
- - admin.oauth2.type - - - {{ mapperType }} - - - -
-
-
- - admin.oauth2.email-attribute-key - - - {{ 'admin.oauth2.email-attribute-key-required' | translate }} + @if (generalSettingsMode) { +
+
+ + admin.oauth2.access-token-uri + + @if (entityForm.get('accessTokenUri').hasError('required')) { + + {{ 'admin.oauth2.access-token-uri-required' | translate }} - - {{ 'admin.oauth2.email-attribute-key-max-length' | translate }} + } + @if (entityForm.get('accessTokenUri').hasError('pattern')) { + + {{ 'admin.oauth2.uri-pattern-error' | translate }} - -
-
- - admin.oauth2.first-name-attribute-key - - - {{ 'admin.oauth2.first-name-attribute-key-max-length' | translate }} + } + + + admin.oauth2.authorization-uri + + @if (entityForm.get('authorizationUri').hasError('required')) { + + {{ 'admin.oauth2.authorization-uri-required' | translate }} - - - admin.oauth2.last-name-attribute-key - - - {{ 'admin.oauth2.last-name-attribute-key-max-length' | translate }} + } + @if (entityForm.get('authorizationUri').hasError('pattern')) { + + {{ 'admin.oauth2.uri-pattern-error' | translate }} - -
-
- - admin.oauth2.tenant-name-strategy - - - {{ tenantNameStrategy }} - - - - - admin.oauth2.tenant-name-pattern - - - {{ 'admin.oauth2.tenant-name-pattern-required' | translate }} + } + +
+
+ + admin.oauth2.jwk-set-uri + + @if (entityForm.get('jwkSetUri').hasError('pattern')) { + + {{ 'admin.oauth2.uri-pattern-error' | translate }} - - {{ 'admin.oauth2.tenant-name-pattern-max-length' | translate }} + } + + + admin.oauth2.user-info-uri + + @if (entityForm.get('userInfoUri').hasError('pattern')) { + + {{ 'admin.oauth2.uri-pattern-error' | translate }} + } + +
+
+ + admin.oauth2.client-authentication-method + + @for (clientAuthenticationMethod of clientAuthenticationMethods; track clientAuthenticationMethod) { + + {{ clientAuthenticationMethod | uppercase }} + + } + + +
+ @if (isCustomProvider()) { +
+ + admin.oauth2.login-button-label + + @if (entityForm.get('loginButtonLabel').hasError('required')) { + + {{ 'admin.oauth2.login-button-label-required' | translate }} + + } +
+
admin.oauth2.login-button-icon
+ + +
-
- - admin.oauth2.customer-name-pattern - - - {{ 'admin.oauth2.customer-name-pattern-max-length' | translate }} - - + } +
+
+ + {{ 'admin.oauth2.allow-user-creation' | translate }} +
-
- - admin.oauth2.default-dashboard-name - - - {{ 'admin.oauth2.default-dashboard-name-max-length' | translate }} - - - - {{ 'admin.oauth2.always-fullscreen' | translate}} +
+ + {{ 'admin.oauth2.activate-user' | translate }}
-
-
- - admin.oauth2.url - - - {{ 'admin.oauth2.url-required' | translate }} - - - {{ 'admin.oauth2.url-pattern' | translate }} - - - {{ 'admin.oauth2.url-max-length' | translate }} - - -
-
- - common.username - - - {{ 'admin.oauth2.username-max-length' | translate }} - - - - common.password - - - {{ 'admin.oauth2.password-max-length' | translate }} + + +
+ } + @if (!generalSettingsMode) { +
+
+ + admin.oauth2.user-name-attribute-name + + @if (entityForm.get('userNameAttributeName').hasError('required')) { + + {{ 'admin.oauth2.user-name-attribute-name-required' | translate }} + } + +
+
+
+ + admin.oauth2.type + + @for (mapperType of mapperTypes; track mapperType) { + + {{ mapperType }} + + } +
- - {{ 'admin.oauth2.send-token' | translate}} - + @if (entityForm.get('mapperConfig.type').value !== mapperType.CUSTOM && entityForm.get('mapperConfig.basic')) { +
+ @if (entityForm.get('mapperConfig.type').value !== mapperType.GITHUB) { +
+ + admin.oauth2.email-attribute-key + + @if (entityForm.get('mapperConfig.basic.emailAttributeKey').hasError('required')) { + + {{ 'admin.oauth2.email-attribute-key-required' | translate }} + + } + @if (entityForm.get('mapperConfig.basic.emailAttributeKey').hasError('maxlength')) { + + {{ 'admin.oauth2.email-attribute-key-max-length' | translate }} + + } + +
+ } +
+ + admin.oauth2.first-name-attribute-key + + @if (entityForm.get('mapperConfig.basic.firstNameAttributeKey').hasError('maxlength')) { + + {{ 'admin.oauth2.first-name-attribute-key-max-length' | translate }} + + } + + + admin.oauth2.last-name-attribute-key + + @if (entityForm.get('mapperConfig.basic.lastNameAttributeKey').hasError('maxlength')) { + + {{ 'admin.oauth2.last-name-attribute-key-max-length' | translate }} + + } + +
+
+ + admin.oauth2.tenant-name-strategy + + @for (tenantNameStrategy of tenantNameStrategies; track tenantNameStrategy) { + + {{ tenantNameStrategy }} + + } + + + + admin.oauth2.tenant-name-pattern + + @if (entityForm.get('mapperConfig.basic.tenantNamePattern').hasError('required')) { + + {{ 'admin.oauth2.tenant-name-pattern-required' | translate }} + + } + @if (entityForm.get('mapperConfig.basic.tenantNamePattern').hasError('maxlength')) { + + {{ 'admin.oauth2.tenant-name-pattern-max-length' | translate }} + + } + +
+
+ + admin.oauth2.customer-name-pattern + + @if (entityForm.get('mapperConfig.basic.customerNamePattern').hasError('maxlength')) { + + {{ 'admin.oauth2.customer-name-pattern-max-length' | translate }} + + } + +
+
+ + admin.oauth2.default-dashboard-name + + @if (entityForm.get('mapperConfig.basic.defaultDashboardName').hasError('maxlength')) { + + {{ 'admin.oauth2.default-dashboard-name-max-length' | translate }} + + } + + + {{ 'admin.oauth2.always-fullscreen' | translate}} + +
+
+ } + @if (entityForm.get('mapperConfig.type').value === mapperType.CUSTOM && entityForm.get('mapperConfig.custom')) { +
+
+ + admin.oauth2.url + + @if (entityForm.get('mapperConfig.custom.url').hasError('required')) { + + {{ 'admin.oauth2.url-required' | translate }} + + } + @if (entityForm.get('mapperConfig.custom.url').hasError('pattern')) { + + {{ 'admin.oauth2.url-pattern' | translate }} + + } + @if (entityForm.get('mapperConfig.custom.url').hasError('maxlength')) { + + {{ 'admin.oauth2.url-max-length' | translate }} + + } + +
+
+ + common.username + + @if (entityForm.get('mapperConfig.custom.username').hasError('maxlength')) { + + {{ 'admin.oauth2.username-max-length' | translate }} + + } + + + common.password + + @if (entityForm.get('mapperConfig.custom.password').hasError('maxlength')) { + + {{ 'admin.oauth2.password-max-length' | translate }} + + } + +
+ + {{ 'admin.oauth2.send-token' | translate}} + +
+ }
-
+ }
diff --git a/ui-ngx/src/app/modules/home/pages/admin/oauth2/domains/domain.component.html b/ui-ngx/src/app/modules/home/pages/admin/oauth2/domains/domain.component.html index 0816cb6f5a..dc0726e68d 100644 --- a/ui-ngx/src/app/modules/home/pages/admin/oauth2/domains/domain.component.html +++ b/ui-ngx/src/app/modules/home/pages/admin/oauth2/domains/domain.component.html @@ -17,22 +17,28 @@ -->
- + admin.oauth2.domain-name - - {{ 'admin.oauth2.domain-name-required' | translate }} - - - {{ 'admin.domain-name-max-length' | translate }} - - - {{ 'admin.error-verification-url' | translate }} - + @if (entityForm.get('name').hasError('required')) { + + {{ 'admin.oauth2.domain-name-required' | translate }} + + } + @if (entityForm.get('name').hasError('maxlength')) { + + {{ 'admin.domain-name-max-length' | translate }} + + } + @if (entityForm.get('name').hasError('pattern')) { + + {{ 'admin.error-verification-url' | translate }} + + }
- + admin.oauth2.redirect-url-template
-
- + admin.jwt.expiration-time - - {{ 'admin.jwt.expiration-time-required' | translate }} - - - {{ 'admin.jwt.expiration-time-max' | translate }} - - - {{ 'admin.jwt.expiration-time-min' | translate }} - + @if (jwtSecuritySettingsFormGroup.get('tokenExpirationTime').hasError('required')) { + + {{ 'admin.jwt.expiration-time-required' | translate }} + + } + @if (jwtSecuritySettingsFormGroup.get('tokenExpirationTime').hasError('max')) { + + {{ 'admin.jwt.expiration-time-max' | translate }} + + } + @if (jwtSecuritySettingsFormGroup.get('tokenExpirationTime').hasError('min')) { + + {{ 'admin.jwt.expiration-time-min' | translate }} + + } - + admin.jwt.refresh-expiration-time - - {{ 'admin.jwt.refresh-expiration-time-required' | translate }} - - - {{ 'admin.jwt.refresh-expiration-time-max' | translate }} - - - {{ 'admin.jwt.refresh-expiration-time-min' | translate }} - - - {{ 'admin.jwt.refresh-expiration-time-less-token' | translate }} - + @if (jwtSecuritySettingsFormGroup.get('refreshTokenExpTime').hasError('required')) { + + {{ 'admin.jwt.refresh-expiration-time-required' | translate }} + + } + @if (jwtSecuritySettingsFormGroup.get('refreshTokenExpTime').hasError('max')) { + + {{ 'admin.jwt.refresh-expiration-time-max' | translate }} + + } + @if (jwtSecuritySettingsFormGroup.get('refreshTokenExpTime').hasError('min')) { + + {{ 'admin.jwt.refresh-expiration-time-min' | translate }} + + } + @if (jwtSecuritySettingsFormGroup.get('refreshTokenExpTime').hasError('lessToken')) { + + {{ 'admin.jwt.refresh-expiration-time-less-token' | translate }} + + }
diff --git a/ui-ngx/src/app/modules/home/pages/admin/security-settings.component.scss b/ui-ngx/src/app/modules/home/pages/admin/security-settings.component.scss index 406cd966e0..93eff35f8c 100644 --- a/ui-ngx/src/app/modules/home/pages/admin/security-settings.component.scss +++ b/ui-ngx/src/app/modules/home/pages/admin/security-settings.component.scss @@ -22,14 +22,18 @@ margin: 0; } - .fields-group { - padding: 8px 16px 0; - margin: 10px 0; - border: 1px groove rgba(0, 0, 0, .25); - border-radius: 4px; + .mat-mdc-card.settings-card { + fieldset { + .fields-group { + padding: 8px 16px 0; + margin: 10px 0; + border: 1px groove rgba(0, 0, 0, .25); + border-radius: 4px; - legend { - color: rgba(0, 0, 0, .7); + legend { + color: rgba(0, 0, 0, .7); + } + } } } } diff --git a/ui-ngx/src/app/modules/home/pages/admin/send-test-sms-dialog.component.html b/ui-ngx/src/app/modules/home/pages/admin/send-test-sms-dialog.component.html index c36923dc84..96975f3f70 100644 --- a/ui-ngx/src/app/modules/home/pages/admin/send-test-sms-dialog.component.html +++ b/ui-ngx/src/app/modules/home/pages/admin/send-test-sms-dialog.component.html @@ -25,25 +25,33 @@ close - - -
+ @if (isLoading$ | async) { + + + } @else { +
+ }
- + admin.sms-message - - {{ 'admin.sms-message-required' | translate }} - - - {{ 'admin.sms-message-max-length' | translate }} - + @if (sendTestSmsFormGroup.get('message').hasError('required')) { + + {{ 'admin.sms-message-required' | translate }} + + } + @if (sendTestSmsFormGroup.get('message').hasError('maxLength')) { + + {{ 'admin.sms-message-max-length' | translate }} + + }
diff --git a/ui-ngx/src/app/modules/home/pages/admin/sms-provider.component.html b/ui-ngx/src/app/modules/home/pages/admin/sms-provider.component.html index 2c38ad4ab8..a83292d2ea 100644 --- a/ui-ngx/src/app/modules/home/pages/admin/sms-provider.component.html +++ b/ui-ngx/src/app/modules/home/pages/admin/sms-provider.component.html @@ -15,35 +15,41 @@ limitations under the License. --> - - - - admin.sms-provider-settings - - -
-
- - -
- - - - -
- - -
- -
-
+@if (isSysAdmin()) { + + + + admin.sms-provider-settings + + +
+
+ @if (isLoading$ | async) { + + + } + @if (!(isLoading$ | async)) { +
+ } + +
+ + +
+ + +
+
+
+
+}
@@ -53,32 +59,40 @@
- - -
+ @if (isLoading$ | async) { + + + } + @if (!(isLoading$ | async)) { +
+ } - + admin.slack-api-token - - - admin.mobile-settings - - - - - - - - + @if (isSysAdmin()) { + + + admin.mobile-settings + + + + + } + @if (isSysAdmin()) { + + + + + }
diff --git a/ui-ngx/src/app/modules/home/pages/admin/two-factor-auth-settings.component.html b/ui-ngx/src/app/modules/home/pages/admin/two-factor-auth-settings.component.html index 2da7097509..46495abd5e 100644 --- a/ui-ngx/src/app/modules/home/pages/admin/two-factor-auth-settings.component.html +++ b/ui-ngx/src/app/modules/home/pages/admin/two-factor-auth-settings.component.html @@ -24,9 +24,13 @@
- - -
+ @if (isLoading$ | async) { + + + } + @if (!(isLoading$ | async)) { +
+ }
@@ -47,42 +51,45 @@ admin.2fa.enforce-for - - {{ notificationTargetConfigTypeInfoMap.get(type).name | translate }} - + @for (type of notificationTargetConfigTypes; track type) { + + {{ notificationTargetConfigTypeInfoMap.get(type).name | translate }} + + } -
-
- - {{ 'tenant.tenant' | translate }} - {{ 'tenant-profile.tenant-profile' | translate }} - -
- - - - - - - - -
+ @if (twoFaFormGroup.get('enforcedUsersFilter.type').value === notificationTargetConfigType.TENANT_ADMINISTRATORS) { +
+
+ + {{ 'tenant.tenant' | translate }} + {{ 'tenant-profile.tenant-profile' | translate }} + +
+ @if (twoFaFormGroup.get('enforcedUsersFilter.filterByTenants').value) { + + + } @else { + + + } +
+ }
@@ -93,42 +100,48 @@ @if (!atListOneProvider) { } - -
- - - - - {{ twoFactorAuthProvidersData.get(provider.value.providerType).name | translate }} - - - - - - - - - admin.2fa.issuer-name - - + @for (provider of providersForm.controls; track trackByElement(i, provider); let i = $index) { +
+ + + + + {{ twoFactorAuthProvidersData.get(provider.value.providerType).name | translate }} + + + + + @switch (provider.get('providerType').value) { + @case (twoFactorAuthProviderType.TOTP) { + + admin.2fa.issuer-name + + @if (provider.get('issuerName').hasError('required') || + provider.get('issuerName').hasError('pattern')) { + {{ "admin.2fa.issuer-name-required" | translate }} - - -
+ } + + } + @case (twoFactorAuthProviderType.SMS) { +
admin.2fa.verification-message-template - - {{ "admin.2fa.verification-message-template-required" | translate }} - - - {{ "admin.2fa.verification-message-template-pattern" | translate }} - + @if (provider.get('smsVerificationMessageTemplate').hasError('required')) { + + {{ "admin.2fa.verification-message-template-required" | translate }} + + } + @if (provider.get('smsVerificationMessageTemplate').hasError('pattern')) { + + {{ "admin.2fa.verification-message-template-pattern" | translate }} + + }
-
+ } + @case (twoFactorAuthProviderType.EMAIL) { +
-
+ } + @case (twoFactorAuthProviderType.BACKUP_CODE) { +
admin.2fa.number-of-codes - - {{ "admin.2fa.number-of-codes-required" | translate }} - - - {{ "admin.2fa.number-of-codes-pattern" | translate }} - + @if (provider.get('codesQuantity').hasError('required')) { + + {{ "admin.2fa.number-of-codes-required" | translate }} + + } + @if (provider.get('codesQuantity').hasError('min') || + provider.get('codesQuantity').hasError('pattern')) { + + {{ "admin.2fa.number-of-codes-pattern" | translate }} + + }
- - - -
- + } + } + + +
+ }
admin.2fa.verification-limitations
@@ -197,11 +219,13 @@ admin.2fa.max-verification-failures-before-user-lockout - - {{ 'admin.2fa.max-verification-failures-before-user-lockout-pattern' | translate }} - + || twoFaFormGroup.get('maxVerificationFailuresBeforeUserLockout').hasError('max')) { + + {{ 'admin.2fa.max-verification-failures-before-user-lockout-pattern' | translate }} + + }
@@ -219,13 +243,17 @@ admin.2fa.number-of-checking-attempts - - {{ 'admin.2fa.number-of-checking-attempts-required' | translate }} - - - {{ 'admin.2fa.number-of-checking-attempts-pattern' | translate }} - + @if (twoFaFormGroup.get('verificationCodeCheckRateLimitNumber').hasError('required')) { + + {{ 'admin.2fa.number-of-checking-attempts-required' | translate }} + + } + @if (twoFaFormGroup.get('verificationCodeCheckRateLimitNumber').hasError('pattern') + || twoFaFormGroup.get('verificationCodeCheckRateLimitNumber').hasError('min')) { + + {{ 'admin.2fa.number-of-checking-attempts-pattern' | translate }} + + } - - - diff --git a/ui-ngx/src/app/modules/home/pages/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()) { + + } - - -
+ @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..76349148fd 100644 --- a/ui-ngx/src/app/modules/home/pages/device-profile/device-profile-tabs.component.html +++ b/ui-ngx/src/app/modules/home/pages/device-profile/device-profile-tabs.component.html @@ -17,28 +17,36 @@ --> @if (entity) { -
- - device-profile.transport-type - - - {{deviceTransportTypeTranslations.get(type) | translate}} - - - - {{deviceTransportTypeHints.get(detailsForm.get('transportType').value) | translate}} - - - {{ 'device-profile.transport-type-required' | translate }} - - -
- - + @if (transportType.isActive) { +
+ + device-profile.transport-type + + @for (type of deviceTransportTypes; track type) { + + {{deviceTransportTypeTranslations.get(type) | translate}} + + } + + @if (detailsForm.get('transportType').value) { + + {{deviceTransportTypeHints.get(detailsForm.get('transportType').value) | translate}} + + } + @if (detailsForm.get('transportType').hasError('required')) { + + {{ 'device-profile.transport-type-required' | translate }} + + } + +
+ + +
-
+ } @if (authUser.authority === authorities.TENANT_ADMIN && !isEdit) { @@ -58,11 +66,13 @@
} @if (alarmRulesOldVersion || isEdit) { -
-
- + @if (alarmRules.isActive) { +
+
+ +
-
+ } } @else {
-
-
- - + @if (deviceProvisioning.isActive) { +
+
+ + +
-
+ } @if (!isEdit) { @@ -101,13 +113,15 @@ } } - -
-
- - +@if (false) { + +
+
+ + +
-
- + +} diff --git a/ui-ngx/src/app/modules/home/pages/device/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..ec05b5ae77 100644 --- a/ui-ngx/src/app/modules/home/pages/device/data/snmp-device-transport-configuration.component.html +++ b/ui-ngx/src/app/modules/home/pages/device/data/snmp-device-transport-configuration.component.html @@ -17,117 +17,149 @@ -->
- + device-profile.snmp.host - - {{ 'device-profile.snmp.host-required' | translate }} - + @if (snmpDeviceTransportConfigurationFormGroup.get('host').hasError('required') || + snmpDeviceTransportConfigurationFormGroup.get('host').hasError('pattern')) { + + {{ 'device-profile.snmp.host-required' | translate }} + + } - + device-profile.snmp.port - - {{ 'device-profile.snmp.port-required' | translate }} - - - {{ 'device-profile.snmp.port-format' | translate }} - + @if (snmpDeviceTransportConfigurationFormGroup.get('port').hasError('required')) { + + {{ 'device-profile.snmp.port-required' | translate }} + + } + @if (snmpDeviceTransportConfigurationFormGroup.get('port').hasError('pattern') || + snmpDeviceTransportConfigurationFormGroup.get('port').hasError('min')) { + + {{ 'device-profile.snmp.port-format' | translate }} + + }
- + device-profile.snmp.protocol-version - - {{ snmpDeviceProtocolVersion | lowercase }} - + @for (snmpDeviceProtocolVersion of snmpDeviceProtocolVersions; track snmpDeviceProtocolVersion) { + + {{ snmpDeviceProtocolVersion | lowercase }} + + } - - {{ 'device-profile.snmp.protocol-version-required' | translate }} - - -
- - device-profile.snmp.community - - - {{ 'device-profile.snmp.community-required' | translate }} + @if (snmpDeviceTransportConfigurationFormGroup.get('protocolVersion').hasError('required')) { + + {{ 'device-profile.snmp.protocol-version-required' | translate }} - -
-
-
- - device-profile.snmp.user-name - - - {{ 'device-profile.snmp.user-name-required' | translate }} - - - - device-profile.snmp.security-name - - - {{ 'device-profile.snmp.security-name-required' | translate }} - - -
-
- - device-profile.snmp.authentication-protocol - - - {{ snmpAuthenticationProtocolTranslation.get(snmpAuthenticationProtocol) }} - - - - {{ 'device-profile.snmp.authentication-protocol-required' | translate }} - - - - device-profile.snmp.authentication-passphrase - - - {{ 'device-profile.snmp.authentication-passphrase-required' | translate }} - - -
-
- - device-profile.snmp.privacy-protocol - - - {{ snmpPrivacyProtocolTranslation.get(snmpPrivacyProtocol) }} - - - - {{ 'device-profile.snmp.privacy-protocol-required' | translate }} - - - - device-profile.snmp.privacy-passphrase - - - {{ 'device-profile.snmp.privacy-passphrase-required' | translate }} - - -
-
- - device-profile.snmp.context-name - - - - device-profile.snmp.engine-id - + } + + @if (!isV3protocolVersion()) { +
+ + device-profile.snmp.community + + @if (snmpDeviceTransportConfigurationFormGroup.get('community').hasError('required') || + snmpDeviceTransportConfigurationFormGroup.get('community').hasError('pattern')) { + + {{ 'device-profile.snmp.community-required' | translate }} + + } -
-
+
+ } + @if (isV3protocolVersion()) { +
+
+ + device-profile.snmp.user-name + + @if (snmpDeviceTransportConfigurationFormGroup.get('username').hasError('required') || + snmpDeviceTransportConfigurationFormGroup.get('username').hasError('pattern')) { + + {{ 'device-profile.snmp.user-name-required' | translate }} + + } + + + device-profile.snmp.security-name + + @if (snmpDeviceTransportConfigurationFormGroup.get('securityName').hasError('required') || + snmpDeviceTransportConfigurationFormGroup.get('securityName').hasError('pattern')) { + + {{ 'device-profile.snmp.security-name-required' | translate }} + + } + +
+
+ + device-profile.snmp.authentication-protocol + + @for (snmpAuthenticationProtocol of snmpAuthenticationProtocols; track snmpAuthenticationProtocol) { + + {{ snmpAuthenticationProtocolTranslation.get(snmpAuthenticationProtocol) }} + + } + + @if (snmpDeviceTransportConfigurationFormGroup.get('authenticationProtocol').hasError('required')) { + + {{ 'device-profile.snmp.authentication-protocol-required' | translate }} + + } + + + device-profile.snmp.authentication-passphrase + + @if (snmpDeviceTransportConfigurationFormGroup.get('authenticationPassphrase').hasError('required') || + snmpDeviceTransportConfigurationFormGroup.get('authenticationPassphrase').hasError('pattern')) { + + {{ 'device-profile.snmp.authentication-passphrase-required' | translate }} + + } + +
+
+ + device-profile.snmp.privacy-protocol + + @for (snmpPrivacyProtocol of snmpPrivacyProtocols; track snmpPrivacyProtocol) { + + {{ snmpPrivacyProtocolTranslation.get(snmpPrivacyProtocol) }} + + } + + @if (snmpDeviceTransportConfigurationFormGroup.get('privacyProtocol').hasError('required')) { + + {{ 'device-profile.snmp.privacy-protocol-required' | translate }} + + } + + + device-profile.snmp.privacy-passphrase + + @if (snmpDeviceTransportConfigurationFormGroup.get('privacyPassphrase').hasError('required') || + snmpDeviceTransportConfigurationFormGroup.get('privacyPassphrase').hasError('pattern')) { + + {{ 'device-profile.snmp.privacy-passphrase-required' | translate }} + + } + +
+
+ + device-profile.snmp.context-name + + + + device-profile.snmp.engine-id + + +
+
+ } diff --git a/ui-ngx/src/app/modules/home/pages/device/device-check-connectivity-dialog.component.html b/ui-ngx/src/app/modules/home/pages/device/device-check-connectivity-dialog.component.html index a9ff103c7d..449187e220 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..06c1470ff6 100644 --- a/ui-ngx/src/app/modules/home/pages/entity-view/entity-view.component.html +++ b/ui-ngx/src/app/modules/home/pages/entity-view/entity-view.component.html @@ -64,7 +64,7 @@
- entity-view.assignedToCustomer @@ -75,29 +75,30 @@ {{ 'entity-view.entity-view-public' | translate }}
-
- +
+ entity-view.name - - {{ 'entity-view.name-required' | translate }} - - - {{ 'entity-view.name-max-length' | translate }} - + @if (entityForm.get('name').hasError('required')) { + {{ 'entity-view.name-required' | translate }} + } + @if (entityForm.get('name').hasError('maxlength')) { + {{ 'entity-view.name-max-length' | translate }} + } + [entityType]="entityType.ENTITY_VIEW">
- + + formControlName="entityId" + appearance="outline">
@@ -109,26 +110,26 @@
entity-view.attributes-propagation-hint
- + label="entity-view.client-attributes" + [dataKeyType]="dataKeyType.attribute" + appearance="outline"> - + label="entity-view.shared-attributes" + [dataKeyType]="dataKeyType.attribute" + appearance="outline"> - + label="entity-view.server-attributes" + [dataKeyType]="dataKeyType.attribute" + appearance="outline"> @@ -138,12 +139,12 @@
entity-view.timeseries-data-hint
- + label="entity-view.timeseries" + [dataKeyType]="dataKeyType.timeseries" + appearance="outline">
@@ -152,16 +153,18 @@ dateText="{{ 'entity-view.start-ts' | translate }}" [maxDate]="maxStartTimeMs | async" formControlName="startTimeMs" + appearance="outline" >
- + entity-view.description - +
diff --git a/ui-ngx/src/app/modules/home/pages/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/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/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 }} + +
+ } + } +
-
-
+ } +
- - {{ 'notification.compose' | translate }} - -
- - -
-
-
+ @if (!notificationRequestForm.get('useTemplate').value) { + + {{ 'notification.compose' | translate }} + +
+ + +
+
+
+ } {{ 'notification.review' | translate }} -
- - -
-
-
-
- supervisor_account -
{{ 'notification.recipients-count' | translate : {count: preview.totalRecipientsCount} }}
-
-
-
- {{ detail.value }}{{ detail.key }} + @if ((isLoading$ | async) && !preview) { +
+ + +
+ } + @if (preview) { +
+
+
+ supervisor_account +
{{ 'notification.recipients-count' | translate : {count: preview.totalRecipientsCount} }}
-
- - - - {{ recipientTitle }} - - -
-
-
- mdi:bell-badge -
notification.delivery-method.web-preview
-
-
- -
-
-
-
- mdi:cellphone-text -
notification.delivery-method.mobile-app-preview
-
-
-
{{ preview.processedTemplates.MOBILE_APP.subject }}
-
{{ preview.processedTemplates.MOBILE_APP.body }}
-
-
-
-
- mdi:message-processing -
notification.delivery-method.sms-preview
-
-
- {{ preview.processedTemplates.SMS.body }} -
-
-
-
- mdi:email -
notification.delivery-method.email-preview
-
-
-
{{ preview.processedTemplates.EMAIL.subject }}
- - -
-
-
-
- mdi:slack -
notification.delivery-method.slack-preview
-
-
- {{ preview.processedTemplates.SLACK.body }} -
-
-
-
- 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 (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 - + user.last-name - + language.language @@ -68,7 +77,7 @@ } - + unit.unit-system @@ -78,20 +87,23 @@ } -
- - - {{ 'dashboard.home-dashboard-hide-toolbar' | translate }} - -
+ @if (!isSysAdmin()) { +
+ + + {{ 'dashboard.home-dashboard-hide-toolbar' | translate }} + +
+ }
+ +
+ + +
+
+ + +
diff --git a/ui-ngx/src/app/modules/home/pages/rulechain/add-note-dialog.component.scss b/ui-ngx/src/app/modules/home/pages/rulechain/add-note-dialog.component.scss new file mode 100644 index 0000000000..fa37306f7b --- /dev/null +++ b/ui-ngx/src/app/modules/home/pages/rulechain/add-note-dialog.component.scss @@ -0,0 +1,30 @@ +/** + * Copyright © 2016-2026 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +:host { + display: grid; + width: 650px; + height: 100%; + max-width: 100%; + max-height: 100vh; + grid-template-rows: min-content minmax(auto, 1fr) min-content; +} + +:host-context(.tb-fullscreen-dialog .mat-mdc-dialog-container) { + .mat-mdc-dialog-content { + padding: 8px; + } +} \ No newline at end of file diff --git a/ui-ngx/src/app/modules/home/pages/rulechain/add-rule-node-dialog.component.html b/ui-ngx/src/app/modules/home/pages/rulechain/add-rule-node-dialog.component.html index 8156c1690d..41e72a14cf 100644 --- a/ui-ngx/src/app/modules/home/pages/rulechain/add-rule-node-dialog.component.html +++ b/ui-ngx/src/app/modules/home/pages/rulechain/add-rule-node-dialog.component.html @@ -26,8 +26,10 @@ close - - + @if (isLoading$ | async) { + + + }
close - - + @if (isLoading$ | async) { + + + }
close - - + @if (isLoading$ | async) { + + + }
- + rulechain.name - - {{ 'rulechain.name-required' | translate }} - - - {{ 'rulechain.name-max-length' | translate }} - + @if (createNestedRuleChainFormGroup.get('name').hasError('required')) { + + {{ 'rulechain.name-required' | translate }} + + } + @if (createNestedRuleChainFormGroup.get('name').hasError('maxlength')) { + + {{ 'rulechain.name-max-length' | translate }} + + }
- + rulechain.description diff --git a/ui-ngx/src/app/modules/home/pages/rulechain/link-labels.component.html b/ui-ngx/src/app/modules/home/pages/rulechain/link-labels.component.html index 65aabc17aa..f6e244a81b 100644 --- a/ui-ngx/src/app/modules/home/pages/rulechain/link-labels.component.html +++ b/ui-ngx/src/app/modules/home/pages/rulechain/link-labels.component.html @@ -15,16 +15,19 @@ limitations under the License. --> - + rulenode.link-labels - - {{label.name}} - close - + @for (label of labels; track label) { + + {{label.name}} + @if (!disabled) { + close + } + + } - - - - -
-
- rulenode.no-link-labels-found + @for (label of filteredLabels | async; track label) { + + + + } + @if ((filteredLabels | async)?.length === 0) { + +
+ @if (!textIsNotEmpty(searchText)) { +
+ rulenode.no-link-labels-found +
+ } @else { + + {{ translate.get('rulenode.no-link-label-matching', + {label: truncate.transform(searchText, true, 6, '...')}) | async }} + + } + @if (allowCustom) { + + rulenode.create-new-link-label + + }
- - - {{ translate.get('rulenode.no-link-label-matching', - {label: truncate.transform(searchText, true, 6, '...')}) | async }} - - - - rulenode.create-new-link-label - -
- + + } diff --git a/ui-ngx/src/app/modules/home/pages/rulechain/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..a83c1f8b17 100644 --- a/ui-ngx/src/app/modules/home/pages/rulechain/rule-node-details.component.html +++ b/ui-ngx/src/app/modules/home/pages/rulechain/rule-node-details.component.html @@ -15,43 +15,48 @@ limitations under the License. --> -
- -
+@if (!isAdd && ruleNode.component?.configurationDescriptor?.nodeDefinition?.ruleChainNode) { +
+ +
+}
- + rulenode.name - - {{ 'rulenode.name-required' | translate }} - - - {{ 'rulenode.name-max-length' | translate }} - + @if (ruleNodeFormGroup.get('name').hasError('required') || ruleNodeFormGroup.get('name').hasError('pattern')) { + + {{ 'rulenode.name-required' | translate }} + + } + @if (ruleNodeFormGroup.get('name').hasError('maxlength')) { + + {{ 'rulenode.name-max-length' | translate }} + + }
- + [entityType]="entityType.RULE_NODE"/> + @if (isSingleton()) { + + }
- - + @if (isAddQueue()) { + + + }
- + rulenode.rule-node-description - +
diff --git a/ui-ngx/src/app/modules/home/pages/rulechain/rule-note-editor.component.html b/ui-ngx/src/app/modules/home/pages/rulechain/rule-note-editor.component.html new file mode 100644 index 0000000000..015c2a38d4 --- /dev/null +++ b/ui-ngx/src/app/modules/home/pages/rulechain/rule-note-editor.component.html @@ -0,0 +1,59 @@ + + + + +
+
{{ 'rulechain.note-background-color' | translate }}
+ + +
+
+ + + rule-node-config.advanced-settings + +
+
{{ 'rulechain.note-border' | translate }}
+
+ + + px + + + +
+
+
+ + {{ 'rulechain.note-apply-default-markdown-style' | translate }} + +
+ + +
+
+ diff --git a/ui-ngx/src/app/modules/home/components/filter/filter-dialog.component.scss b/ui-ngx/src/app/modules/home/pages/rulechain/rule-note-editor.component.scss similarity index 80% rename from ui-ngx/src/app/modules/home/components/filter/filter-dialog.component.scss rename to ui-ngx/src/app/modules/home/pages/rulechain/rule-note-editor.component.scss index 7b620f8c2f..6d89be41ad 100644 --- a/ui-ngx/src/app/modules/home/components/filter/filter-dialog.component.scss +++ b/ui-ngx/src/app/modules/home/pages/rulechain/rule-note-editor.component.scss @@ -13,16 +13,12 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -:host { - .tb-editable-switch { - padding-left: 10px; - - .editable-switch { - margin: 0; - } - - .editable-label { - margin: 5px 0; +:host ::ng-deep { + tb-markdown-editor { + .markdown-content.tb-edit-mode { + &:not(.tb-fullscreen) { + padding-bottom: 0; + } } } -} +} \ No newline at end of file diff --git a/ui-ngx/src/app/modules/home/pages/rulechain/rule-note-editor.component.ts b/ui-ngx/src/app/modules/home/pages/rulechain/rule-note-editor.component.ts new file mode 100644 index 0000000000..a5a6bb1622 --- /dev/null +++ b/ui-ngx/src/app/modules/home/pages/rulechain/rule-note-editor.component.ts @@ -0,0 +1,106 @@ +/// +/// Copyright © 2016-2026 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +/// See the License for the specific language governing permissions and +/// limitations under the License. +/// + +import { Component, DestroyRef, inject, Input, OnChanges, OnInit, SimpleChanges, ViewChild } from '@angular/core'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; +import { MatExpansionPanel } from '@angular/material/expansion'; +import { FormBuilder, Validators } from '@angular/forms'; +import { + FC_RULE_NOTE_DEFAULT_APPLY_MARKDOWN_STYLE as DEFAULT_APPLY_MARKDOWN_STYLE, + FC_RULE_NOTE_DEFAULT_BACKGROUND_COLOR as DEFAULT_BACKGROUND_COLOR, + FC_RULE_NOTE_DEFAULT_BORDER_WIDTH as DEFAULT_BORDER_WIDTH, + FcRuleNote +} from '@shared/models/rule-node.models'; +import { isNotEmptyStr } from '@core/utils'; +import tinycolor from 'tinycolor2'; + +@Component({ + selector: 'tb-rule-note-editor', + templateUrl: './rule-note-editor.component.html', + styleUrls: ['./rule-note-editor.component.scss'], + standalone: false +}) +export class RuleNoteEditorComponent implements OnChanges, OnInit { + + @Input() + note: FcRuleNote; + + @ViewChild('advancedPanel') advancedPanel: MatExpansionPanel; + + private fb = inject(FormBuilder); + private destroyRef = inject(DestroyRef); + + noteForm = this.fb.group({ + content: ['', Validators.maxLength(65536)], + backgroundColor: [DEFAULT_BACKGROUND_COLOR], + borderColor: [tinycolor(DEFAULT_BACKGROUND_COLOR).darken(20).toString()], + borderWidth: [DEFAULT_BORDER_WIDTH, [Validators.min(0)]], + applyDefaultMarkdownStyle: [DEFAULT_APPLY_MARKDOWN_STYLE], + markdownCss: ['', Validators.maxLength(65536)] + }); + + ngOnInit(): void { + this.noteForm.get('backgroundColor').valueChanges + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe(color => { + const borderControl = this.noteForm.get('borderColor'); + if (borderControl.pristine) { + borderControl.setValue(this.borderColorFrom(color), { emitEvent: false }); + } + }); + } + + ngOnChanges(changes: SimpleChanges): void { + if (changes.note) { + this.updatedForm(); + } + } + + private borderColorFrom(bg: string): string { + const color = tinycolor(bg || DEFAULT_BACKGROUND_COLOR); + return color.isDark() ? color.lighten(20).toString() : color.darken(20).toString(); + } + + private updatedForm(): void { + const bg = this.note?.backgroundColor || DEFAULT_BACKGROUND_COLOR; + this.noteForm.setValue({ + content: this.note?.content || '', + backgroundColor: bg, + borderColor: this.note?.borderColor || this.borderColorFrom(bg), + borderWidth: this.note?.borderWidth ?? DEFAULT_BORDER_WIDTH, + applyDefaultMarkdownStyle: this.note?.applyDefaultMarkdownStyle ?? DEFAULT_APPLY_MARKDOWN_STYLE, + markdownCss: this.note?.markdownCss || '' + }); + const shouldExpand = this.hasNonDefaultAdvancedSettings(bg); + setTimeout(() => { + if (shouldExpand) { + this.advancedPanel?.open(); + } else { + this.advancedPanel?.close(); + } + this.noteForm.markAsPristine(); + }, 0); + } + + private hasNonDefaultAdvancedSettings(bg: string): boolean { + const note = this.note; + if (!note) return false; + return (note.borderColor != null && note.borderColor !== this.borderColorFrom(bg)) + || (note.borderWidth != null && note.borderWidth !== DEFAULT_BORDER_WIDTH) + || note.applyDefaultMarkdownStyle === false + || isNotEmptyStr(note.markdownCss); + } +} diff --git a/ui-ngx/src/app/modules/home/pages/rulechain/rulechain-page.component.html b/ui-ngx/src/app/modules/home/pages/rulechain/rulechain-page.component.html index 9d855647fa..e506e8d87c 100644 --- a/ui-ngx/src/app/modules/home/pages/rulechain/rulechain-page.component.html +++ b/ui-ngx/src/app/modules/home/pages/rulechain/rulechain-page.component.html @@ -28,14 +28,15 @@ opened position="start"> - - + @if (!isImport) { + + + }
@@ -59,37 +60,40 @@
- - - - {{ ruleNodeTypeDescriptorsMap.get(ruleNodeType).icon }} -
{{ ruleNodeTypeDescriptorsMap.get(ruleNodeType).name }}
-
-
- - -
+ @for (ruleNodeType of ruleNodeTypesLibraryArray; track ruleNodeType) { + + + + {{ ruleNodeTypeDescriptorsMap.get(ruleNodeType).icon }} +
{{ ruleNodeTypeDescriptorsMap.get(ruleNodeType).name }}
+
+
+ + +
+ }
- -
-
-
- - - - - - - - - - -
-
-
{{editingRuleNode.component.name}}
-
 
-
-
 
-
+
+
+
+ + + + + + @if (editingRuleNode.ruleNodeId) { + + + + + } + +
+
+
{{editingRuleNode.component.name}}
+
 
+
+
 
+
+
-
- - - - -
-
-
- - -
+ + + + } + @if (editingRuleNodeLink) { + +
+
+
+ + +
+ } + @if (editingNote) { + + + + + } - + @if (!isImport) { + + + } -
+ @for (menuItem of contextInfo.menuItems; track menuItem) { +
+ @if (menuItem.divider) { + + } + @if (!menuItem.divider) { + + } +
+ }
diff --git a/ui-ngx/src/app/modules/home/pages/rulechain/rulechain-page.component.scss b/ui-ngx/src/app/modules/home/pages/rulechain/rulechain-page.component.scss index 4152ca767a..b38fe007e8 100644 --- a/ui-ngx/src/app/modules/home/pages/rulechain/rulechain-page.component.scss +++ b/ui-ngx/src/app/modules/home/pages/rulechain/rulechain-page.component.scss @@ -54,6 +54,15 @@ z-index: 2; } + button.mdc-fab.add-note-button { + position: absolute; + top: 10px; + right: 110px; + opacity: .85; + margin: 0 6px; + z-index: 2; + } + .tb-library-node-btn { width: 20px; height: 90px; @@ -188,6 +197,20 @@ margin: -3px; border: solid 3px #f00; } + &.fc-edit { + margin: -2px; + border: solid 2px rgba(0, 0, 0, 0.35); + } + } + } + + .fc-note { + border-radius: 8px; + &.fc-selected:not(.fc-edit) { + box-shadow: 0 0 0 3px #f00; + } + &.fc-selected.fc-edit { + box-shadow: 0 0 0 2px rgba(0, 0, 0, 0.35); } } diff --git a/ui-ngx/src/app/modules/home/pages/rulechain/rulechain-page.component.ts b/ui-ngx/src/app/modules/home/pages/rulechain/rulechain-page.component.ts index 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 { +
+}
@@ -37,16 +40,20 @@

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

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

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

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

- + - - {{ 'security.2fa.dialog.verification-code-invalid' | translate }} - + @if (totpConfigForm.get('verificationCode').invalid) { + + {{ 'security.2fa.dialog.verification-code-invalid' | translate }} + + }
diff --git a/ui-ngx/src/app/modules/home/pages/security/security.component.html b/ui-ngx/src/app/modules/home/pages/security/security.component.html index c5afb7d1ea..cb9785897f 100644 --- a/ui-ngx/src/app/modules/home/pages/security/security.component.html +++ b/ui-ngx/src/app/modules/home/pages/security/security.component.html @@ -50,45 +50,57 @@

profile.change-password

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

security.password-requirement.password-requirements

-

security.password-requirement.at-least

-

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

-

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

-

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

-

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

-

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

-
-

security.password-requirement.at-most

-

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

+ @if (passwordPolicy) { +
+

security.password-requirement.password-requirements

+

security.password-requirement.at-least

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

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

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

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

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

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

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

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

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

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

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

security.password-requirement.at-most

+

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

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

security.2fa.authenticate-with

-
- -
-

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

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

security.2fa.authenticate-with

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

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

+
+ @if (!twoFactorAuth.get(provider).value) { +
+ {{ providersData.get(provider).description | translate }} +
+ } @else { +
+ {{ providersData.get(provider).activatedHint | translate: providerDataInfo(provider) }} +
+ } + +
- -
- {{ providersData.get(provider).activatedHint | translate: providerDataInfo(provider) }} -
-
- - + @if (twoFactorAuth.get(provider).value && provider !== twoFactorAuthProviderType.BACKUP_CODE && !activeSingleProvider) { + + security.2fa.main-2fa-method + + } + @if (twoFactorAuth.get(provider).value && provider === twoFactorAuthProviderType.BACKUP_CODE) { + + }
- - security.2fa.main-2fa-method - - -
- - - - + @if (!$last) { + + } + } + + + }
diff --git a/ui-ngx/src/app/modules/home/pages/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..8e7b639c38 100644 --- a/ui-ngx/src/app/modules/home/pages/tenant/tenant.component.html +++ b/ui-ngx/src/app/modules/home/pages/tenant/tenant.component.html @@ -48,17 +48,22 @@
- + tenant.title - - {{ 'tenant.title-required' | translate }} - - - {{ 'tenant.title-max-length' | translate }} - + @if (entityForm.get('title').hasError('required')) { + + {{ 'tenant.title-required' | translate }} + + } + @if (entityForm.get('title').hasError('maxlength')) { + + {{ 'tenant.title-max-length' | translate }} + + }
- + @if (entity?.id) { +
+
+ + + {{ 'dashboard.home-dashboard-hide-toolbar' | translate }} + +
+
+ } + + tenant.description - + -
-
- - - {{ 'dashboard.home-dashboard-hide-toolbar' | translate }} - -
-
-
diff --git a/ui-ngx/src/app/modules/home/pages/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..1b316cdc81 100644 --- a/ui-ngx/src/app/modules/home/pages/widget/save-widget-type-as-dialog.component.html +++ b/ui-ngx/src/app/modules/home/pages/widget/save-widget-type-as-dialog.component.html @@ -25,20 +25,26 @@ close - - -
+ @if (isLoading$ | async) { + + + } @else { +
+ }
{{ dialogTitle }} - + widget.title - - {{ 'widget.title-required' | translate }} - + @if (saveWidgetTypeAsFormGroup.get('title').hasError('required')) { + + {{ 'widget.title-required' | translate }} + + } diff --git a/ui-ngx/src/app/modules/home/pages/widget/select-widget-type-dialog.component.html b/ui-ngx/src/app/modules/home/pages/widget/select-widget-type-dialog.component.html index ba70e617eb..60ecf4ea6d 100644 --- a/ui-ngx/src/app/modules/home/pages/widget/select-widget-type-dialog.component.html +++ b/ui-ngx/src/app/modules/home/pages/widget/select-widget-type-dialog.component.html @@ -26,20 +26,25 @@ close - - -
+ @if (isLoading$ | async) { + + + } @else { +
+ }
- + @for (type of allWidgetTypes; track type) { + + }
diff --git a/ui-ngx/src/app/modules/home/pages/widget/widget-editor.component.html b/ui-ngx/src/app/modules/home/pages/widget/widget-editor.component.html index c9c3f634ae..685f0f3336 100644 --- a/ui-ngx/src/app/modules/home/pages/widget/widget-editor.component.html +++ b/ui-ngx/src/app/modules/home/pages/widget/widget-editor.component.html @@ -27,9 +27,11 @@ - - {{ widgetTypesDataMap.get(widgetTypes[type]).name | translate }} - + @for (type of allWidgetTypes; track type) { + + {{ widgetTypesDataMap.get(widgetTypes[type]).name | translate }} + + } @@ -48,15 +50,17 @@ undo action.undo - + @if (!isReadOnly) { + + } - + @if (!isReadOnly) { + + } -
+ @for (resource of widget.resources; track resource; let i = $index) { +
+ + + + {{ 'widget.resource-is-extension' | translate }} + + +
+ } + @if (selectWidgetTypeFormGroup.get('widgetType').value && !disabled) { + + } - -
- {{ widgetType.name }} -
-
-
{{ 'widget.deprecated' | translate }}
+ @for (widgetType of filteredWidgetTypes | async; track widgetType) { + +
+ {{ widgetType.name }} +
+
+ @if (widgetType.deprecated) { +
{{ 'widget.deprecated' | translate }}
+ } +
-
- - -
-
- {{ 'widget.no-widgets-text' | translate }} + + } + @if (!(filteredWidgetTypes | async)?.length) { + +
+ @if (!textIsNotEmpty(searchText)) { +
+ {{ 'widget.no-widgets-text' | translate }} +
+ } @else { + + {{ translate.get('widget.no-widgets-matching', {entity: searchText}) | async }} + + }
- - - {{ translate.get('widget.no-widgets-matching', {entity: searchText}) | async }} - - -
- + + } diff --git a/ui-ngx/src/app/modules/home/pages/widget/widget-type-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..7f607c1730 100644 --- a/ui-ngx/src/app/modules/home/pages/widget/widgets-bundle.component.html +++ b/ui-ngx/src/app/modules/home/pages/widget/widgets-bundle.component.html @@ -15,61 +15,67 @@ limitations under the License. --> -
- - - - -
+@if (!standalone) { +
+ + + + +
+}
- + widgets-bundle.title - - {{ 'widgets-bundle.title-required' | translate }} - - - {{ 'widgets-bundle.title-max-length' | translate }} - + @if (entityForm.get('title').hasError('required')) { + + {{ 'widgets-bundle.title-required' | translate }} + + } + @if (entityForm.get('title').hasError('maxlength')) { + + {{ 'widgets-bundle.title-max-length' | translate }} + + } - - widgets-bundle.description - - {{descriptionInput.value?.length || 0}}/1024 - {{ 'widgets-bundle.scada' | translate }} - + widgets-bundle.order + + widgets-bundle.description + + {{descriptionInput.value?.length || 0}}/1024 +
diff --git a/ui-ngx/src/app/modules/login/pages/login/create-password.component.html b/ui-ngx/src/app/modules/login/pages/login/create-password.component.html index 2d060483b1..a73d8b3a25 100644 --- a/ui-ngx/src/app/modules/login/pages/login/create-password.component.html +++ b/ui-ngx/src/app/modules/login/pages/login/create-password.component.html @@ -21,9 +21,13 @@ login.create-password
- - - + @if (isLoading || (isLoading$ | async)) { + + + } + @if (!isLoading && !(isLoading$ | async)) { + + }
@@ -39,18 +43,22 @@ formControlName="newPassword"/> lock - - {{ 'security.password-requirement.password-not-meet-requirements' | translate }} - + @if (createPassword.get('newPassword').errors) { + + {{ 'security.password-requirement.password-not-meet-requirements' | translate }} + + } login.password-again lock - - {{ 'security.password-requirement.new-passwords-not-match' | translate }} - + @if (createPassword.get('newPassword2').hasError('passwordsNotMatch')) { + + {{ 'security.password-requirement.new-passwords-not-match' | translate }} + + }
+ [passwordControl]="createPassword.get('newPassword')" + [passwordPolicy]="passwordPolicy" + [trigger]="passwordTrigger"> diff --git a/ui-ngx/src/app/modules/login/pages/login/force-two-factor-auth-login.component.html b/ui-ngx/src/app/modules/login/pages/login/force-two-factor-auth-login.component.html index 495d6fa0cb..7126e6b3a2 100644 --- a/ui-ngx/src/app/modules/login/pages/login/force-two-factor-auth-login.component.html +++ b/ui-ngx/src/app/modules/login/pages/login/force-two-factor-auth-login.component.html @@ -76,7 +76,7 @@ tooltipPosition="above" icon="content_copy" [style]="{'font-size': '24px', color: 'rgba(255,255,255,.8)'}" - > + >
@@ -120,6 +120,7 @@ label="{{ 'login.phone-input.phone-input-label' | translate }}" hint="login.phone-input.phone-input-hint" requiredErrorText="{{ 'login.phone-input.phone-input-required' | translate }}" + appearance="outline" validationErrorText="{{ 'login.phone-input.phone-input-validation' | translate }}" formControlName="phone" [floatLabel]="'auto'"> @@ -167,12 +168,16 @@ type="email" required autocomplete="email" placeholder="{{ 'login.email-label' | translate }}" /> - - {{ 'login.email-required' | translate }} - - - {{ 'login.invalid-email-format' | translate }} - + @if (emailConfigForm.get('email').hasError('required')) { + + {{ 'login.email-required' | translate }} + + } + @if (emailConfigForm.get('email').hasError('email')) { + + {{ 'login.invalid-email-format' | translate }} + + }
@@ -267,9 +272,9 @@ autocomplete="one-time-code" placeholder="{{ 'login.verification-code' | translate }}"> @if (configForm.get('verificationCode').getError('required') || - configForm.get('verificationCode').getError('minlength') || - configForm.get('verificationCode').getError('maxlength') || - configForm.get('verificationCode').getError('pattern')) { + configForm.get('verificationCode').getError('minlength') || + configForm.get('verificationCode').getError('maxlength') || + configForm.get('verificationCode').getError('pattern')) { {{ 'login.verification-code-invalid' | translate }} } @if (configForm.get('verificationCode').getError('incorrectCode')) { diff --git a/ui-ngx/src/app/modules/login/pages/login/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..95c16c2662 100644 --- a/ui-ngx/src/app/shared/components/color-input.component.html +++ b/ui-ngx/src/app/shared/components/color-input.component.html @@ -15,26 +15,37 @@ limitations under the License. --> - - - {{icon}} - {{label}} - -
-
-
- - - - {{ requiredText }} - -
- +@if (!asBoxInput) { + + @if (icon || label) { + + @if (icon) { + {{icon}} + } + @if (label) { + {{label}} + } + + } +
+
+
+ + @if (colorClearButton && !disabled) { + + } + @if (colorFormGroup.get('color').hasError('required')) { + + {{ requiredText }} + + } +
+} @else {
- +} diff --git a/ui-ngx/src/app/shared/components/color-input.component.ts b/ui-ngx/src/app/shared/components/color-input.component.ts index dd5dd847a3..77e5668dfc 100644 --- a/ui-ngx/src/app/shared/components/color-input.component.ts +++ b/ui-ngx/src/app/shared/components/color-input.component.ts @@ -42,6 +42,7 @@ import { coerceBoolean } from '@shared/decorators/coercion'; import { TbPopoverService } from '@shared/components/popover.service'; import { ColorPickerPanelComponent } from '@shared/components/color-picker/color-picker-panel.component'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; +import { MatFormFieldAppearance } from '@angular/material/form-field'; @Component({ selector: 'tb-color-input', @@ -83,6 +84,9 @@ export class ColorInputComponent extends PageComponent implements OnInit, Contro @coerceBoolean() noBorder = false; + @Input() + appearance: MatFormFieldAppearance = 'fill'; + private requiredValue: boolean; get required(): boolean { return this.requiredValue; diff --git a/ui-ngx/src/app/shared/components/color-picker/color-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/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/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/dialog/node-script-test-dialog.component.html b/ui-ngx/src/app/shared/components/dialog/node-script-test-dialog.component.html index 16ef833ec3..0878443cfa 100644 --- a/ui-ngx/src/app/shared/components/dialog/node-script-test-dialog.component.html +++ b/ui-ngx/src/app/shared/components/dialog/node-script-test-dialog.component.html @@ -34,9 +34,10 @@
-
+
@@ -60,7 +61,8 @@
diff --git a/ui-ngx/src/app/shared/components/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-keys-list.component.html b/ui-ngx/src/app/shared/components/entity/entity-keys-list.component.html index f48bf48804..8ecebe7d0d 100644 --- a/ui-ngx/src/app/shared/components/entity/entity-keys-list.component.html +++ b/ui-ngx/src/app/shared/components/entity/entity-keys-list.component.html @@ -15,15 +15,21 @@ limitations under the License. --> - - - - {{key}} - close - + + @if (label) { + {{ label | translate }} +} + + @for (key of modelValue; track key) { + + {{key}} + @if (!disabled) { + close + } + + } - - - + @for (key of filteredKeys | async; track key) { + + + + } diff --git a/ui-ngx/src/app/shared/components/entity/entity-keys-list.component.ts b/ui-ngx/src/app/shared/components/entity/entity-keys-list.component.ts index a034af3825..5aa5cf3eb5 100644 --- a/ui-ngx/src/app/shared/components/entity/entity-keys-list.component.ts +++ b/ui-ngx/src/app/shared/components/entity/entity-keys-list.component.ts @@ -29,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', @@ -62,9 +63,15 @@ export class EntityKeysListComponent implements ControlValueAccessor, OnInit, Af @Input() keysText: string; + @Input() + label: string; + @Input() dataKeyType: DataKeyType; + @Input() + appearance: MatFormFieldAppearance = 'fill'; + private requiredValue: boolean; get required(): boolean { return this.requiredValue; diff --git a/ui-ngx/src/app/shared/components/entity/entity-list-select.component.html b/ui-ngx/src/app/shared/components/entity/entity-list-select.component.html index a6eec3ec29..034f118b4d 100644 --- a/ui-ngx/src/app/shared/components/entity/entity-list-select.component.html +++ b/ui-ngx/src/app/shared/components/entity/entity-list-select.component.html @@ -16,28 +16,32 @@ -->
- - - - + @if (displayEntityTypeSelect) { + + + } + @if (modelValue.entityType) { + + + }
diff --git a/ui-ngx/src/app/shared/components/entity/entity-list-select.component.ts b/ui-ngx/src/app/shared/components/entity/entity-list-select.component.ts index 7ceaee14d7..1bf532af2e 100644 --- a/ui-ngx/src/app/shared/components/entity/entity-list-select.component.ts +++ b/ui-ngx/src/app/shared/components/entity/entity-list-select.component.ts @@ -21,6 +21,7 @@ import { EntityService } from '@core/http/entity.service'; import { EntityId } from '@shared/models/id/entity-id'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { isDefinedAndNotNull } from '@core/utils'; +import { MatFormFieldAppearance } from '@angular/material/form-field'; interface EntityListSelectModel { entityType: EntityType | AliasEntityType; @@ -72,6 +73,9 @@ export class EntityListSelectComponent implements ControlValueAccessor, OnInit { @Input({transform: booleanAttribute}) useEntityDisplayName = false; + @Input() + appearance: MatFormFieldAppearance = 'fill'; + displayEntityTypeSelect: boolean; private defaultEntityType: EntityType | AliasEntityType = null; diff --git a/ui-ngx/src/app/shared/components/entity/entity-list.component.html b/ui-ngx/src/app/shared/components/entity/entity-list.component.html index 589019b9fa..8781908d82 100644 --- a/ui-ngx/src/app/shared/components/entity/entity-list.component.html +++ b/ui-ngx/src/app/shared/components/entity/entity-list.component.html @@ -21,16 +21,21 @@ [class.tb-chips]="inlineField" [class.flex]="inlineField" [subscriptSizing]="inlineField ? 'dynamic' : subscriptSizing"> - {{ labelText }} + @if (!inlineField && labelText) { + {{ labelText }} + } - - {{ displayEntityFn(entity) }} - close - + @for (entity of entities; track entity) { + + {{ displayEntityFn(entity) }} + @if (!disabled) { + close + } + + } - + @if (allowCreateNew && !disabled) { + + } - - - - -
-
- {{ 'entity.no-entities-text' | translate }} -
- - - {{ 'entity.no-entities-matching' | translate: {entity: searchText} }} - - @if (allowCreateNew) { + @for (entity of filteredEntities | async; track entity) { + + + + } + @if (!(filteredEntities | async)?.length) { + +
+ @if (!textIsNotEmpty(searchText)) { +
+ {{ 'entity.no-entities-text' | translate }} +
+ } @else { - entity.create-new-key + {{ 'entity.no-entities-matching' | translate: {entity: searchText} }} + @if (allowCreateNew) { + + entity.create-new-key + + } } - -
-
+
+
+ }
- - {{ hint }} - - - {{ requiredText }} - + @if (!inlineField && hint) { + + {{ hint }} + + } + @if (!inlineField && entityListFormGroup.get('entities').hasError('required')) { + + {{ requiredText }} + + }
diff --git a/ui-ngx/src/app/shared/components/entity/entity-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 b3f4fa5300..190eff28e1 100644 --- a/ui-ngx/src/app/shared/components/entity/entity-type-select.component.html +++ b/ui-ngx/src/app/shared/components/entity/entity-type-select.component.html @@ -18,16 +18,22 @@ - {{ label }} + @if (showLabel && !inlineField) { + {{ label }} + } - - {{ displayEntityTypeFn(type) }} - + @for (type of entityTypes; track type) { + + {{ displayEntityTypeFn(type) }} + + } @if (!inlineField && subscriptSizing === 'dynamic') { } - - {{ '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/file-input.component.ts b/ui-ngx/src/app/shared/components/file-input.component.ts index 727b1628dc..d9f891c7f4 100644 --- a/ui-ngx/src/app/shared/components/file-input.component.ts +++ b/ui-ngx/src/app/shared/components/file-input.component.ts @@ -38,6 +38,7 @@ import { UtilsService } from '@core/services/utils.service'; import { DialogService } from '@core/services/dialog.service'; import { FileSizePipe } from '@shared/pipe/file-size.pipe'; import { coerceBoolean } from '@shared/decorators/coercion'; +import { bytesToString } from '@core/utils'; @Component({ selector: 'tb-file-input', @@ -216,34 +217,39 @@ export class FileInputComponent extends PageComponent implements AfterViewInit, return; } - const reader = new FileReader(); - reader.onload = () => { - let fileName = null; - let fileContent = null; - let mediaType = null; - if (reader.readyState === reader.DONE) { - fileContent = reader.result; - if (fileContent && fileContent.length > 0) { - if (this.contentConvertFunction) { - fileContent = this.contentConvertFunction(fileContent); - } - fileName = fileContent ? file.name : null; - mediaType = file?.file?.type || null; - } - } - resolve({fileContent, fileName, files: null, mediaType}); - }; - reader.onerror = () => { - resolve({fileContent: null, fileName: null, files: null, mediaType: null}); - }; + const onReadError = () => resolve({fileContent: null, fileName: null, files: null, mediaType: null}); + if (this.readAsBinary) { + const reader = new FileReader(); + reader.onload = () => { + const content = reader.readyState === reader.DONE ? reader.result : null; + resolve(this.buildResult(file, content)); + }; + reader.onerror = onReadError; reader.readAsBinaryString(file.file); } else { - reader.readAsText(file.file); + file.file.arrayBuffer().then( + buf => resolve(this.buildResult(file, bytesToString(new Uint8Array(buf)))), + onReadError + ); } }); } + private buildResult(file: flowjs.FlowFile, content: string | ArrayBuffer): any { + let fileContent: any = content; + let fileName = null; + let mediaType = null; + if (fileContent && fileContent.length > 0) { + if (this.contentConvertFunction) { + fileContent = this.contentConvertFunction(fileContent); + } + fileName = fileContent ? file.name : null; + mediaType = file?.file?.type || null; + } + return {fileContent, fileName, files: null, mediaType}; + } + private checkMaxSize(file: flowjs.FlowFile): boolean { return !this.maxSizeByte || file.size <= this.maxSizeByte; } 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..0fe1a4be59 100644 --- a/ui-ngx/src/app/shared/components/image/image-dialog.component.html +++ b/ui-ngx/src/app/shared/components/image/image-dialog.component.html @@ -25,36 +25,45 @@ close - - -
+ @if (isLoading$ | async) { + + + } @else { +
+ }
- + image.name - - {{ 'image.name-required' | translate }} - - - + @if (imageFormGroup.get('title').hasError('required')) { + + {{ 'image.name-required' | translate }} + + } + @if (!readonly && imageFormGroup.dirty) { + + } + @if (!readonly && !imageFormGroup.invalid && imageFormGroup.dirty) { + + }
@@ -82,14 +91,16 @@ code
- + @if (!readonly) { + + }
diff --git a/ui-ngx/src/app/shared/components/image/image-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..3e7b85268a 100644 --- a/ui-ngx/src/app/shared/components/image/upload-image-dialog.component.html +++ b/ui-ngx/src/app/shared/components/image/upload-image-dialog.component.html @@ -25,9 +25,12 @@ close - - -
+ @if (isLoading$ | async) { + + + } @else { +
+ }
- - image.name - - - {{ 'image.name-required' | translate }} - - + @if (uploadImage && uploadImageFormGroup.get('file').value) { + + image.name + + @if (uploadImageFormGroup.get('title').hasError('required')) { + + {{ 'image.name-required' | translate }} + + } + + }
diff --git a/ui-ngx/src/app/shared/components/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-sanitize.helper.ts b/ui-ngx/src/app/shared/components/markdown-sanitize.helper.ts new file mode 100644 index 0000000000..942cc257f3 --- /dev/null +++ b/ui-ngx/src/app/shared/components/markdown-sanitize.helper.ts @@ -0,0 +1,258 @@ +/// +/// Copyright © 2016-2026 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +/// See the License for the specific language governing permissions and +/// limitations under the License. +/// + +/** Block keywords: @keyword (...) { body } */ +const BLOCK_KEYWORDS = [ + 'if', 'else if', 'else', 'for', 'empty', 'switch', 'case', + 'default', 'defer', 'placeholder', 'loading', 'error', +]; + +/** Inline keywords: @keyword identifier = expr; */ +const INLINE_KEYWORDS = ['let']; + +/** Chain keywords that can follow a closing '}' */ +const CHAIN_KEYWORDS = [ + 'else if', 'else', 'empty', 'error', 'placeholder', 'loading', +]; + +/** Validation: inline keyword → regex that must match after keyword */ +const INLINE_VALIDATORS: Record = { + let: /^\s*[a-zA-Z_$][a-zA-Z0-9_$]*\s*=/, +}; + +/** HTML entities decoded in preserved blocks (order matters: & must be last) */ +const HTML_ENTITIES: [RegExp, string][] = [ + [/'/g, "'"], + [/"/g, '"'], + [/</g, '<'], + [/>/g, '>'], + [/&/g, '&'], +]; + +/** Matches any known @keyword at position 0 */ +const KEYWORD_RE = new RegExp( + `@(${[...BLOCK_KEYWORDS, ...INLINE_KEYWORDS] + .sort((a, b) => b.length - a.length).join('|')})(?=[\\s(;{])`, +); + +/** Matches a chain keyword after '}' */ +const CHAIN_RE = new RegExp( + `^\\s*@(${CHAIN_KEYWORDS + .sort((a, b) => b.length - a.length).join('|')})(?=[\\s(;{])`, +); + +// ─── Public API ────────────────────────────────────────────────────── + +/** + * Sanitize a markdown-compiled HTML template for safe Angular compilation. + * + * Escapes `{`, `}`, `@` that are not part of Angular control flow, + * preserves recognized control flow blocks (@if, @for, @switch, @let, @defer …), + * decodes HTML entities inside them, and protects `
` code blocks.
+ */
+export function sanitizeTemplate(template: string): string {
+  // Phase 1 — protect zones that must not be processed
+  const protectedZones: string[] = [];
+  template = extractProtectedZones(template, protectedZones);
+
+  // Phase 2 — extract Angular control flow blocks
+  const blocks: string[] = [];
+  template = extractControlFlowBlocks(template, blocks);
+
+  // Phase 3 — decode HTML entities inside control flow blocks
+  decodeHtmlEntities(blocks);
+
+  // Phase 4 — escape Angular-sensitive chars in remaining text
+  template = escapeAngularChars(template);
+
+  // Phase 5 — restore control flow blocks (as-is)
+  template = restoreBlocks(template, '__NG_BLOCK_', blocks);
+
+  // Phase 6 — restore protected zones (with escaping)
+  template = restoreBlocks(template, '__PROTECTED_', protectedZones, true);
+
+  return template;
+}
+
+// ─── Phase helpers ───────────────────────────────────────────────────
+
+function extractProtectedZones(template: string, zones: string[]): string {
+  return template.replace(//g, match => {
+    zones.push(match);
+    return `__PROTECTED_${zones.length - 1}__`;
+  });
+}
+
+function decodeHtmlEntities(blocks: string[]): void {
+  for (let i = 0; i < blocks.length; i++) {
+    for (const [pattern, replacement] of HTML_ENTITIES) {
+      blocks[i] = blocks[i].replace(pattern, replacement);
+    }
+  }
+}
+
+function escapeAngularChars(text: string): string {
+  return text.replace(/{/g, '{').replace(/}/g, '}').replace(/@/g, '@');
+}
+
+function restoreBlocks(template: string, prefix: string, blocks: string[], escape = false): string {
+  let restored = '';
+  let searchStart = 0;
+  for (let i = 0; i < blocks.length; i++) {
+    const token = `${prefix}${i}__`;
+    const tokenIndex = template.indexOf(token, searchStart);
+    if (tokenIndex === -1) continue;
+    const content = escape ? escapeAngularChars(blocks[i]) : blocks[i];
+    restored += template.slice(searchStart, tokenIndex) + content;
+    searchStart = tokenIndex + token.length;
+  }
+  restored += template.slice(searchStart);
+  return restored;
+}
+
+// ─── Control flow extraction ─────────────────────────────────────────
+
+function extractControlFlowBlocks(template: string, blocks: string[]): string {
+  let result = '';
+  let i = 0;
+  while (i < template.length) {
+    if (template[i] !== '@') {
+      result += template[i++];
+      continue;
+    }
+    const extracted = tryExtractInline(template, i)
+                   ?? tryExtractBlock(template, i);
+    if (extracted) {
+      blocks.push(extracted.content);
+      result += `__NG_BLOCK_${blocks.length - 1}__`;
+      i = extracted.end;
+    } else {
+      result += template[i++];
+    }
+  }
+  return result;
+}
+
+/** Try to extract an inline statement: @let identifier = expr; */
+function tryExtractInline(template: string, pos: number): { content: string; end: number } | null {
+  const match = matchKeywordAt(template, pos);
+  if (!match || !INLINE_KEYWORDS.includes(match.keyword)) {
+    return null;
+  }
+  const validator = INLINE_VALIDATORS[match.keyword];
+  if (validator && !validator.test(template.substring(pos + match.raw.length))) {
+    return null;
+  }
+  const semicolonIdx = findStatementSemicolon(template, pos);
+  if (semicolonIdx === -1) {
+    return null;
+  }
+  return { content: template.substring(pos, semicolonIdx + 1), end: semicolonIdx + 1 };
+}
+
+/** Try to extract a block statement: @keyword (...) { body } with optional chains */
+function tryExtractBlock(template: string, start: number): { content: string; end: number } | null {
+  let i = start;
+  const len = template.length;
+
+  while (i < len) {
+    const match = matchKeywordAt(template, i);
+    if (!match) {
+      return null;
+    }
+    i += match.raw.length;
+
+    // optional parenthesized params
+    i = skipWhitespace(template, i);
+    if (i < len && template[i] === '(') {
+      const parenEnd = findMatchingPair(template, i, '(', ')');
+      if (parenEnd === -1) return null;
+      i = skipWhitespace(template, parenEnd + 1);
+    }
+
+    // required { body }
+    if (i >= len || template[i] !== '{') return null;
+    const braceEnd = findMatchingPair(template, i, '{', '}');
+    if (braceEnd === -1) return null;
+    i = braceEnd + 1;
+
+    // check for chain keyword (@else, @empty, etc.)
+    const chainMatch = template.substring(i).match(CHAIN_RE);
+    if (chainMatch) {
+      i += chainMatch[0].indexOf('@');
+      continue;
+    }
+
+    return { content: template.substring(start, i), end: i };
+  }
+  return null;
+}
+
+// ─── Low-level helpers ───────────────────────────────────────────────
+
+function matchKeywordAt(template: string, pos: number): { keyword: string; raw: string } | null {
+  const remaining = template.substring(pos);
+  const m = KEYWORD_RE.exec(remaining);
+  return m && m.index === 0 ? { keyword: m[1], raw: m[0] } : null;
+}
+
+function skipWhitespace(str: string, pos: number): number {
+  while (pos < str.length && (str[pos] === ' ' || str[pos] === '\n' || str[pos] === '\r' || str[pos] === '\t')) {
+    pos++;
+  }
+  return pos;
+}
+
+function findMatchingPair(template: string, start: number, open: string, close: string): number {
+  let depth = 0;
+  return scanOutsideStrings(template, start, pos => {
+    if (template[pos] === open) depth++;
+    else if (template[pos] === close) {
+      if (--depth === 0) return pos;
+    }
+  });
+}
+
+/** Find the real statement-terminating ; (skipping ; inside HTML entities and entity-encoded strings) */
+function findStatementSemicolon(template: string, start: number): number {
+  return scanOutsideStrings(template, start, pos => {
+    if (template[pos] === ';') {
+      const before = template.substring(Math.max(start, pos - 12), pos);
+      if (!/&[a-zA-Z0-9#]+$/.test(before)) return pos;
+    }
+  });
+}
+
+/** Scan template skipping entity-encoded strings ("...", '...'), call handler for each char outside strings */
+function scanOutsideStrings(template: string, start: number, onChar: (pos: number) => number): number {
+  const ENTITY_QUOTES = ['"', '''];
+  let inString: string | null = null;
+  let i = start;
+  while (i < template.length) {
+    if (!inString) {
+      const quote = ENTITY_QUOTES.find(q => template.startsWith(q, i));
+      if (quote) { inString = quote; i += quote.length; continue; }
+    } else if (template.startsWith(inString, i)) {
+      i += inString.length; inString = null; continue;
+    }
+    if (!inString) {
+      const result = onChar(i);
+      if (result !== undefined) return result;
+    }
+    i++;
+  }
+  return -1;
+}
diff --git a/ui-ngx/src/app/shared/components/markdown.component.html b/ui-ngx/src/app/shared/components/markdown.component.html
index 62709f4f90..203900dd96 100644
--- a/ui-ngx/src/app/shared/components/markdown.component.html
+++ b/ui-ngx/src/app/shared/components/markdown.component.html
@@ -17,10 +17,12 @@
 -->
 
 
-
- {{error}} -
+ {{error}} +
+}
diff --git a/ui-ngx/src/app/shared/components/markdown.component.ts b/ui-ngx/src/app/shared/components/markdown.component.ts index f797960f6d..e7d9df1eb3 100644 --- a/ui-ngx/src/app/shared/components/markdown.component.ts +++ b/ui-ngx/src/app/shared/components/markdown.component.ts @@ -38,6 +38,7 @@ import { SHARED_MODULE_TOKEN } from '@shared/components/tokens'; import { guid, isDefinedAndNotNull } from '@core/utils'; import { Observable, of, ReplaySubject } from 'rxjs'; import { coerceBoolean } from '@shared/decorators/coercion'; +import { sanitizeTemplate } from './markdown-sanitize.helper'; let defaultMarkdownStyle: string; @@ -133,7 +134,7 @@ export class TbMarkdownComponent implements OnChanges { const preHtml = preElements.item(i).outerHTML.replace('ngnonbindable=""', 'ngNonBindable'); template = template.replace(matches[i][0], preHtml); } - template = this.sanitize(template); + template = sanitizeTemplate(template); this.markdownContainer.clear(); let styles: string[] = []; let readyObservable: Observable; @@ -267,10 +268,6 @@ export class TbMarkdownComponent implements OnChanges { } } - private sanitize(template: string): string { - return template.replace(/{/g, '{').replace(/}/g, '}').replace(/@/g, '@'); - } - private destroyMarkdownInstanceResources() { if (this.tbMarkdownInstanceComponentType) { this.dynamicComponentFactoryService.destroyDynamicComponent(this.tbMarkdownInstanceComponentType); diff --git a/ui-ngx/src/app/shared/components/material-icon-select.component.html b/ui-ngx/src/app/shared/components/material-icon-select.component.html index 59566607be..55e76946d6 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-icon-select.component.ts b/ui-ngx/src/app/shared/components/material-icon-select.component.ts index a0f5387a24..ec040bd488 100644 --- a/ui-ngx/src/app/shared/components/material-icon-select.component.ts +++ b/ui-ngx/src/app/shared/components/material-icon-select.component.ts @@ -36,6 +36,7 @@ import { TbPopoverService } from '@shared/components/popover.service'; import { MaterialIconsComponent } from '@shared/components/material-icons.component'; import { MatButton } from '@angular/material/button'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; +import { MatFormFieldAppearance } from '@angular/material/form-field'; @Component({ selector: 'tb-material-icon-select', @@ -76,6 +77,9 @@ export class MaterialIconSelectComponent extends PageComponent implements OnInit @coerceBoolean() allowedCustomIcon = false; + @Input() + appearance: MatFormFieldAppearance = 'fill'; + private requiredValue: boolean; get required(): boolean { return this.requiredValue; diff --git a/ui-ngx/src/app/shared/components/material-icons.component.html b/ui-ngx/src/app/shared/components/material-icons.component.html index 97a661bfb1..bb9e3fca58 100644 --- a/ui-ngx/src/app/shared/components/material-icons.component.html +++ b/ui-ngx/src/app/shared/components/material-icons.component.html @@ -17,7 +17,9 @@ -->
- icon.icons + @if (showTitle) { + icon.icons + } @if (allowedCustomIcon) { {{ 'resource.system' | translate }} @@ -30,61 +32,73 @@ search - + @if (searchIconControl.value) { + + }
- - - - + @for (icon of iconRow; track icon) { + @if (icon.name === selectedIcon) { + + } + @if (icon.name !== selectedIcon) { + + } + }
- + @if (notFound) {
{{ 'icon.no-icons-found' | translate:{iconSearch: searchIconControl.value} }}
-
-
- - - -
+ } + @if (iconClearButton || !showAllSubject.value) { +
+ @if (iconClearButton) { + + } + + @if (!showAllSubject.value) { + + } +
+ } } @else { diff --git a/ui-ngx/src/app/shared/components/message-type-autocomplete.component.html b/ui-ngx/src/app/shared/components/message-type-autocomplete.component.html index b073d93a4e..c63cbf82fb 100644 --- a/ui-ngx/src/app/shared/components/message-type-autocomplete.component.html +++ b/ui-ngx/src/app/shared/components/message-type-autocomplete.component.html @@ -15,7 +15,7 @@ limitations under the License. --> - + {{ 'rulenode.message-type' | translate }} - + @if (messageTypeFormGroup.get('messageType').value && !disabled) { + + } - - - + @for (messageType of filteredMessageTypes | async; track messageType) { + + + + } - - {{ 'rulenode.message-type-required' | translate }} - + @if (messageTypeFormGroup.get('messageType').hasError('required')) { + + {{ 'rulenode.message-type-required' | translate }} + + } diff --git a/ui-ngx/src/app/shared/components/message-type-autocomplete.component.ts b/ui-ngx/src/app/shared/components/message-type-autocomplete.component.ts index f9ca55f587..b9719a6863 100644 --- a/ui-ngx/src/app/shared/components/message-type-autocomplete.component.ts +++ b/ui-ngx/src/app/shared/components/message-type-autocomplete.component.ts @@ -24,6 +24,7 @@ import { TranslateService } from '@ngx-translate/core'; import { coerceBooleanProperty } from '@angular/cdk/coercion'; import { MessageType, messageTypeNames } from '@shared/models/rule-node.models'; import { objectValues } from '@core/utils'; +import { MatFormFieldAppearance } from '@angular/material/form-field'; @Component({ selector: 'tb-message-type-autocomplete', @@ -51,6 +52,9 @@ export class MessageTypeAutocompleteComponent implements ControlValueAccessor, O this.requiredValue = coerceBooleanProperty(value); } + @Input() + appearance: MatFormFieldAppearance = 'fill'; + @Input() disabled: boolean; diff --git a/ui-ngx/src/app/shared/components/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 65e7c936f3..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/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 7a0489345f..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,16 +20,22 @@ [floatLabel]="floatLabel" [appearance]="appearance" [subscriptSizing]="subscriptSizing"> - {{ label }} + @if (label) { + {{ label }} + } - - {{ item.name }} - close - + @for (item of itemList; track item) { + + {{ item.name }} + @if (!disabled) { + close + } + + } @for (value of filteredValues | async; track value.value) { - } @empty { + } @empty { @if (!allowUserValue) { {{ 'common.not-found' | translate }} } @@ -67,7 +73,9 @@
- - {{ requiredText }} - + @if (stringItemsForm.get('items').hasError('required')) { + + {{ requiredText }} + + } 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 b5d7678ba2..caef81b9d0 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/datetime.component.ts b/ui-ngx/src/app/shared/components/time/datetime.component.ts index 6d89185702..8306b14872 100644 --- a/ui-ngx/src/app/shared/components/time/datetime.component.ts +++ b/ui-ngx/src/app/shared/components/time/datetime.component.ts @@ -40,7 +40,7 @@ export class DatetimeComponent implements OnInit, ControlValueAccessor { return this.requiredValue; } @Input() - set required(value: boolean) { + set required(value: unknown) { this.requiredValue = coerceBooleanProperty(value); } 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/toggle-header.component.html b/ui-ngx/src/app/shared/components/toggle-header.component.html index c71e226dce..6c41d365f5 100644 --- a/ui-ngx/src/app/shared/components/toggle-header.component.html +++ b/ui-ngx/src/app/shared/components/toggle-header.component.html @@ -26,37 +26,52 @@ [class.tb-mat-24]="isMdLg"> chevron_left -
- - - @if (option.template) { - - } @else { - {{ option.name }} - - warning - +@if ((useSelect$ | async) === false) { +
+ + @for (option of options; track trackByHeaderOption($index, option)) { + + @if (option.template) { + + } @else { + {{ 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-select.component.html b/ui-ngx/src/app/shared/components/widgets-bundle-select.component.html index 04355f2bc1..d383c7ba71 100644 --- a/ui-ngx/src/app/shared/components/widgets-bundle-select.component.html +++ b/ui-ngx/src/app/shared/components/widgets-bundle-select.component.html @@ -15,7 +15,7 @@ limitations under the License. --> - +
{{widgetsBundle?.title}} - widgets-bundle.system + @if (isSystem(widgetsBundle)) { + widgets-bundle.system + }
- - widgets-bundle.create-new - - - - - -
- {{widgetsBundle.title}} - widgets-bundle.system -
-
+ @if (createNew) { + + widgets-bundle.create-new + + } @else { + @if (!required) { + + } + } + @for (widgetsBundle of widgetsBundles$ | async; track widgetsBundle) { + +
+ {{widgetsBundle.title}} + @if (isSystem(widgetsBundle)) { + widgets-bundle.system + } +
+
+ }
diff --git a/ui-ngx/src/app/shared/components/widgets-bundle-select.component.ts b/ui-ngx/src/app/shared/components/widgets-bundle-select.component.ts index aa4d8328ba..e8bd78262b 100644 --- a/ui-ngx/src/app/shared/components/widgets-bundle-select.component.ts +++ b/ui-ngx/src/app/shared/components/widgets-bundle-select.component.ts @@ -40,6 +40,7 @@ import { WidgetsBundleDialogComponent, WidgetsBundleDialogData } from '@home/pages/widget/widgets-bundle-dialog.component'; +import { MatFormFieldAppearance } from '@angular/material/form-field'; @Component({ selector: 'tb-widgets-bundle-select', @@ -79,6 +80,9 @@ export class WidgetsBundleSelectComponent implements ControlValueAccessor, OnIni @coerceBoolean() createNew: boolean; + @Input() + appearance: MatFormFieldAppearance = 'fill'; + widgetsBundles$: Observable>; widgetsBundles: Array; diff --git a/ui-ngx/src/app/shared/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..bf0f0401c8 100644 --- a/ui-ngx/src/app/shared/import-export/import-dialog-csv.component.html +++ b/ui-ngx/src/app/shared/import-export/import-dialog-csv.component.html @@ -27,8 +27,10 @@ close - - + @if (isLoading$ | async) { + + + }
@@ -47,23 +49,25 @@
+ (click)="cancel()">{{ 'action.cancel' | translate }} + (click)="nextStep(2)">{{ 'action.continue' | translate }}
-
+ {{ 'import.stepper-text.configuration' | translate }}
- + import.csv-delimiter - - {{ delimiter.value }} - + @for (delimiter of delimiters; track delimiter) { + + {{ delimiter.value }} + + }
@@ -79,15 +83,15 @@
+ (click)="previousStep()">{{ 'action.back' | translate }}
+ (click)="cancel()">{{ 'action.cancel' | translate }} + (click)="nextStep(3)">{{ 'action.continue' | translate }}
@@ -99,15 +103,15 @@
+ (click)="previousStep()">{{ 'action.back' | translate }}
+ (click)="cancel()">{{ 'action.cancel' | translate }} + (click)="nextStep(4)">{{ 'action.continue' | translate }}
@@ -119,32 +123,39 @@ {{ 'action.done' | translate }}
-

- {{ translate.instant('import.message.create-entities', {count: 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/import-export.service.ts b/ui-ngx/src/app/shared/import-export/import-export.service.ts index 6593e8c9bf..3c4d3b3f12 100644 --- a/ui-ngx/src/app/shared/import-export/import-export.service.ts +++ b/ui-ngx/src/app/shared/import-export/import-export.service.ts @@ -89,12 +89,16 @@ import { import { FormProperty, propertyValid } from '@shared/models/dynamic-form.models'; import { CalculatedFieldsService } from '@core/http/calculated-fields.service'; import { CalculatedField } from '@shared/models/calculated-field.models'; +import { RuleChainId } from '@shared/models/id/rule-chain-id'; export type editMissingAliasesFunction = (widgets: Array, isSingleWidget: boolean, customTitle: string, missingEntityAliases: EntityAliases) => Observable; type SupportEntityResources = 'includeResourcesInExportWidgetTypes' | 'includeResourcesInExportDashboard' | 'includeBundleWidgetsInExport'; +const profileRuleChainFields = ['defaultRuleChainId', 'defaultEdgeRuleChainId'] as const; +type ProfileRuleChainField = typeof profileRuleChainFields[number]; + // @dynamic @Injectable() export class ImportExportService { @@ -749,7 +753,8 @@ export class ImportExportService { type: 'error'})); throw new Error('Invalid device profile file'); } else { - return this.deviceProfileService.saveDeviceProfile(this.prepareImport(deviceProfile)); + return this.clearMissingRuleChainsAndSave(deviceProfile, + profile => this.deviceProfileService.saveDeviceProfile(profile)); } }), catchError(() => of(null)) @@ -776,13 +781,44 @@ export class ImportExportService { type: 'error'})); throw new Error('Invalid asset profile file'); } else { - return this.assetProfileService.saveAssetProfile(this.prepareImport(assetProfile)); + return this.clearMissingRuleChainsAndSave(assetProfile, + profile => this.assetProfileService.saveAssetProfile(profile)); } }), catchError(() => of(null)) ); } + private clearMissingRuleChainsAndSave & Partial>>( + profile: T, save: (profile: T) => Observable): Observable { + return this.clearMissingProfileRuleChains(profile).pipe( + mergeMap(cleared => save(this.prepareImport(cleared))) + ); + } + + private clearMissingProfileRuleChains>>(profile: T): Observable { + const fieldsToCheck = profileRuleChainFields.filter(field => profile[field]?.id); + + if (!fieldsToCheck.length) { + return of(profile); + } + const ruleChainIds = fieldsToCheck.map(field => profile[field].id); + return this.ruleChainService.getRuleChainsByIds(ruleChainIds, {ignoreErrors: true, ignoreLoading: true}).pipe( + map(ruleChains => { + const existingIds = new Set(ruleChains.map(ruleChain => ruleChain.id.id)); + const missingFields = fieldsToCheck.filter(field => !existingIds.has(profile[field].id)); + if (!missingFields.length) { + return profile; + } + this.store.dispatch(new ActionNotificationShow( + {message: this.translate.instant('rulechain.rulechain-not-found-warning'), + type: 'warn'})); + missingFields.forEach(field => profile[field] = null); + return profile; + }) + ); + } + public exportTenantProfile(tenantProfileId: string) { this.tenantProfileService.getTenantProfile(tenantProfileId).subscribe({ next: (tenantProfile) => { diff --git a/ui-ngx/src/app/shared/import-export/table-columns-assignment.component.html b/ui-ngx/src/app/shared/import-export/table-columns-assignment.component.html index 46be8d8c57..7f51f98303 100644 --- a/ui-ngx/src/app/shared/import-export/table-columns-assignment.component.html +++ b/ui-ngx/src/app/shared/import-export/table-columns-assignment.component.html @@ -32,25 +32,33 @@ {{ 'import.column-type.column-type' | translate }} - - {{ columnTypesTranslations.get(type.value) | translate }} - - - - {{ columnTypesTranslations.get(credential.value) | translate }} + @for (type of columnTypes; track type) { + + {{ columnTypesTranslations.get(type.value) | translate }} - + } + @if (entityType === entityTypeDevice) { + + @for (credential of columnDeviceCredentials; track credential) { + + {{ columnTypesTranslations.get(credential.value) | translate }} + + } + + } {{ 'import.column-key' | translate }} - - - + @if (isColumnTypeDiffers(column.type)) { + + + + } diff --git a/ui-ngx/src/app/shared/import-export/table-columns-assignment.component.scss b/ui-ngx/src/app/shared/import-export/table-columns-assignment.component.scss index 778d3c4486..f8d56f1c49 100644 --- a/ui-ngx/src/app/shared/import-export/table-columns-assignment.component.scss +++ b/ui-ngx/src/app/shared/import-export/table-columns-assignment.component.scss @@ -32,4 +32,10 @@ flex: 0 0 120px; min-width: 120px; } + mat-row { + mat-cell { + padding-top: 4px; + padding-bottom: 4px; + } + } } diff --git a/ui-ngx/src/app/shared/models/query/query.models.ts b/ui-ngx/src/app/shared/models/query/query.models.ts index 7f04f07664..cd35c410dc 100644 --- a/ui-ngx/src/app/shared/models/query/query.models.ts +++ b/ui-ngx/src/app/shared/models/query/query.models.ts @@ -401,20 +401,21 @@ export interface FilterInfo { filter: string; editable: boolean; keyFilters: Array; + keyFiltersOperation?: ComplexOperation; } export interface FiltersInfo { datasourceFilters: {[datasourceIndex: number]: FilterInfo}; } -export function keyFiltersToText(translate: TranslateService, datePipe: DatePipe, keyFilters: Array): string { +export function keyFiltersToText(translate: TranslateService, datePipe: DatePipe, keyFilters: Array, + operation: ComplexOperation = ComplexOperation.AND): string { const filtersText = keyFilters.map(keyFilter => - keyFilterToText(translate, datePipe, keyFilter, - keyFilters.length > 1 ? ComplexOperation.AND : undefined)); + keyFilterToText(translate, datePipe, keyFilter, operation)); let result: string; if (filtersText.length > 1) { - const andText = translate.instant('filter.operation.and'); - result = filtersText.join(' ' + andText + ' '); + const opText = translate.instant(complexOperationTranslationMap.get(operation)); + result = filtersText.join(' ' + opText + ' '); } else { result = filtersText[0]; } @@ -802,6 +803,7 @@ export const singleEntityFilterFromDeviceId = (deviceId: string): EntityFilter = export interface EntityCountQuery { entityFilter: EntityFilter; keyFilters?: Array; + keyFiltersOperation?: ComplexOperation; } export interface AbstractDataQuery extends EntityCountQuery { @@ -940,13 +942,15 @@ export const getFilterId = (filters: Filters, filterInfo: FilterInfo): string => const newFilterName = createFilterName(filters, filterInfo.filter); newFilterId = guid(); filters[newFilterId] = {id: newFilterId, filter: newFilterName, - keyFilters: filterInfo.keyFilters, editable: filterInfo.editable}; + keyFilters: filterInfo.keyFilters, editable: filterInfo.editable, + keyFiltersOperation: filterInfo.keyFiltersOperation}; } return newFilterId; } const isFilterEqual = (filter1: FilterInfo, filter2: FilterInfo): boolean => { - return isEqual(filter1.keyFilters, filter2.keyFilters); + return isEqual(filter1.keyFilters, filter2.keyFilters) && + filter1.keyFiltersOperation === filter2.keyFiltersOperation; } const createFilterName = (filters: Filters, filter: string): string => { diff --git a/ui-ngx/src/app/shared/models/rule-chain.models.ts b/ui-ngx/src/app/shared/models/rule-chain.models.ts index cf859e859f..feb4952236 100644 --- a/ui-ngx/src/app/shared/models/rule-chain.models.ts +++ b/ui-ngx/src/app/shared/models/rule-chain.models.ts @@ -18,7 +18,7 @@ import { BaseData, ExportableEntity } from '@shared/models/base-data'; import { TenantId } from '@shared/models/id/tenant-id'; import { RuleChainId } from '@shared/models/id/rule-chain-id'; import { RuleNodeId } from '@shared/models/id/rule-node-id'; -import { RuleNode, RuleNodeComponentDescriptor, RuleNodeType } from '@shared/models/rule-node.models'; +import { FcRuleNote, RuleNode, RuleNodeComponentDescriptor, RuleNodeType } from '@shared/models/rule-node.models'; import { ComponentClusteringMode, ComponentType } from '@shared/models/component-descriptor.models'; import { HasTenantId, HasVersion } from '@shared/models/entity.models'; @@ -39,6 +39,7 @@ export interface RuleChainMetaData extends HasVersion { firstNodeIndex?: number; nodes: Array; connections: Array; + notes?: Array; } export interface RuleChainImport { diff --git a/ui-ngx/src/app/shared/models/rule-node.models.ts b/ui-ngx/src/app/shared/models/rule-node.models.ts index 30e1648f7e..1789e5c5d8 100644 --- a/ui-ngx/src/app/shared/models/rule-node.models.ts +++ b/ui-ngx/src/app/shared/models/rule-node.models.ts @@ -18,7 +18,7 @@ import { BaseData } from '@shared/models/base-data'; import { RuleChainId } from '@shared/models/id/rule-chain-id'; import { RuleNodeId } from '@shared/models/id/rule-node-id'; import { ComponentDescriptor } from '@shared/models/component-descriptor.models'; -import { FcEdge, FcNode } from 'ngx-flowchart'; +import { FcEdge, FcNode, FcNote } from 'ngx-flowchart'; import { Observable } from 'rxjs'; import { PageComponent } from '@shared/components/page.component'; import { AfterViewInit, DestroyRef, Directive, EventEmitter, inject, OnInit } from '@angular/core'; @@ -27,6 +27,7 @@ import { RuleChainType } from '@shared/models/rule-chain.models'; import { DebugRuleNodeEventBody } from '@shared/models/event.models'; import { EntityTestScriptResult, HasEntityDebugSettings } from '@shared/models/entity.models'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; +import { strings } from '@angular-devkit/core'; export interface RuleNodeConfiguration { [key: string]: any; @@ -358,6 +359,19 @@ export interface FcRuleEdge extends FcEdge { labels?: string[]; } +export const FC_RULE_NOTE_DEFAULT_BACKGROUND_COLOR = '#FFF9C4'; +export const FC_RULE_NOTE_DEFAULT_BORDER_WIDTH = 1; +export const FC_RULE_NOTE_DEFAULT_APPLY_MARKDOWN_STYLE = true; + +export interface FcRuleNote extends FcNote { + content?: string; + backgroundColor?: string; + borderColor?: string; + borderWidth?: number; + applyDefaultMarkdownStyle?: boolean; + markdownCss?: string; +} + export enum ScriptLanguage { JS = 'JS', TBEL = 'TBEL' diff --git a/ui-ngx/src/app/shared/models/widget.models.ts b/ui-ngx/src/app/shared/models/widget.models.ts index 1a2f7e1c8b..982f492eba 100644 --- a/ui-ngx/src/app/shared/models/widget.models.ts +++ b/ui-ngx/src/app/shared/models/widget.models.ts @@ -24,6 +24,7 @@ import { EntityId } from '@shared/models/id/entity-id'; import { AlarmFilter, AlarmFilterConfig, + ComplexOperation, EntityDataPageLink, EntityFilter, KeyFilter @@ -450,6 +451,7 @@ export interface Datasource { origDatasourceIndex?: number; pageLink?: EntityDataPageLink; keyFilters?: Array; + keyFiltersOperation?: ComplexOperation; entityFilter?: EntityFilter; alarmFilterConfig?: AlarmFilterConfig; alarmFilter?: AlarmFilter; diff --git a/ui-ngx/src/app/shared/models/widget/maps/map.models.ts b/ui-ngx/src/app/shared/models/widget/maps/map.models.ts index f4f6e45d0d..497df5a435 100644 --- a/ui-ngx/src/app/shared/models/widget/maps/map.models.ts +++ b/ui-ngx/src/app/shared/models/widget/maps/map.models.ts @@ -931,6 +931,7 @@ export const defaultMapActionButtonSettings: MapActionButtonSettings = { } export enum MapProvider { + openfreemap = 'openfreemap', openstreet = 'openstreet', google = 'google', here = 'here', @@ -942,6 +943,7 @@ export const mapProviders = Object.keys(MapProvider) as MapProvider[]; export const mapProviderTranslationMap = new Map( [ + [MapProvider.openfreemap, 'widgets.maps.layer.provider.openfreemap.title'], [MapProvider.openstreet, 'widgets.maps.layer.provider.openstreet.title'], [MapProvider.google, 'widgets.maps.layer.provider.google.title'], [MapProvider.here, 'widgets.maps.layer.provider.here.title'], @@ -977,6 +979,9 @@ export const mapLayerValid = (layer: MapLayerSettings): boolean => { return false; } switch (layer.provider) { + case MapProvider.openfreemap: + const openFreeMapLayer = layer as OpenFreeMapLayerSettings; + return !!openFreeMapLayer.layerType; case MapProvider.openstreet: const openStreetLayer = layer as OpenStreetMapLayerSettings; return !!openStreetLayer.layerType; @@ -1010,6 +1015,9 @@ export const defaultLayerTitle = (layer: MapLayerSettings): string => { return null; } switch (layer.provider) { + case MapProvider.openfreemap: + const ofmLayer = layer as OpenFreeMapLayerSettings; + return openFreeMapStyleTranslationMap.get(ofmLayer.layerType); case MapProvider.openstreet: const openStreetLayer = layer as OpenStreetMapLayerSettings; return openStreetMapLayerTranslationMap.get(openStreetLayer.layerType); @@ -1061,6 +1069,32 @@ export const defaultOpenStreetMapLayerSettings: OpenStreetMapLayerSettings = { layerType: OpenStreetLayerType.openStreetMapnik } +export enum OpenFreeMapStyleType { + bright = 'bright', + positron = 'positron', + liberty = 'liberty' +} + +export const openFreeMapStyleTypes = Object.values(OpenFreeMapStyleType) as OpenFreeMapStyleType[]; + +export const openFreeMapStyleTranslationMap = new Map( + [ + [OpenFreeMapStyleType.liberty, 'widgets.maps.layer.provider.openfreemap.liberty'], + [OpenFreeMapStyleType.bright, 'widgets.maps.layer.provider.openfreemap.bright'], + [OpenFreeMapStyleType.positron, 'widgets.maps.layer.provider.openfreemap.positron'] + ] +); + +export interface OpenFreeMapLayerSettings extends MapLayerSettings { + provider: MapProvider.openfreemap; + layerType: OpenFreeMapStyleType; +} + +export const defaultOpenFreeMapLayerSettings: OpenFreeMapLayerSettings = { + provider: MapProvider.openfreemap, + layerType: OpenFreeMapStyleType.bright +} + export enum GoogleLayerType { roadmap = 'roadmap', satellite = 'satellite', @@ -1150,6 +1184,8 @@ export const defaultTencentMapLayerSettings: TencentMapLayerSettings = { export interface CustomMapLayerSettings extends MapLayerSettings { provider: MapProvider.custom; tileUrl: string; + vectorTiles?: boolean; + customAttribution?: string; } export const defaultCustomMapLayerSettings: CustomMapLayerSettings = { @@ -1159,6 +1195,8 @@ export const defaultCustomMapLayerSettings: CustomMapLayerSettings = { export const defaultMapLayerSettings = (provider: MapProvider): MapLayerSettings => { switch (provider) { + case MapProvider.openfreemap: + return defaultOpenFreeMapLayerSettings; case MapProvider.openstreet: return defaultOpenStreetMapLayerSettings; case MapProvider.google: @@ -1175,9 +1213,9 @@ export const defaultMapLayerSettings = (provider: MapProvider): MapLayerSettings export const defaultMapLayers: MapLayerSettings[] = [ { label: '{i18n:widgets.maps.layer.roadmap}', - provider: MapProvider.openstreet, - layerType: OpenStreetLayerType.openStreetMapnik, - } as OpenStreetMapLayerSettings, + provider: MapProvider.openfreemap, + layerType: OpenFreeMapStyleType.bright, + } as OpenFreeMapLayerSettings, { label: '{i18n:widgets.maps.layer.satellite}', provider: MapProvider.openstreet, diff --git a/ui-ngx/src/app/shared/pipe/tbJson.pipe.ts b/ui-ngx/src/app/shared/pipe/tbJson.pipe.ts index 52805f52b2..915f05f93e 100644 --- a/ui-ngx/src/app/shared/pipe/tbJson.pipe.ts +++ b/ui-ngx/src/app/shared/pipe/tbJson.pipe.ts @@ -22,12 +22,18 @@ import { isNumber, isObject } from '@core/utils'; standalone: false }) export class TbJsonPipe implements PipeTransform { - transform(value: any): string { + transform(value: any, maxLength?: number): string { + let result: string; if (isObject(value)) { - return JSON.stringify(value); + result = JSON.stringify(value); } else if (isNumber(value)) { - return value.toString(); + result = value.toString(); + } else { + result = value; } - return value; + if (maxLength != null && maxLength > 0 && result && result.length > maxLength) { + return result.slice(0, maxLength) + '\u2026'; + } + return result; } } 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-en_US.json b/ui-ngx/src/assets/locale/locale.constant-en_US.json index 3970b408fe..9fb9a7211c 100644 --- a/ui-ngx/src/assets/locale/locale.constant-en_US.json +++ b/ui-ngx/src/assets/locale/locale.constant-en_US.json @@ -3611,6 +3611,7 @@ "order-priority": "Display order", "key-filter": "Key filter", "key-filters": "Key filters", + "or-conditions-disabled": "OR conditions between key filters are disabled by the system administrator.", "key-name": "Key name", "key-name-required": "Key name is required.", "key-type": { @@ -5402,6 +5403,7 @@ "create-new-rulechain": "Create new rule chain", "rulechain-file": "Rule chain file", "invalid-rulechain-file-error": "Unable to import rule chain: Invalid rule chain data structure.", + "rulechain-not-found-warning": "Default rule chain not found. The profile was saved without one — you can assign it in the profile settings.", "copyId": "Copy rule chain Id", "idCopiedMessage": "Rule chain Id has been copied to clipboard", "select-rulechain": "Select rule chain", @@ -5431,7 +5433,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", @@ -5776,10 +5788,11 @@ "endpoint-url-pattern": "Endpoint URL pattern", "endpoint-url-pattern-required": "Endpoint URL pattern is required", "request-method": "Request method", - "use-simple-client-http-factory": "Use simple client HTTP factory", "ignore-request-body": "Without request body", + "request-body-template": "Request body template", + "request-body-template-hint": "Use ${metadataKey} for metadata, $[messageKey] for message values. If empty, the message payload is used as the request body. For non-JSON payloads, enable 'Parse to plain text'.", "parse-to-plain-text": "Parse to plain text", - "parse-to-plain-text-hint": "If selected, request body message payload will be transformed from JSON string to plain text, e.g. msg = \"Hello,\\t\"world\"\" will be parsed to Hello, \"world\"", + "parse-to-plain-text-hint": "If selected, the request body (message payload or body template result) will be sent as a plain text string instead of being parsed as JSON, e.g. msg = \"Hello,\\t\"world\"\" will be parsed to Hello, \"world\"", "read-timeout": "Read timeout in millis", "read-timeout-hint": "The value of 0 means an infinite timeout", "max-parallel-requests-count": "Max number of parallel requests", @@ -5801,6 +5814,7 @@ "bootstrap-servers": "Bootstrap servers", "bootstrap-servers-required": "Bootstrap servers value is required", "other-properties": "Other properties", + "other-properties-empty": "No other properties configured", "key": "Key", "key-required": "Key is required", "retries": "Automatically retry times if fails", @@ -5838,6 +5852,7 @@ "handshake-timeout-ms": "Handshake timeout (ms)", "min-handshake-timeout-ms-message": "Only 0 ms minimum value is allowed.", "client-properties": "Client properties", + "client-properties-empty": "No client properties configured", "queue-url-pattern": "Queue URL pattern", "queue-url-pattern-required": "Queue URL pattern is required", "delay-seconds": "Delay (seconds)", @@ -5855,6 +5870,7 @@ "pubsub-topic-name": "Topic name", "pubsub-topic-name-required": "Topic name is required", "message-attributes": "Message attributes", + "message-attributes-empty": "No message attributes configured", "message-attributes-hint": "Use ${metadataKey} for value from metadata, $[messageKey] for value from message body in name/value fields", "connect-timeout": "Connection timeout (sec)", "connect-timeout-required": "Connection timeout is required.", @@ -5932,7 +5948,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", @@ -6216,6 +6231,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.", @@ -9180,7 +9206,18 @@ }, "custom": { "title": "Custom", - "tile-url": "Tile URL" + "tile-url": "Tile URL", + "style-url": "Style URL", + "vector-tiles": "Vector tiles", + "vector-tiles-hint": "Enable when using a style URL from providers like OpenFreeMap or MapTiler.", + "attribution": "Copyright text", + "attribution-hint": "Displayed at the bottom of the map. Required by most tile providers." + }, + "openfreemap": { + "title": "OpenFreeMap", + "liberty": "Liberty", + "bright": "Bright", + "positron": "Positron" } }, "credentials": { @@ -9955,6 +9992,8 @@ "chart": "Chart", "chart-style": "Chart style", "data-zoom": "Data zoom", + "data-zoom-update-timewindow": "Update time window on zoom", + "reset-timewindow": "Back to original time window", "stack-mode": "Stack mode", "stack-mode-hint": "Stacks series on the chart. The series with the same unit would be put on top of each other.", "axes": "Axes", 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/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 e05a9e4a7c..863ab770b1 100644 --- a/ui-ngx/yarn.lock +++ b/ui-ngx/yarn.lock @@ -8144,9 +8144,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"