Browse Source

Merge remote-tracking branch 'upstream/master' into openApiClientGeneration

# Conflicts:
#	application/src/main/java/org/thingsboard/server/controller/TelemetryController.java
pull/14929/head
dashevchenko 7 months ago
parent
commit
ee01757723
  1. 4
      .github/release.yml
  2. 2
      .github/workflows/license-header-format.yml
  3. 36
      application/src/main/data/json/edge/instructions/install/centos/instructions.md
  4. 12
      application/src/main/data/json/edge/instructions/install/ubuntu/instructions.md
  5. 2
      application/src/main/data/json/system/widget_types/bar_chart_with_labels.json
  6. 2
      application/src/main/data/json/system/widget_types/range_chart.json
  7. 58
      application/src/main/java/org/thingsboard/server/controller/EntityQueryController.java
  8. 2
      application/src/main/java/org/thingsboard/server/controller/TelemetryController.java
  9. 7
      application/src/main/java/org/thingsboard/server/service/entitiy/EntityStateSourcingListener.java
  10. 92
      application/src/main/java/org/thingsboard/server/service/job/DefaultJobManager.java
  11. 145
      application/src/main/java/org/thingsboard/server/service/query/DefaultEntityQueryService.java
  12. 7
      application/src/main/java/org/thingsboard/server/service/query/EntityQueryService.java
  13. 2
      application/src/main/resources/thingsboard.yml
  14. 58
      application/src/test/java/org/thingsboard/server/controller/AbstractWebTest.java
  15. 7
      application/src/test/java/org/thingsboard/server/controller/DeviceControllerTest.java
  16. 6
      application/src/test/java/org/thingsboard/server/controller/EdqsEntityQueryControllerTest.java
  17. 237
      application/src/test/java/org/thingsboard/server/controller/EntityQueryControllerTest.java
  18. 7
      application/src/test/java/org/thingsboard/server/controller/EntityRelationControllerTest.java
  19. 23
      application/src/test/java/org/thingsboard/server/controller/TelemetryControllerTest.java
  20. 101
      application/src/test/java/org/thingsboard/server/service/job/JobManagerTest.java
  21. 9
      application/src/test/java/org/thingsboard/server/service/resource/DefaultResourceDataCacheTest.java
  22. 43
      application/src/test/java/org/thingsboard/server/service/script/TbelInvokeDocsIoTest.java
  23. 11
      common/dao-api/src/main/java/org/thingsboard/server/dao/attributes/AttributesService.java
  24. 3
      common/dao-api/src/main/java/org/thingsboard/server/dao/entity/EntityService.java
  25. 32
      common/dao-api/src/main/java/org/thingsboard/server/dao/nosql/ResultSetSizeLimitExceededException.java
  26. 24
      common/dao-api/src/main/java/org/thingsboard/server/dao/nosql/TbResultSet.java
  27. 7
      common/dao-api/src/main/java/org/thingsboard/server/dao/timeseries/TimeseriesService.java
  28. 140
      common/dao-api/src/test/java/org/thingsboard/server/dao/nosql/TbResultSetTest.java
  29. 34
      common/data/src/main/java/org/thingsboard/server/common/data/job/Job.java
  30. 5
      common/data/src/main/java/org/thingsboard/server/common/data/job/task/DummyTaskResult.java
  31. 3
      common/data/src/main/java/org/thingsboard/server/common/data/job/task/TaskResult.java
  32. 99
      common/data/src/main/java/org/thingsboard/server/common/data/query/AvailableEntityKeysV2.java
  33. 4
      common/message/src/test/java/org/thingsboard/server/common/msg/tools/TbRateLimitsTest.java
  34. 8
      common/script/script-api/src/main/java/org/thingsboard/script/api/tbel/TbUtils.java
  35. 134
      common/script/script-api/src/test/java/org/thingsboard/script/api/tbel/TbDateTest.java
  36. 95
      common/script/script-api/src/test/java/org/thingsboard/script/api/tbel/TbUtilsTest.java
  37. 4
      common/util/src/main/java/org/thingsboard/common/util/ThingsBoardThreadFactory.java
  38. 16
      common/version-control/src/main/java/org/thingsboard/server/service/sync/DefaultGitSyncService.java
  39. 35
      common/version-control/src/main/java/org/thingsboard/server/service/sync/vc/GitRepository.java
  40. 6
      dao/src/main/java/org/thingsboard/server/dao/attributes/AttributesDao.java
  41. 20
      dao/src/main/java/org/thingsboard/server/dao/attributes/BaseAttributesService.java
  42. 18
      dao/src/main/java/org/thingsboard/server/dao/attributes/CachedAttributesService.java
  43. 86
      dao/src/main/java/org/thingsboard/server/dao/entity/BaseEntityService.java
  44. 6
      dao/src/main/java/org/thingsboard/server/dao/model/sqlts/latest/TsKvLatestEntity.java
  45. 3
      dao/src/main/java/org/thingsboard/server/dao/nosql/CassandraAbstractAsyncDao.java
  46. 36
      dao/src/main/java/org/thingsboard/server/dao/sql/alarm/AlarmRepository.java
  47. 57
      dao/src/main/java/org/thingsboard/server/dao/sql/attributes/AttributeKvRepository.java
  48. 24
      dao/src/main/java/org/thingsboard/server/dao/sql/attributes/JpaAttributeDao.java
  49. 12
      dao/src/main/java/org/thingsboard/server/dao/sql/audit/AuditLogRepository.java
  50. 10
      dao/src/main/java/org/thingsboard/server/dao/sqlts/CachedRedisSqlTimeseriesLatestDao.java
  51. 17
      dao/src/main/java/org/thingsboard/server/dao/sqlts/SqlTimeseriesLatestDao.java
  52. 20
      dao/src/main/java/org/thingsboard/server/dao/sqlts/latest/SearchTsKvLatestRepository.java
  53. 13
      dao/src/main/java/org/thingsboard/server/dao/timeseries/BaseTimeseriesService.java
  54. 92
      dao/src/main/java/org/thingsboard/server/dao/timeseries/CassandraBaseTimeseriesDao.java
  55. 12
      dao/src/main/java/org/thingsboard/server/dao/timeseries/CassandraBaseTimeseriesLatestDao.java
  56. 4
      dao/src/main/java/org/thingsboard/server/dao/timeseries/SimpleListenableFuture.java
  57. 9
      dao/src/main/java/org/thingsboard/server/dao/timeseries/TimeseriesLatestDao.java
  58. 103
      dao/src/test/java/org/thingsboard/server/dao/service/attributes/BaseAttributesServiceTest.java
  59. 42
      dao/src/test/java/org/thingsboard/server/dao/service/timeseries/nosql/TimeseriesServiceNoSqlTest.java
  60. 68
      dao/src/test/java/org/thingsboard/server/dao/sqlts/SqlTimeseriesLatestDaoTest.java
  61. 3
      msa/js-executor/package.json
  62. 30
      msa/js-executor/yarn.lock
  63. 13
      msa/tb/docker-cassandra/Dockerfile
  64. 8
      msa/tb/docker-postgres/Dockerfile
  65. 3
      msa/web-ui/package.json
  66. 30
      msa/web-ui/yarn.lock
  67. 56
      packaging/java/build.gradle
  68. 8
      packaging/java/scripts/windows/install.bat
  69. 39
      packaging/js/build.gradle
  70. 58
      pom.xml
  71. 22
      rest-client/src/main/java/org/thingsboard/rest/client/RestClient.java
  72. 34
      rest-client/src/main/resources/logback.xml
  73. 3
      rule-engine/rule-engine-api/src/main/java/org/thingsboard/rule/engine/api/JobManager.java
  74. 17
      ui-ngx/package.json
  75. 2
      ui-ngx/patches/@angular+build+20.3.16.patch
  76. 51
      ui-ngx/patches/@iplab+ngx-color-picker+20.0.0.patch
  77. 12
      ui-ngx/src/app/modules/home/components/widget/config/basic/chart/bar-chart-with-labels-basic-config.component.ts
  78. 7
      ui-ngx/src/app/modules/home/components/widget/config/basic/chart/range-chart-basic-config.component.ts
  79. 6
      ui-ngx/src/app/modules/home/components/widget/lib/chart/bar-chart-with-labels-widget.component.ts
  80. 6
      ui-ngx/src/app/modules/home/components/widget/lib/chart/range-chart-widget.component.ts
  81. 100
      ui-ngx/src/app/modules/home/components/widget/lib/chart/time-series-chart.models.ts
  82. 23
      ui-ngx/src/app/modules/home/components/widget/lib/chart/time-series-chart.ts
  83. 6
      ui-ngx/src/app/modules/home/components/widget/lib/settings/chart/bar-chart-with-labels-widget-settings.component.ts
  84. 7
      ui-ngx/src/app/modules/home/components/widget/lib/settings/chart/range-chart-widget-settings.component.ts
  85. 37
      ui-ngx/src/app/modules/home/components/widget/lib/settings/common/axis-scale-row.component.ts
  86. 6
      ui-ngx/src/app/modules/home/components/widget/lib/settings/common/chart/time-series-chart-axis-settings.component.ts
  87. 108
      ui-ngx/src/app/modules/home/components/widget/lib/settings/common/chart/time-series-chart-y-axes-panel.component.ts
  88. 29
      ui-ngx/src/app/modules/home/components/widget/lib/settings/common/chart/time-series-chart-y-axis-row.component.ts
  89. 7
      ui-ngx/src/app/modules/home/components/widget/lib/settings/common/color-settings-panel.component.ts
  90. 4
      ui-ngx/src/app/modules/home/pages/asset/asset-tabs.component.html
  91. 7
      ui-ngx/src/app/modules/home/pages/customer/customer-tabs.component.html
  92. 5
      ui-ngx/src/app/modules/home/pages/entity-view/entity-view-tabs.component.html
  93. 28
      ui-ngx/src/app/shared/components/color-picker/color-input.base.scss
  94. 2
      ui-ngx/src/app/shared/components/color-picker/color-picker-panel.component.scss
  95. 6
      ui-ngx/src/app/shared/components/color-picker/color-picker.component.html
  96. 4
      ui-ngx/src/app/shared/components/color-picker/color-picker.component.scss
  97. 6
      ui-ngx/src/app/shared/components/color-picker/hex-input.component.scss
  98. 54
      ui-ngx/src/app/shared/components/color-picker/hsla-input.component.html
  99. 72
      ui-ngx/src/app/shared/components/color-picker/hsla-input.component.ts
  100. 46
      ui-ngx/src/app/shared/components/color-picker/input-change.directive.ts

4
.github/release.yml

@ -19,6 +19,10 @@ changelog:
labels: labels:
- Ignore for release - Ignore for release
categories: categories:
- title: 'Security'
labels:
- 'Security'
- title: 'Major Core & Rule Engine' - title: 'Major Core & Rule Engine'
labels: labels:
- 'Major Core' - 'Major Core'

2
.github/workflows/license-header-format.yml

@ -35,7 +35,7 @@ jobs:
uses: actions/setup-java@v4 uses: actions/setup-java@v4
with: with:
distribution: 'corretto' # https://github.com/actions/setup-java?tab=readme-ov-file#supported-distributions 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 cache: 'maven' # https://github.com/actions/setup-java?tab=readme-ov-file#caching-sbt-dependencies
- name: License header format - name: License header format

36
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 #### Prerequisites
Before continuing to installation, execute the following commands to install the necessary tools: Before continuing to installation, execute the following commands to install the necessary tools:
```bash ```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} {:copy-code}
``` ```
#### Step 1. Install Java 17 (OpenJDK) #### Step 1. Install Java 25 (OpenJDK)
ThingsBoard service is running on Java 17. To install OpenJDK 17, follow these instructions: ThingsBoard service is running on Java 25. To install OpenJDK 25, follow these instructions:
```bash ```bash
sudo dnf install java-17-openjdk sudo dnf install -y java-25-openjdk
{:copy-code} {: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 ```bash
sudo update-alternatives --config java sudo update-alternatives --config java
@ -33,7 +35,7 @@ java -version
The expected result is: The expected result is:
```text ```text
openjdk version "17.x.xx" openjdk version "25.x.xx"
OpenJDK Runtime Environment (...) OpenJDK Runtime Environment (...)
OpenJDK 64-Bit Server VM (build ...) OpenJDK 64-Bit Server VM (build ...)
``` ```
@ -41,7 +43,7 @@ OpenJDK 64-Bit Server VM (build ...)
#### Step 2. Configure ThingsBoard Edge Database #### Step 2. Configure ThingsBoard Edge Database
ThingsBoard Edge supports **SQL** and **hybrid** database configurations. 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 <a href="https://thingsboard.io/docs/user-guide/install/edge/rhel/#step-2-configure-thingsboard-database" target="_blank">ThingsBoard documentation site</a>. For more details about the hybrid setup, please refer to the official installation instructions on the <a href="https://thingsboard.io/docs/user-guide/install/edge/rhel/#step-2-configure-thingsboard-database" target="_blank">ThingsBoard documentation site</a>.
To install the PostgreSQL database, run these commands: To install the PostgreSQL database, run these commands:
@ -54,19 +56,19 @@ sudo dnf update
Install the repository RPM: Install the repository RPM:
* **For CentOS/RHEL 8:** * **For RHEL/CentOS 9:**
```bash ```bash
# Install the repository RPM (For CentOS/RHEL 8): # Install the repository RPM (for RHEL/CentOS 9):
sudo sudo dnf -y install https://download.postgresql.org/pub/repos/yum/reporpms/EL-8-x86_64/pgdg-redhat-repo-latest.noarch.rpm sudo dnf -y install https://download.postgresql.org/pub/repos/yum/reporpms/EL-9-x86_64/pgdg-redhat-repo-latest.noarch.rpm
{:copy-code} {:copy-code}
``` ```
* **For CentOS/RHEL 9:** * **For RHEL/CentOS 10:**
```bash ```bash
# Install the repository RPM (for CentOS 9): # Install the repository RPM (for RHEL/CentOS 10):
sudo dnf -y install https://download.postgresql.org/pub/repos/yum/reporpms/EL-9-x86_64/pgdg-redhat-repo-latest.noarch.rpm sudo dnf -y install https://download.postgresql.org/pub/repos/yum/reporpms/EL-10-x86_64/pgdg-redhat-repo-latest.noarch.rpm
{:copy-code} {:copy-code}
``` ```
@ -91,8 +93,8 @@ sudo -u postgres psql -c "\password"
Then, enter and confirm the 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 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, 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. 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: 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} {: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. and create the ThingsBoard Edge database (tb_edge). To connect to the PostgreSQL database, enter the PostgreSQL password.
```bash ```bash

12
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. 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) **Note:** OpenJDK 25 requires Ubuntu 22.04 LTS or newer. Earlier versions (20.04 and below) are not supported.
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 ```bash
sudo apt update && sudo apt install openjdk-17-jdk sudo apt update && sudo apt install openjdk-25-jdk
{:copy-code} {: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 ```bash
sudo update-alternatives --config java sudo update-alternatives --config java
@ -25,7 +27,7 @@ java -version
The expected result is: The expected result is:
```text ```text
openjdk version "17.x.xx" openjdk version "25.x.xx"
OpenJDK Runtime Environment (...) OpenJDK Runtime Environment (...)
OpenJDK 64-Bit Server VM (build ...) OpenJDK 64-Bit Server VM (build ...)
``` ```

2
application/src/main/data/json/system/widget_types/bar_chart_with_labels.json

@ -11,7 +11,7 @@
"resources": [], "resources": [],
"templateHtml": "<tb-bar-chart-with-labels-widget \n [ctx]=\"ctx\">\n</tb-bar-chart-with-labels-widget>", "templateHtml": "<tb-bar-chart-with-labels-widget \n [ctx]=\"ctx\">\n</tb-bar-chart-with-labels-widget>",
"templateCss": ".legend {\n font-size: 13px;\n line-height: 10px;\n}\n\n.legend table { \n border-spacing: 0px;\n border-collapse: separate;\n}\n\n.mouse-events .flot-overlay {\n cursor: crosshair; \n}\n\n", "templateCss": ".legend {\n font-size: 13px;\n line-height: 10px;\n}\n\n.legend table { \n border-spacing: 0px;\n border-collapse: separate;\n}\n\n.mouse-events .flot-overlay {\n cursor: crosshair; \n}\n\n",
"controllerScript": "self.onInit = function() {\n self.ctx.$scope.barChartWidget.onInit();\n};\n\nself.onDataUpdated = function() {\n self.ctx.$scope.barChartWidget.onDataUpdated();\n}\n\nself.typeParameters = function() {\n return {\n previewWidth: '80%',\n embedTitlePanel: true,\n embedActionsPanel: true,\n supportsUnitConversion: true,\n hasAdditionalLatestDataKeys: false,\n defaultDataKeysFunction: function() {\n return [{ name: 'humidity', label: 'Humidity', type: 'timeseries' }];\n }\n };\n}\n", "controllerScript": "self.onInit = function() {\n self.ctx.$scope.barChartWidget.onInit();\n};\n\nself.onDataUpdated = function() {\n self.ctx.$scope.barChartWidget.onDataUpdated();\n}\n\nself.onLatestDataUpdated = function() {\n self.ctx.$scope.barChartWidget.onLatestDataUpdated();\n}\n\nself.typeParameters = function() {\n return {\n previewWidth: '80%',\n embedTitlePanel: true,\n embedActionsPanel: true,\n supportsUnitConversion: true,\n hasAdditionalLatestDataKeys: false,\n defaultDataKeysFunction: function() {\n return [{ name: 'humidity', label: 'Humidity', type: 'timeseries' }];\n }\n };\n}\n",
"settingsForm": [], "settingsForm": [],
"dataKeySettingsForm": [], "dataKeySettingsForm": [],
"latestDataKeySettingsForm": [], "latestDataKeySettingsForm": [],

2
application/src/main/data/json/system/widget_types/range_chart.json

@ -11,7 +11,7 @@
"resources": [], "resources": [],
"templateHtml": "<tb-range-chart-widget \n [ctx]=\"ctx\">\n</tb-range-chart-widget>", "templateHtml": "<tb-range-chart-widget \n [ctx]=\"ctx\">\n</tb-range-chart-widget>",
"templateCss": ".legend {\n font-size: 13px;\n line-height: 10px;\n}\n\n.legend table { \n border-spacing: 0px;\n border-collapse: separate;\n}\n\n.mouse-events .flot-overlay {\n cursor: crosshair; \n}\n\n", "templateCss": ".legend {\n font-size: 13px;\n line-height: 10px;\n}\n\n.legend table { \n border-spacing: 0px;\n border-collapse: separate;\n}\n\n.mouse-events .flot-overlay {\n cursor: crosshair; \n}\n\n",
"controllerScript": "self.onInit = function() {\n self.ctx.$scope.rangeChartWidget.onInit();\n};\n\nself.onDataUpdated = function() {\n self.ctx.$scope.rangeChartWidget.onDataUpdated();\n}\n\nself.typeParameters = function() {\n return {\n maxDatasources: 1,\n maxDataKeys: 1,\n singleEntity: true,\n previewWidth: '80%',\n embedTitlePanel: true,\n embedActionsPanel: true,\n supportsUnitConversion: true,\n hasAdditionalLatestDataKeys: false,\n defaultDataKeysFunction: function() {\n return [{ name: 'temperature', label: 'Temperature', type: 'timeseries' }];\n }\n };\n}\n", "controllerScript": "self.onInit = function() {\n self.ctx.$scope.rangeChartWidget.onInit();\n};\n\nself.onDataUpdated = function() {\n self.ctx.$scope.rangeChartWidget.onDataUpdated();\n}\n\nself.onLatestDataUpdated = function() {\n self.ctx.$scope.rangeChartWidget.onLatestDataUpdated();\n}\n\nself.typeParameters = function() {\n return {\n maxDatasources: 1,\n maxDataKeys: 1,\n singleEntity: true,\n previewWidth: '80%',\n embedTitlePanel: true,\n embedActionsPanel: true,\n supportsUnitConversion: true,\n hasAdditionalLatestDataKeys: false,\n defaultDataKeysFunction: function() {\n return [{ name: 'temperature', label: 'Temperature', type: 'timeseries' }];\n }\n };\n}\n",
"settingsForm": [], "settingsForm": [],
"dataKeySettingsForm": [], "dataKeySettingsForm": [],
"latestDataKeySettingsForm": [], "latestDataKeySettingsForm": [],

58
application/src/main/java/org/thingsboard/server/controller/EntityQueryController.java

@ -36,6 +36,7 @@ import org.thingsboard.server.common.data.query.AlarmCountQuery;
import org.thingsboard.server.common.data.query.AlarmData; import org.thingsboard.server.common.data.query.AlarmData;
import org.thingsboard.server.common.data.query.AlarmDataQuery; import org.thingsboard.server.common.data.query.AlarmDataQuery;
import org.thingsboard.server.common.data.query.AvailableEntityKeys; import org.thingsboard.server.common.data.query.AvailableEntityKeys;
import org.thingsboard.server.common.data.query.AvailableEntityKeysV2;
import org.thingsboard.server.common.data.query.EntityCountQuery; import org.thingsboard.server.common.data.query.EntityCountQuery;
import org.thingsboard.server.common.data.query.EntityData; import org.thingsboard.server.common.data.query.EntityData;
import org.thingsboard.server.common.data.query.EntityDataPageLink; import org.thingsboard.server.common.data.query.EntityDataPageLink;
@ -47,6 +48,8 @@ import org.thingsboard.server.queue.util.TbCoreComponent;
import org.thingsboard.server.service.query.EntityQueryService; import org.thingsboard.server.service.query.EntityQueryService;
import org.thingsboard.server.service.security.permission.Operation; import org.thingsboard.server.service.security.permission.Operation;
import java.util.Set;
import static org.thingsboard.server.controller.ControllerConstants.ALARM_DATA_QUERY_DESCRIPTION; import static org.thingsboard.server.controller.ControllerConstants.ALARM_DATA_QUERY_DESCRIPTION;
import static org.thingsboard.server.controller.ControllerConstants.ENTITY_COUNT_QUERY_DESCRIPTION; import static org.thingsboard.server.controller.ControllerConstants.ENTITY_COUNT_QUERY_DESCRIPTION;
import static org.thingsboard.server.controller.ControllerConstants.ENTITY_DATA_QUERY_DESCRIPTION; import static org.thingsboard.server.controller.ControllerConstants.ENTITY_DATA_QUERY_DESCRIPTION;
@ -115,9 +118,11 @@ public class EntityQueryController extends BaseController {
return entityQueryService.countAlarmsByQuery(getCurrentUser(), query); return entityQueryService.countAlarmsByQuery(getCurrentUser(), query);
} }
@Deprecated(forRemoval = true)
@ApiOperation( @ApiOperation(
value = "Find Available Entity Keys by Query", value = "Find Available Entity Keys by Query (deprecated)",
notes = """ notes = """
**Deprecated.** Use the V2 endpoint (`POST /api/v2/entitiesQuery/find/keys`) instead.\n
Returns unique time series and/or attribute key names from entities matching the query.\n Returns unique time series and/or attribute key names from entities matching the query.\n
Executes the Entity Data Query to find up to 100 entities, then fetches and aggregates all distinct key names.\n Executes the Entity Data Query to find up to 100 entities, then fetches and aggregates all distinct key names.\n
Primarily used for UI features like autocomplete suggestions.""" + TENANT_OR_CUSTOMER_AUTHORITY_PARAGRAPH Primarily used for UI features like autocomplete suggestions.""" + TENANT_OR_CUSTOMER_AUTHORITY_PARAGRAPH
@ -128,9 +133,6 @@ public class EntityQueryController extends BaseController {
@Parameter(description = "Entity data query to find entities. Page size is capped at 100.") @Parameter(description = "Entity data query to find entities. Page size is capped at 100.")
@RequestBody EntityDataQuery query, @RequestBody EntityDataQuery query,
// fixme: combination of timeseries = false and attributes = false is allowed, but always results in empty response, therefore does not make any sense
// such combinations should NOT be allowed, but changing this will break clients
@Parameter(description = """ @Parameter(description = """
When true, includes unique time series key names in the response. When true, includes unique time series key names in the response.
When false, the 'timeseries' list will be empty.""") When false, the 'timeseries' list will be empty.""")
@ -155,6 +157,54 @@ public class EntityQueryController extends BaseController {
return wrapFuture(entityQueryService.getKeysByQuery(getCurrentUser(), getTenantId(), query, includeTimeseries, includeAttributes, scope)); return wrapFuture(entityQueryService.getKeysByQuery(getCurrentUser(), getTenantId(), query, includeTimeseries, includeAttributes, scope));
} }
@ApiOperation(
value = "Find Available Entity Keys By Query",
notes = """
Discovers unique time series and/or attribute key names available on entities that match the given query.
Works in two steps: first, the request body (an Entity Data Query) is executed to find matching entities
(page size is capped at 100); then, all distinct key names are collected from those entities.\n
Optionally, each key can include a sample — the most recent value (by timestamp) for that key
across all matched entities."""
+ TENANT_OR_CUSTOMER_AUTHORITY_PARAGRAPH
)
@PreAuthorize("hasAnyAuthority('TENANT_ADMIN', 'CUSTOMER_USER')")
@PostMapping("/v2/entitiesQuery/find/keys")
public DeferredResult<AvailableEntityKeysV2> findAvailableEntityKeysByQueryV2(
@Parameter(description = "Entity data query to find entities. Page size is capped at 100.")
@RequestBody EntityDataQuery query,
@Parameter(description = """
When true, includes unique time series keys in the response.
When false, the 'timeseries' field is omitted. At least one of 'includeTimeseries' or 'includeAttributes' must be true.""")
@RequestParam(defaultValue = "true") boolean includeTimeseries,
@Parameter(description = """
When true, includes unique attribute keys in the response.
When false, the 'attributes' field is omitted. At least one of 'includeTimeseries' or 'includeAttributes' must be true.""")
@RequestParam(defaultValue = "true") boolean includeAttributes,
@Parameter(description = """
Filters attribute keys by scope. Only applies when 'includeAttributes' is true.
When not specified, scopes are auto-determined: all three scopes (server, client, shared) for device entities,
server scope only for other entity types.""",
schema = @Schema(allowableValues = {"SERVER_SCOPE", "SHARED_SCOPE", "CLIENT_SCOPE"}))
@RequestParam(required = false) Set<AttributeScope> scopes,
@Parameter(description = """
When true, each key entry includes a 'sample' object with the most recent value and timestamp.
When false, only key names are returned (sample is omitted from JSON).""")
@RequestParam(defaultValue = "false") boolean includeSamples
) throws ThingsboardException {
resolveQuery(query);
EntityDataPageLink pageLink = query.getPageLink();
if (pageLink.getPageSize() > MAX_PAGE_SIZE) {
pageLink.setPageSize(MAX_PAGE_SIZE);
}
return wrapFuture(entityQueryService.findAvailableEntityKeysByQuery(
getCurrentUser(), query,
includeTimeseries, includeAttributes, scopes, includeSamples));
}
@PreAuthorize("hasAnyAuthority('SYS_ADMIN')") @PreAuthorize("hasAnyAuthority('SYS_ADMIN')")
@PostMapping("/edqs/system/request") @PostMapping("/edqs/system/request")
public void processSystemEdqsRequest(@RequestBody ToCoreEdqsRequest request) { public void processSystemEdqsRequest(@RequestBody ToCoreEdqsRequest request) {

2
application/src/main/java/org/thingsboard/server/controller/TelemetryController.java

@ -299,7 +299,7 @@ public class TelemetryController extends BaseController {
@Hidden @Hidden
@PreAuthorize("hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN', 'CUSTOMER_USER')") @PreAuthorize("hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN', 'CUSTOMER_USER')")
@GetMapping(value = "/{entityType}/{entityId}/values/timeseries", params = {"keys", "startTs", "endTs"}) @GetMapping(value = "/{entityType}/{entityId}/values/timeseries", params = {"startTs", "endTs"})
public DeferredResult<ResponseEntity> getTimeseries( public DeferredResult<ResponseEntity> getTimeseries(
@PathVariable("entityType") String entityType, @PathVariable("entityType") String entityType,
@PathVariable("entityId") String entityIdStr, @PathVariable("entityId") String entityIdStr,

7
application/src/main/java/org/thingsboard/server/service/entitiy/EntityStateSourcingListener.java

@ -45,6 +45,7 @@ import org.thingsboard.server.common.data.id.EntityId;
import org.thingsboard.server.common.data.id.RuleChainId; import org.thingsboard.server.common.data.id.RuleChainId;
import org.thingsboard.server.common.data.id.TenantId; import org.thingsboard.server.common.data.id.TenantId;
import org.thingsboard.server.common.data.job.Job; import org.thingsboard.server.common.data.job.Job;
import org.thingsboard.server.common.data.job.JobStatus;
import org.thingsboard.server.common.data.msg.TbMsgType; import org.thingsboard.server.common.data.msg.TbMsgType;
import org.thingsboard.server.common.data.notification.NotificationRequest; import org.thingsboard.server.common.data.notification.NotificationRequest;
import org.thingsboard.server.common.data.plugin.ComponentLifecycleEvent; import org.thingsboard.server.common.data.plugin.ComponentLifecycleEvent;
@ -360,10 +361,12 @@ public class EntityStateSourcingListener {
jobManager.onJobUpdate(job); jobManager.onJobUpdate(job);
ComponentLifecycleEvent event; ComponentLifecycleEvent event;
if (job.getResult().getCancellationTs() > 0) { if (job.getStatus() == JobStatus.CANCELLED) {
event = ComponentLifecycleEvent.STOPPED; event = ComponentLifecycleEvent.STOPPED;
} else if (job.getResult().getGeneralError() != null) { } else if (job.getStatus() == JobStatus.FAILED) {
event = ComponentLifecycleEvent.FAILED; event = ComponentLifecycleEvent.FAILED;
} else if (job.getStatus() == JobStatus.COMPLETED) {
event = ComponentLifecycleEvent.UPDATED;
} else { } else {
return; return;
} }

92
application/src/main/java/org/thingsboard/server/service/job/DefaultJobManager.java

@ -15,15 +15,20 @@
*/ */
package org.thingsboard.server.service.job; package org.thingsboard.server.service.job;
import com.google.common.util.concurrent.FutureCallback;
import com.google.common.util.concurrent.Futures; import com.google.common.util.concurrent.Futures;
import com.google.common.util.concurrent.ListenableFuture; import com.google.common.util.concurrent.ListenableFuture;
import com.google.common.util.concurrent.MoreExecutors;
import jakarta.annotation.Nullable;
import jakarta.annotation.PreDestroy; import jakarta.annotation.PreDestroy;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.ObjectUtils; import org.apache.commons.lang3.ObjectUtils;
import org.springframework.context.event.EventListener;
import org.springframework.stereotype.Component; import org.springframework.stereotype.Component;
import org.thingsboard.common.util.JacksonUtil; import org.thingsboard.common.util.JacksonUtil;
import org.thingsboard.common.util.ThingsBoardExecutors; import org.thingsboard.common.util.ThingsBoardExecutors;
import org.thingsboard.rule.engine.api.JobManager; import org.thingsboard.rule.engine.api.JobManager;
import org.thingsboard.server.common.data.EntityType;
import org.thingsboard.server.common.data.id.EntityId; import org.thingsboard.server.common.data.id.EntityId;
import org.thingsboard.server.common.data.id.JobId; import org.thingsboard.server.common.data.id.JobId;
import org.thingsboard.server.common.data.id.TenantId; import org.thingsboard.server.common.data.id.TenantId;
@ -33,7 +38,10 @@ import org.thingsboard.server.common.data.job.JobStatus;
import org.thingsboard.server.common.data.job.JobType; import org.thingsboard.server.common.data.job.JobType;
import org.thingsboard.server.common.data.job.task.Task; import org.thingsboard.server.common.data.job.task.Task;
import org.thingsboard.server.common.data.job.task.TaskResult; import org.thingsboard.server.common.data.job.task.TaskResult;
import org.thingsboard.server.common.data.plugin.ComponentLifecycleEvent;
import org.thingsboard.server.common.msg.plugin.ComponentLifecycleMsg;
import org.thingsboard.server.common.msg.queue.ServiceType; import org.thingsboard.server.common.msg.queue.ServiceType;
import org.thingsboard.server.common.msg.queue.TbCallback;
import org.thingsboard.server.common.msg.queue.TopicPartitionInfo; import org.thingsboard.server.common.msg.queue.TopicPartitionInfo;
import org.thingsboard.server.dao.job.JobService; import org.thingsboard.server.dao.job.JobService;
import org.thingsboard.server.gen.transport.TransportProtos.TaskProto; import org.thingsboard.server.gen.transport.TransportProtos.TaskProto;
@ -50,7 +58,10 @@ import java.util.Arrays;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.UUID; import java.util.UUID;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ExecutorService; import java.util.concurrent.ExecutorService;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.TimeUnit;
import java.util.function.Function; import java.util.function.Function;
import java.util.stream.Collectors; import java.util.stream.Collectors;
@ -65,6 +76,8 @@ public class DefaultJobManager implements JobManager {
private final Map<JobType, JobProcessor> jobProcessors; private final Map<JobType, JobProcessor> jobProcessors;
private final Map<JobType, TbQueueProducer<TbProtoQueueMsg<TaskProto>>> taskProducers; private final Map<JobType, TbQueueProducer<TbProtoQueueMsg<TaskProto>>> taskProducers;
private final ExecutorService executor; private final ExecutorService executor;
private final ConcurrentHashMap<JobId, TbCallback> finishCallbacks = new ConcurrentHashMap<>();
private final ScheduledExecutorService cleanupScheduler;
public DefaultJobManager(JobService jobService, JobStatsService jobStatsService, PartitionService partitionService, public DefaultJobManager(JobService jobService, JobStatsService jobStatsService, PartitionService partitionService,
TaskProducerQueueFactory queueFactory, TasksQueueConfig queueConfig, TaskProducerQueueFactory queueFactory, TasksQueueConfig queueConfig,
@ -76,6 +89,8 @@ public class DefaultJobManager implements JobManager {
this.jobProcessors = jobProcessors.stream().collect(Collectors.toMap(JobProcessor::getType, Function.identity())); this.jobProcessors = jobProcessors.stream().collect(Collectors.toMap(JobProcessor::getType, Function.identity()));
this.taskProducers = Arrays.stream(JobType.values()).collect(Collectors.toMap(Function.identity(), queueFactory::createTaskProducer)); this.taskProducers = Arrays.stream(JobType.values()).collect(Collectors.toMap(Function.identity(), queueFactory::createTaskProducer));
this.executor = ThingsBoardExecutors.newWorkStealingPool(Math.max(4, Runtime.getRuntime().availableProcessors()), getClass()); this.executor = ThingsBoardExecutors.newWorkStealingPool(Math.max(4, Runtime.getRuntime().availableProcessors()), getClass());
this.cleanupScheduler = ThingsBoardExecutors.newSingleThreadScheduledExecutor("job-callback-cleanup");
this.cleanupScheduler.scheduleWithFixedDelay(this::cleanupStaleCallbacks, 1, 1, TimeUnit.HOURS);
} }
@Override @Override
@ -84,6 +99,25 @@ public class DefaultJobManager implements JobManager {
return Futures.submit(() -> jobService.saveJob(job.getTenantId(), job), executor); return Futures.submit(() -> jobService.saveJob(job.getTenantId(), job), executor);
} }
@Override
public ListenableFuture<Job> submitJob(Job job, TbCallback finishCallback) {
ListenableFuture<Job> saveFuture = submitJob(job);
if (finishCallback != null) {
Futures.addCallback(saveFuture, new FutureCallback<>() {
@Override
public void onSuccess(Job savedJob) {
finishCallbacks.put(savedJob.getId(), finishCallback);
}
@Override
public void onFailure(Throwable t) {
finishCallback.onFailure(t);
}
}, MoreExecutors.directExecutor());
}
return saveFuture;
}
@Override @Override
public void onJobUpdate(Job job) { public void onJobUpdate(Job job) {
JobStatus status = job.getStatus(); JobStatus status = job.getStatus();
@ -109,6 +143,35 @@ public class DefaultJobManager implements JobManager {
} }
} }
@EventListener
public void onJobUpdateEvent(ComponentLifecycleMsg event) {
EntityId entityId = event.getEntityId();
if (entityId.getEntityType() != EntityType.JOB) {
return;
}
ComponentLifecycleEvent lifecycleEvent = event.getEvent();
if (!lifecycleEvent.equals(ComponentLifecycleEvent.STOPPED) &&
!lifecycleEvent.equals(ComponentLifecycleEvent.FAILED) &&
!lifecycleEvent.equals(ComponentLifecycleEvent.UPDATED)) {
return;
}
JobId jobId = new JobId(entityId.getId());
TbCallback callback = finishCallbacks.remove(jobId);
if (callback == null) {
return;
}
executor.execute(() -> {
try {
Job job = jobService.findJobById(event.getTenantId(), jobId);
invokeFinishCallback(job, callback);
} catch (Throwable e) {
log.error("[{}] Failed to invoke finish callback", jobId, e);
callback.onFailure(e);
}
});
}
private void processJob(Job job) { private void processJob(Job job) {
TenantId tenantId = job.getTenantId(); TenantId tenantId = job.getTenantId();
JobId jobId = job.getId(); JobId jobId = job.getId();
@ -195,12 +258,41 @@ public class DefaultJobManager implements JobManager {
}); });
} }
private void invokeFinishCallback(@Nullable Job job, TbCallback callback) {
if (job == null) {
callback.onFailure(new RuntimeException("Job not found"));
} else if (job.getStatus() == JobStatus.COMPLETED) {
callback.onSuccess();
} else {
callback.onFailure(new RuntimeException(job.getError()));
}
}
private void cleanupStaleCallbacks() {
finishCallbacks.entrySet().removeIf(entry -> {
JobId jobId = entry.getKey();
try {
Job job = jobService.findJobById(TenantId.SYS_TENANT_ID, jobId);
if (job == null || job.getStatus().isOneOf(JobStatus.COMPLETED, JobStatus.FAILED, JobStatus.CANCELLED)) {
invokeFinishCallback(job, entry.getValue());
return true;
}
return false;
} catch (Throwable e) {
log.error("[{}] Failed to cleanup stale callback", jobId, e);
entry.getValue().onFailure(e);
return true;
}
});
}
private JobProcessor getJobProcessor(JobType jobType) { private JobProcessor getJobProcessor(JobType jobType) {
return jobProcessors.get(jobType); return jobProcessors.get(jobType);
} }
@PreDestroy @PreDestroy
private void destroy() { private void destroy() {
cleanupScheduler.shutdownNow();
executor.shutdownNow(); executor.shutdownNow();
} }

145
application/src/main/java/org/thingsboard/server/service/query/DefaultEntityQueryService.java

@ -15,13 +15,15 @@
*/ */
package org.thingsboard.server.service.query; package org.thingsboard.server.service.query;
import com.fasterxml.jackson.databind.JsonNode;
import com.google.common.util.concurrent.Futures; import com.google.common.util.concurrent.Futures;
import com.google.common.util.concurrent.ListenableFuture; import com.google.common.util.concurrent.ListenableFuture;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.apache.commons.collections4.CollectionUtils;
import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value; import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import org.springframework.util.CollectionUtils; import org.thingsboard.common.util.JacksonUtil;
import org.thingsboard.common.util.KvUtil; import org.thingsboard.common.util.KvUtil;
import org.thingsboard.server.common.data.AttributeScope; import org.thingsboard.server.common.data.AttributeScope;
import org.thingsboard.server.common.data.EntityType; import org.thingsboard.server.common.data.EntityType;
@ -30,11 +32,17 @@ import org.thingsboard.server.common.data.exception.ThingsboardException;
import org.thingsboard.server.common.data.id.EntityId; import org.thingsboard.server.common.data.id.EntityId;
import org.thingsboard.server.common.data.id.TenantId; import org.thingsboard.server.common.data.id.TenantId;
import org.thingsboard.server.common.data.kv.AttributeKvEntry; import org.thingsboard.server.common.data.kv.AttributeKvEntry;
import org.thingsboard.server.common.data.kv.DataType;
import org.thingsboard.server.common.data.kv.KvEntry;
import org.thingsboard.server.common.data.kv.TsKvEntry;
import org.thingsboard.server.common.data.page.PageData; import org.thingsboard.server.common.data.page.PageData;
import org.thingsboard.server.common.data.query.AlarmCountQuery; import org.thingsboard.server.common.data.query.AlarmCountQuery;
import org.thingsboard.server.common.data.query.AlarmData; import org.thingsboard.server.common.data.query.AlarmData;
import org.thingsboard.server.common.data.query.AlarmDataQuery; import org.thingsboard.server.common.data.query.AlarmDataQuery;
import org.thingsboard.server.common.data.query.AvailableEntityKeys; import org.thingsboard.server.common.data.query.AvailableEntityKeys;
import org.thingsboard.server.common.data.query.AvailableEntityKeysV2;
import org.thingsboard.server.common.data.query.AvailableEntityKeysV2.KeyInfo;
import org.thingsboard.server.common.data.query.AvailableEntityKeysV2.KeySample;
import org.thingsboard.server.common.data.query.ComplexFilterPredicate; import org.thingsboard.server.common.data.query.ComplexFilterPredicate;
import org.thingsboard.server.common.data.query.DynamicValue; import org.thingsboard.server.common.data.query.DynamicValue;
import org.thingsboard.server.common.data.query.EntityCountQuery; import org.thingsboard.server.common.data.query.EntityCountQuery;
@ -59,11 +67,13 @@ import org.thingsboard.server.service.security.model.SecurityUser;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.Collections; import java.util.Collections;
import java.util.Comparator;
import java.util.LinkedHashMap; import java.util.LinkedHashMap;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.Optional; import java.util.Optional;
import java.util.Set; import java.util.Set;
import java.util.TreeMap;
import java.util.concurrent.ExecutionException; import java.util.concurrent.ExecutionException;
import java.util.stream.Collectors; import java.util.stream.Collectors;
@ -253,7 +263,7 @@ public class DefaultEntityQueryService implements EntityQueryService {
if (isAttributes) { if (isAttributes) {
Map<EntityType, List<EntityId>> typesMap = ids.stream().collect(Collectors.groupingBy(EntityId::getEntityType)); Map<EntityType, List<EntityId>> typesMap = ids.stream().collect(Collectors.groupingBy(EntityId::getEntityType));
List<ListenableFuture<List<String>>> futures = new ArrayList<>(typesMap.size()); List<ListenableFuture<List<String>>> futures = new ArrayList<>(typesMap.size());
typesMap.forEach((type, entityIds) -> futures.add(dbCallbackExecutor.submit(() -> attributesService.findAllKeysByEntityIds(tenantId, entityIds, scope)))); typesMap.forEach((type, entityIds) -> futures.add(dbCallbackExecutor.submit(() -> attributesService.findAllKeysByEntityIdsAndScope(tenantId, entityIds, scope))));
attributesKeysFuture = Futures.transform(Futures.allAsList(futures), lists -> { attributesKeysFuture = Futures.transform(Futures.allAsList(futures), lists -> {
if (CollectionUtils.isEmpty(lists)) { if (CollectionUtils.isEmpty(lists)) {
return Collections.emptyList(); return Collections.emptyList();
@ -274,4 +284,135 @@ public class DefaultEntityQueryService implements EntityQueryService {
}, dbCallbackExecutor); }, dbCallbackExecutor);
} }
@Override
public ListenableFuture<AvailableEntityKeysV2> findAvailableEntityKeysByQuery(SecurityUser securityUser, EntityDataQuery query,
boolean includeTimeseries, boolean includeAttributes,
Set<AttributeScope> scopes, boolean includeSamples) {
if (!includeTimeseries && !includeAttributes) {
return Futures.immediateFailedFuture(
new IllegalArgumentException("At least one of 'includeTimeseries' or 'includeAttributes' must be true"));
}
return Futures.transformAsync(findEntityIdsByQueryAsync(securityUser, query), ids -> {
if (ids.isEmpty()) {
return immediateFuture(new AvailableEntityKeysV2(
Collections.emptySet(),
includeTimeseries ? Collections.emptyList() : null,
includeAttributes ? Collections.emptyMap() : null));
}
TenantId tenantId = securityUser.getTenantId();
Set<EntityType> entityTypes = ids.stream().map(EntityId::getEntityType).collect(Collectors.toSet());
var tsFuture = includeTimeseries ? fetchTimeseriesKeys(tenantId, ids, includeSamples) : null;
Set<AttributeScope> effectiveScopes = includeAttributes
? resolveAttributeScopes(scopes, entityTypes) : Collections.emptySet();
var attrFutures = effectiveScopes.stream()
.map(scope -> fetchAttributeKeys(tenantId, ids, scope, includeSamples))
.toList();
return assembleResult(entityTypes, tsFuture, attrFutures);
}, dbCallbackExecutor);
}
private ListenableFuture<List<EntityId>> findEntityIdsByQueryAsync(SecurityUser securityUser, EntityDataQuery query) {
return Futures.transform(entityService.findEntityDataByQueryAsync(securityUser.getTenantId(), securityUser.getCustomerId(), query),
page -> page.getData().stream()
.map(EntityData::getEntityId)
.toList(),
dbCallbackExecutor);
}
private static Set<AttributeScope> resolveAttributeScopes(Set<AttributeScope> requestedScopes, Set<EntityType> entityTypes) {
boolean hasDevices = entityTypes.contains(EntityType.DEVICE);
Set<AttributeScope> scopes;
if (CollectionUtils.isNotEmpty(requestedScopes)) {
scopes = requestedScopes;
} else { // auto-determine scopes
scopes = hasDevices
? Set.of(AttributeScope.SERVER_SCOPE, AttributeScope.CLIENT_SCOPE, AttributeScope.SHARED_SCOPE)
: Collections.singleton(AttributeScope.SERVER_SCOPE);
}
// Non-device entities only support SERVER_SCOPE
if (!hasDevices) {
return scopes.contains(AttributeScope.SERVER_SCOPE)
? Collections.singleton(AttributeScope.SERVER_SCOPE)
: Collections.emptySet();
}
return scopes;
}
private ListenableFuture<List<KeyInfo>> fetchTimeseriesKeys(TenantId tenantId, List<EntityId> entityIds, boolean includeSamples) {
if (includeSamples) {
return Futures.transform(
timeseriesService.findLatestByEntityIdsAsync(tenantId, entityIds),
entries -> toKeyInfos(entries, true),
dbCallbackExecutor);
}
return Futures.transform(
timeseriesService.findAllKeysByEntityIdsAsync(tenantId, entityIds),
keys -> keys.stream().sorted().map(k -> new KeyInfo(k, null)).toList(),
dbCallbackExecutor);
}
private ListenableFuture<Map.Entry<AttributeScope, List<KeyInfo>>> fetchAttributeKeys(
TenantId tenantId, List<EntityId> entityIds, AttributeScope scope, boolean includeSamples) {
if (includeSamples) {
return Futures.transform(
attributesService.findLatestByEntityIdsAndScopeAsync(tenantId, entityIds, scope),
entries -> Map.entry(scope, toKeyInfos(entries, true)),
dbCallbackExecutor);
}
return Futures.transform(
attributesService.findAllKeysByEntityIdsAndScopeAsync(tenantId, entityIds, scope),
keys -> Map.entry(scope, keys.stream().sorted().map(k -> new KeyInfo(k, null)).toList()),
dbCallbackExecutor);
}
private ListenableFuture<AvailableEntityKeysV2> assembleResult(
Set<EntityType> entityTypes,
ListenableFuture<List<KeyInfo>> tsFuture,
List<ListenableFuture<Map.Entry<AttributeScope, List<KeyInfo>>>> attrFutures) {
var allAttrFuture = attrFutures.isEmpty()
? immediateFuture(List.<Map.Entry<AttributeScope, List<KeyInfo>>>of())
: Futures.allAsList(attrFutures);
List<ListenableFuture<?>> allFutures = new ArrayList<>();
if (tsFuture != null) {
allFutures.add(tsFuture);
}
allFutures.add(allAttrFuture);
var finalTsFuture = tsFuture;
return Futures.whenAllComplete(allFutures)
.call(() -> {
List<KeyInfo> tsKeys = finalTsFuture != null ? Futures.getDone(finalTsFuture) : null;
Map<AttributeScope, List<KeyInfo>> attrMap = attrFutures.isEmpty() ? null : new TreeMap<>();
if (attrMap != null) {
for (var entry : Futures.getDone(allAttrFuture)) {
attrMap.put(entry.getKey(), entry.getValue());
}
}
return new AvailableEntityKeysV2(entityTypes, tsKeys, attrMap);
}, dbCallbackExecutor);
}
private static List<KeyInfo> toKeyInfos(List<? extends KvEntry> entries, boolean includeSamples) {
return entries.stream()
.map(e -> new KeyInfo(e.getKey(), includeSamples ? toKeySample(e) : null))
.sorted(Comparator.comparing(KeyInfo::key))
.toList();
}
private static KeySample toKeySample(KvEntry entry) {
long ts = entry instanceof TsKvEntry tsKv ? tsKv.getTs()
: entry instanceof AttributeKvEntry attr ? attr.getLastUpdateTs()
: 0;
JsonNode value = entry.getDataType() == DataType.JSON
? JacksonUtil.toJsonNode(entry.getJsonValue().get())
: JacksonUtil.valueToTree(entry.getValue());
return new KeySample(ts, value);
}
} }

7
application/src/main/java/org/thingsboard/server/service/query/EntityQueryService.java

@ -23,11 +23,14 @@ import org.thingsboard.server.common.data.query.AlarmCountQuery;
import org.thingsboard.server.common.data.query.AlarmData; import org.thingsboard.server.common.data.query.AlarmData;
import org.thingsboard.server.common.data.query.AlarmDataQuery; import org.thingsboard.server.common.data.query.AlarmDataQuery;
import org.thingsboard.server.common.data.query.AvailableEntityKeys; import org.thingsboard.server.common.data.query.AvailableEntityKeys;
import org.thingsboard.server.common.data.query.AvailableEntityKeysV2;
import org.thingsboard.server.common.data.query.EntityCountQuery; import org.thingsboard.server.common.data.query.EntityCountQuery;
import org.thingsboard.server.common.data.query.EntityData; import org.thingsboard.server.common.data.query.EntityData;
import org.thingsboard.server.common.data.query.EntityDataQuery; import org.thingsboard.server.common.data.query.EntityDataQuery;
import org.thingsboard.server.service.security.model.SecurityUser; import org.thingsboard.server.service.security.model.SecurityUser;
import java.util.Set;
public interface EntityQueryService { public interface EntityQueryService {
long countEntitiesByQuery(SecurityUser securityUser, EntityCountQuery query); long countEntitiesByQuery(SecurityUser securityUser, EntityCountQuery query);
@ -41,4 +44,8 @@ public interface EntityQueryService {
ListenableFuture<AvailableEntityKeys> getKeysByQuery(SecurityUser securityUser, TenantId tenantId, EntityDataQuery query, ListenableFuture<AvailableEntityKeys> getKeysByQuery(SecurityUser securityUser, TenantId tenantId, EntityDataQuery query,
boolean isTimeseries, boolean isAttributes, AttributeScope scope); boolean isTimeseries, boolean isAttributes, AttributeScope scope);
ListenableFuture<AvailableEntityKeysV2> findAvailableEntityKeysByQuery(SecurityUser securityUser, EntityDataQuery query,
boolean includeTimeseries, boolean includeAttributes,
Set<AttributeScope> scopes, boolean includeSamples);
} }

2
application/src/main/resources/thingsboard.yml

@ -341,6 +341,8 @@ cassandra:
set_null_values_enabled: "${CASSANDRA_QUERY_SET_NULL_VALUES_ENABLED:true}" set_null_values_enabled: "${CASSANDRA_QUERY_SET_NULL_VALUES_ENABLED:true}"
# log one of cassandra queries with specified frequency (0 - logging is disabled) # log one of cassandra queries with specified frequency (0 - logging is disabled)
print_queries_freq: "${CASSANDRA_QUERY_PRINT_FREQ:0}" print_queries_freq: "${CASSANDRA_QUERY_PRINT_FREQ:0}"
# Maximum total size in bytes of a Cassandra query result set across all pages. Default is 50MB. 0 means unlimited
max_result_set_size_in_bytes: "${CASSANDRA_QUERY_MAX_RESULT_SET_SIZE_IN_BYTES:52428800}"
tenant_rate_limits: tenant_rate_limits:
# Whether to print rate-limited tenant names when printing Cassandra query queue statistic # Whether to print rate-limited tenant names when printing Cassandra query queue statistic
print_tenant_names: "${CASSANDRA_QUERY_TENANT_RATE_LIMITS_PRINT_TENANT_NAMES:false}" print_tenant_names: "${CASSANDRA_QUERY_TENANT_RATE_LIMITS_PRINT_TENANT_NAMES:false}"

58
application/src/test/java/org/thingsboard/server/controller/AbstractWebTest.java

@ -123,6 +123,8 @@ import org.thingsboard.server.common.data.id.UUIDBased;
import org.thingsboard.server.common.data.id.UserId; import org.thingsboard.server.common.data.id.UserId;
import org.thingsboard.server.common.data.job.Job; import org.thingsboard.server.common.data.job.Job;
import org.thingsboard.server.common.data.job.JobType; import org.thingsboard.server.common.data.job.JobType;
import org.thingsboard.server.common.data.kv.KvEntry;
import org.thingsboard.server.common.data.kv.TsKvEntry;
import org.thingsboard.server.common.data.notification.Notification; import org.thingsboard.server.common.data.notification.Notification;
import org.thingsboard.server.common.data.notification.NotificationDeliveryMethod; import org.thingsboard.server.common.data.notification.NotificationDeliveryMethod;
import org.thingsboard.server.common.data.notification.NotificationType; import org.thingsboard.server.common.data.notification.NotificationType;
@ -177,6 +179,7 @@ import java.nio.charset.StandardCharsets;
import java.sql.SQLException; import java.sql.SQLException;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.Arrays; import java.util.Arrays;
import java.util.Collection;
import java.util.Collections; import java.util.Collections;
import java.util.Comparator; import java.util.Comparator;
import java.util.HashMap; import java.util.HashMap;
@ -718,6 +721,10 @@ public abstract class AbstractWebTest extends AbstractInMemoryStorageTest {
return assetProfile; return assetProfile;
} }
protected Device createDevice(String name) throws Exception {
return createDevice(name, "default", null, null);
}
protected Device createDevice(String name, String accessToken) throws Exception { protected Device createDevice(String name, String accessToken) throws Exception {
return createDevice(name, "default", null, accessToken); return createDevice(name, "default", null, accessToken);
} }
@ -731,7 +738,11 @@ public abstract class AbstractWebTest extends AbstractInMemoryStorageTest {
deviceData.setTransportConfiguration(new DefaultDeviceTransportConfiguration()); deviceData.setTransportConfiguration(new DefaultDeviceTransportConfiguration());
deviceData.setConfiguration(new DefaultDeviceConfiguration()); deviceData.setConfiguration(new DefaultDeviceConfiguration());
device.setDeviceData(deviceData); device.setDeviceData(deviceData);
return doPost("/api/device?accessToken=" + accessToken, device, Device.class); if (accessToken != null) {
return doPost("/api/device?accessToken=" + accessToken, device, Device.class);
} else {
return doPost("/api/device", device, Device.class);
}
} }
protected Device assignDeviceToCustomer(DeviceId deviceId, CustomerId customerId) { protected Device assignDeviceToCustomer(DeviceId deviceId, CustomerId customerId) {
@ -1219,7 +1230,7 @@ public abstract class AbstractWebTest extends AbstractInMemoryStorageTest {
Awaitility.await("CF state for entity actor ready to refresh dynamic arguments").atMost(TIMEOUT, TimeUnit.SECONDS).until(() -> { Awaitility.await("CF state for entity actor ready to refresh dynamic arguments").atMost(TIMEOUT, TimeUnit.SECONDS).until(() -> {
CalculatedFieldState calculatedFieldState = statesMap.get(cfId); CalculatedFieldState calculatedFieldState = statesMap.get(cfId);
boolean isReady = calculatedFieldState != null && ((GeofencingCalculatedFieldState) calculatedFieldState).getLastScheduledRefreshTs() < boolean isReady = calculatedFieldState != null && ((GeofencingCalculatedFieldState) calculatedFieldState).getLastScheduledRefreshTs() <
System.currentTimeMillis() - TimeUnit.SECONDS.toMillis(scheduledUpdateInterval); System.currentTimeMillis() - TimeUnit.SECONDS.toMillis(scheduledUpdateInterval);
log.warn("entityId {}, cfId {}, state ready to refresh == {}", entityId, cfId, isReady); log.warn("entityId {}, cfId {}, state ready to refresh == {}", entityId, cfId, isReady);
return isReady; return isReady;
}); });
@ -1411,7 +1422,7 @@ public abstract class AbstractWebTest extends AbstractInMemoryStorageTest {
protected List<Job> findJobs(List<JobType> types, List<UUID> entities) throws Exception { protected List<Job> findJobs(List<JobType> types, List<UUID> entities) throws Exception {
return doGetTypedWithPageLink("/api/jobs?types=" + types.stream().map(Enum::name).collect(Collectors.joining(",")) + return doGetTypedWithPageLink("/api/jobs?types=" + types.stream().map(Enum::name).collect(Collectors.joining(",")) +
"&entities=" + entities.stream().map(UUID::toString).collect(Collectors.joining(",")) + "&", "&entities=" + entities.stream().map(UUID::toString).collect(Collectors.joining(",")) + "&",
new TypeReference<PageData<Job>>() {}, new PageLink(100, 0, null, new SortOrder("createdTime", SortOrder.Direction.DESC))).getData(); new TypeReference<PageData<Job>>() {}, new PageLink(100, 0, null, new SortOrder("createdTime", SortOrder.Direction.DESC))).getData();
} }
@ -1425,12 +1436,37 @@ public abstract class AbstractWebTest extends AbstractInMemoryStorageTest {
protected void postTelemetry(EntityId entityId, String payload) throws Exception { protected void postTelemetry(EntityId entityId, String payload) throws Exception {
doPostAsync("/api/plugins/telemetry/" + entityId.getEntityType() + "/" + entityId.getId() + doPostAsync("/api/plugins/telemetry/" + entityId.getEntityType() + "/" + entityId.getId() +
"/timeseries/" + DataConstants.SERVER_SCOPE, JacksonUtil.toJsonNode(payload), 30_000L).andExpect(status().isOk()); "/timeseries/" + DataConstants.SERVER_SCOPE, JacksonUtil.toJsonNode(payload), 30_000L).andExpect(status().isOk());
}
protected void postTelemetry(EntityId entityId, TsKvEntry entry) throws Exception {
var values = JacksonUtil.newObjectNode();
JacksonUtil.addKvEntry(values, entry);
var payload = JacksonUtil.newObjectNode()
.put("ts", entry.getTs())
.set("values", values);
var url = "/api/plugins/telemetry/" + entityId.getEntityType() + "/" + entityId.getId() + "/timeseries/any";
doPostAsync(url, payload, 30_000L).andExpect(status().isOk());
} }
protected void postAttributes(EntityId entityId, AttributeScope scope, String payload) throws Exception { protected void postAttributes(EntityId entityId, AttributeScope scope, String payload) throws Exception {
doPostAsync("/api/plugins/telemetry/" + entityId.getEntityType() + "/" + entityId.getId() + doPostAsync("/api/plugins/telemetry/" + entityId.getEntityType() + "/" + entityId.getId() +
"/attributes/" + scope, JacksonUtil.toJsonNode(payload), 30_000L).andExpect(status().isOk()); "/attributes/" + scope, JacksonUtil.toJsonNode(payload), 30_000L).andExpect(status().isOk());
}
protected void postAttributes(EntityId entityId, AttributeScope scope, KvEntry... attributes) throws Exception {
postAttributes(entityId, scope, Arrays.asList(attributes));
}
protected void postAttributes(EntityId entityId, AttributeScope scope, Collection<? extends KvEntry> attributes) throws Exception {
var url = "/api/plugins/telemetry/" + entityId.getEntityType() + "/" + entityId.getId() + "/attributes/" + scope;
var payload = JacksonUtil.newObjectNode();
for (KvEntry entry : attributes) {
JacksonUtil.addKvEntry(payload, entry);
}
doPostAsync(url, payload, 30_000L).andExpect(status().isOk());
} }
protected CalculatedField saveCalculatedField(CalculatedField calculatedField) { protected CalculatedField saveCalculatedField(CalculatedField calculatedField) {
@ -1439,7 +1475,7 @@ public abstract class AbstractWebTest extends AbstractInMemoryStorageTest {
protected PageData<CalculatedField> getEntityCalculatedFields(EntityId entityId, CalculatedFieldType type, PageLink pageLink) throws Exception { protected PageData<CalculatedField> getEntityCalculatedFields(EntityId entityId, CalculatedFieldType type, PageLink pageLink) throws Exception {
return doGetTypedWithPageLink("/api/" + entityId.getEntityType() + "/" + entityId.getId() + "/calculatedFields" + return doGetTypedWithPageLink("/api/" + entityId.getEntityType() + "/" + entityId.getId() + "/calculatedFields" +
(type != null ? "?type=" + type.name() + "&" : "?"), new TypeReference<>() {}, pageLink); (type != null ? "?type=" + type.name() + "&" : "?"), new TypeReference<>() {}, pageLink);
} }
protected PageData<String> getCalculatedFieldNames(CalculatedFieldType type, PageLink pageLink) throws Exception { protected PageData<String> getCalculatedFieldNames(CalculatedFieldType type, PageLink pageLink) throws Exception {
@ -1452,11 +1488,11 @@ public abstract class AbstractWebTest extends AbstractInMemoryStorageTest {
List<UUID> entities, List<UUID> entities,
List<String> names) throws Exception { List<String> names) throws Exception {
return doGetTypedWithPageLink("/api/calculatedFields?" + return doGetTypedWithPageLink("/api/calculatedFields?" +
(type != null ? "types=" + type + "&" : "") + (type != null ? "types=" + type + "&" : "") +
(entityType != null ? "entityType=" + entityType + "&" : "") + (entityType != null ? "entityType=" + entityType + "&" : "") +
(entities != null ? "entities=" + String.join(",", (entities != null ? "entities=" + String.join(",",
entities.stream().map(UUID::toString).toList()) + "&" : "") + entities.stream().map(UUID::toString).toList()) + "&" : "") +
(names != null ? names.stream().map(name -> "name=" + name + "&").collect(Collectors.joining("")) : ""), (names != null ? names.stream().map(name -> "name=" + name + "&").collect(Collectors.joining("")) : ""),
new TypeReference<PageData<CalculatedFieldInfo>>() {}, new PageLink(10)).getData(); new TypeReference<PageData<CalculatedFieldInfo>>() {}, new PageLink(10)).getData();
} }

7
application/src/test/java/org/thingsboard/server/controller/DeviceControllerTest.java

@ -1732,11 +1732,4 @@ public class DeviceControllerTest extends AbstractControllerTest {
assertThat(fifthDevice.getName()).isEqualTo("My unique device_2"); assertThat(fifthDevice.getName()).isEqualTo("My unique device_2");
} }
private Device createDevice(String name) {
Device device = new Device();
device.setName(name);
device.setType("default");
return doPost("/api/device", device, Device.class);
}
} }

6
application/src/test/java/org/thingsboard/server/controller/EdqsEntityQueryControllerTest.java

@ -23,6 +23,7 @@ import org.springframework.test.context.TestPropertySource;
import org.thingsboard.server.common.data.edqs.EdqsState; import org.thingsboard.server.common.data.edqs.EdqsState;
import org.thingsboard.server.common.data.edqs.EdqsState.EdqsApiMode; import org.thingsboard.server.common.data.edqs.EdqsState.EdqsApiMode;
import org.thingsboard.server.common.data.edqs.ToCoreEdqsRequest; import org.thingsboard.server.common.data.edqs.ToCoreEdqsRequest;
import org.awaitility.core.ThrowingRunnable;
import org.thingsboard.server.common.data.page.PageData; import org.thingsboard.server.common.data.page.PageData;
import org.thingsboard.server.common.data.query.AlarmCountQuery; import org.thingsboard.server.common.data.query.AlarmCountQuery;
import org.thingsboard.server.common.data.query.AlarmData; import org.thingsboard.server.common.data.query.AlarmData;
@ -86,6 +87,11 @@ public class EdqsEntityQueryControllerTest extends EntityQueryControllerTest {
result -> result == expectedResult); result -> result == expectedResult);
} }
@Override
protected void verifyAvailableKeysByQueryV2(ThrowingRunnable assertion) {
await().atMost(TIMEOUT, TimeUnit.SECONDS).untilAsserted(assertion);
}
@Test @Test
public void testEdqsState() throws Exception { public void testEdqsState() throws Exception {
loginSysAdmin(); loginSysAdmin();

237
application/src/test/java/org/thingsboard/server/controller/EntityQueryControllerTest.java

@ -17,7 +17,11 @@ package org.thingsboard.server.controller;
import com.fasterxml.jackson.core.type.TypeReference; import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.node.BooleanNode;
import com.fasterxml.jackson.databind.node.DoubleNode;
import com.fasterxml.jackson.databind.node.IntNode;
import com.fasterxml.jackson.databind.node.ObjectNode; import com.fasterxml.jackson.databind.node.ObjectNode;
import org.awaitility.core.ThrowingRunnable;
import org.junit.After; import org.junit.After;
import org.junit.Assert; import org.junit.Assert;
import org.junit.Before; import org.junit.Before;
@ -43,12 +47,21 @@ import org.thingsboard.server.common.data.alarm.AlarmSeverity;
import org.thingsboard.server.common.data.asset.Asset; import org.thingsboard.server.common.data.asset.Asset;
import org.thingsboard.server.common.data.id.DeviceId; import org.thingsboard.server.common.data.id.DeviceId;
import org.thingsboard.server.common.data.id.EntityId; import org.thingsboard.server.common.data.id.EntityId;
import org.thingsboard.server.common.data.kv.BaseAttributeKvEntry;
import org.thingsboard.server.common.data.kv.BasicTsKvEntry;
import org.thingsboard.server.common.data.kv.BooleanDataEntry;
import org.thingsboard.server.common.data.kv.DoubleDataEntry;
import org.thingsboard.server.common.data.kv.JsonDataEntry;
import org.thingsboard.server.common.data.kv.LongDataEntry;
import org.thingsboard.server.common.data.kv.StringDataEntry;
import org.thingsboard.server.common.data.page.PageData; import org.thingsboard.server.common.data.page.PageData;
import org.thingsboard.server.common.data.query.AlarmCountQuery; import org.thingsboard.server.common.data.query.AlarmCountQuery;
import org.thingsboard.server.common.data.query.AlarmData; import org.thingsboard.server.common.data.query.AlarmData;
import org.thingsboard.server.common.data.query.AlarmDataPageLink; import org.thingsboard.server.common.data.query.AlarmDataPageLink;
import org.thingsboard.server.common.data.query.AlarmDataQuery; import org.thingsboard.server.common.data.query.AlarmDataQuery;
import org.thingsboard.server.common.data.query.AliasEntityId; import org.thingsboard.server.common.data.query.AliasEntityId;
import org.thingsboard.server.common.data.query.AvailableEntityKeysV2;
import org.thingsboard.server.common.data.query.AvailableEntityKeysV2.KeyInfo;
import org.thingsboard.server.common.data.query.DeviceTypeFilter; import org.thingsboard.server.common.data.query.DeviceTypeFilter;
import org.thingsboard.server.common.data.query.DynamicValue; import org.thingsboard.server.common.data.query.DynamicValue;
import org.thingsboard.server.common.data.query.DynamicValueSourceType; import org.thingsboard.server.common.data.query.DynamicValueSourceType;
@ -84,6 +97,7 @@ import java.util.ArrayList;
import java.util.Arrays; import java.util.Arrays;
import java.util.Collections; import java.util.Collections;
import java.util.List; import java.util.List;
import java.util.UUID;
import java.util.concurrent.TimeUnit; import java.util.concurrent.TimeUnit;
import java.util.function.BiConsumer; import java.util.function.BiConsumer;
import java.util.stream.Collectors; import java.util.stream.Collectors;
@ -1329,7 +1343,7 @@ public class EntityQueryControllerTest extends AbstractControllerTest {
//assign dashboard //assign dashboard
doPost("/api/customer/" + savedCustomer.getId().getId().toString() 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 // check entity data query by customer
User customerUser = new User(); User customerUser = new User();
@ -1454,6 +1468,10 @@ public class EntityQueryControllerTest extends AbstractControllerTest {
return result; return result;
} }
protected void verifyAvailableKeysByQueryV2(ThrowingRunnable assertion) throws Throwable {
assertion.run();
}
private KeyFilter getEntityFieldEqualFilter(String keyName, String value) { private KeyFilter getEntityFieldEqualFilter(String keyName, String value) {
return getEntityFieldKeyFilter(keyName, value, StringFilterPredicate.StringOperation.EQUAL); return getEntityFieldKeyFilter(keyName, value, StringFilterPredicate.StringOperation.EQUAL);
} }
@ -1494,4 +1512,221 @@ public class EntityQueryControllerTest extends AbstractControllerTest {
return nameFilter; return nameFilter;
} }
// --- findAvailableEntityKeysV2 tests ---
@Test
public void testFindAvailableKeysByQueryV2() throws Throwable {
// GIVEN — two devices matched by query; a third device should not be matched
var device1 = createDevice("Test device 1");
var device2 = createDevice("Test device 2");
var unmatchedDevice = createDevice("Unmatched device");
// unmatched device has unique keys that must NOT appear in the result
postTelemetry(unmatchedDevice.getId(), new BasicTsKvEntry(9000, new DoubleDataEntry("unmatchedTs", 999.0)));
postAttributes(unmatchedDevice.getId(), AttributeScope.SHARED_SCOPE, new StringDataEntry("unmatchedAttr", "nope"));
// device1: timeseries1 (Double) with two data points, and timeseries2 older data point
postTelemetry(device1.getId(), new BasicTsKvEntry(1000, new DoubleDataEntry("timeseries1", 10.0)));
postTelemetry(device1.getId(), new BasicTsKvEntry(2000, new DoubleDataEntry("timeseries1", 20.5)));
postTelemetry(device1.getId(), new BasicTsKvEntry(1000, new LongDataEntry("timeseries2", 100L)));
// device2: timeseries2 (Long) with a newer data point, and timeseries3 only on this device
postTelemetry(device2.getId(), new BasicTsKvEntry(3000, new LongDataEntry("timeseries2", 300L)));
postTelemetry(device2.getId(), new BasicTsKvEntry(5000, new DoubleDataEntry("timeseries3", 99.9)));
// device1: SHARED_SCOPE attributes
postAttributes(device1.getId(), AttributeScope.SHARED_SCOPE,
new BooleanDataEntry("sharedAttribute1", true), new DoubleDataEntry("sharedAttribute2", 3.14));
// device2: CLIENT_SCOPE attributes (saved via service to bypass API restriction)
attributesService.save(tenantId, device2.getId(), AttributeScope.CLIENT_SCOPE, List.of(
new BaseAttributeKvEntry(new JsonDataEntry("clientAttribute1", "{\"key\":\"val\"}"), System.currentTimeMillis()),
new BaseAttributeKvEntry(new BooleanDataEntry("clientAttribute2", false), System.currentTimeMillis())
)).get();
// device1 also has SERVER_SCOPE attributes (should be omitted by scope filter)
postAttributes(device1.getId(), AttributeScope.SERVER_SCOPE,
new StringDataEntry("serverAttribute1", "sv1"), new LongDataEntry("serverAttribute2", 42L));
// WHEN — query matches both devices; request timeseries + only SHARED and CLIENT attribute scopes
DeviceTypeFilter filter = new DeviceTypeFilter();
filter.setDeviceTypes(List.of("default"));
filter.setDeviceNameFilter("Test device");
EntityDataPageLink pageLink = new EntityDataPageLink(100, 0, null, null);
EntityDataQuery query = new EntityDataQuery(filter, pageLink, List.of(), null, null);
// THEN
verifyAvailableKeysByQueryV2(() -> {
AvailableEntityKeysV2 result = findAvailableEntityKeysByQueryV2(query,
true, true, List.of(AttributeScope.SHARED_SCOPE, AttributeScope.CLIENT_SCOPE), true);
assertThat(result.entityTypes()).containsExactly(EntityType.DEVICE);
// timeseries: keys collected from both devices, samples contain the freshest data points
assertThat(result.timeseries()).extracting(KeyInfo::key)
.containsExactly("timeseries1", "timeseries2", "timeseries3");
assertThat(result.timeseries()).allSatisfy(ki -> assertThat(ki.sample()).isNotNull());
assertKeySample(result.timeseries(), "timeseries1", new DoubleNode(20.5), 2000); // from device1
assertKeySample(result.timeseries(), "timeseries2", new IntNode(300), 3000); // from device2 (newer)
assertKeySample(result.timeseries(), "timeseries3", new DoubleNode(99.9), 5000); // only on device2
// SERVER_SCOPE must be fully omitted from the response
assertThat(result.attributes()).containsOnlyKeys(AttributeScope.SHARED_SCOPE, AttributeScope.CLIENT_SCOPE);
// SHARED_SCOPE: from device1 (alphabetical order)
assertThat(result.attributes().get(AttributeScope.SHARED_SCOPE))
.extracting(KeyInfo::key).containsExactly("sharedAttribute1", "sharedAttribute2");
assertKeySample(result.attributes().get(AttributeScope.SHARED_SCOPE), "sharedAttribute1", BooleanNode.TRUE);
assertKeySample(result.attributes().get(AttributeScope.SHARED_SCOPE), "sharedAttribute2", new DoubleNode(3.14));
// CLIENT_SCOPE: from device2 (alphabetical order)
assertThat(result.attributes().get(AttributeScope.CLIENT_SCOPE))
.extracting(KeyInfo::key).containsExactly("clientAttribute1", "clientAttribute2");
assertKeySample(result.attributes().get(AttributeScope.CLIENT_SCOPE), "clientAttribute1", JacksonUtil.toJsonNode("{\"key\":\"val\"}"));
assertKeySample(result.attributes().get(AttributeScope.CLIENT_SCOPE), "clientAttribute2", BooleanNode.FALSE);
});
}
@Test
public void testFindAvailableKeysByQueryV2_withoutSamples() throws Throwable {
// GIVEN
var device = createDevice("Test device");
postTelemetry(device.getId(), new BasicTsKvEntry(System.currentTimeMillis(), new DoubleDataEntry("temperature", 10.0)));
postAttributes(device.getId(), AttributeScope.SERVER_SCOPE, new StringDataEntry("firmware", "v1.0"));
// THEN
verifyAvailableKeysByQueryV2(() -> {
AvailableEntityKeysV2 result = findAvailableEntityKeysByQueryV2(
buildDeviceQuery("Test device"), true, true, null, false);
assertThat(result.timeseries()).allSatisfy(ki -> assertThat(ki.sample()).isNull());
assertThat(result.attributes().get(AttributeScope.SERVER_SCOPE))
.allSatisfy(ki -> assertThat(ki.sample()).isNull());
});
}
@Test
public void testFindAvailableKeysByQueryV2_timeseriesOnly() throws Throwable {
// GIVEN
var device = createDevice("Test device");
postTelemetry(device.getId(), new BasicTsKvEntry(System.currentTimeMillis(), new DoubleDataEntry("temperature", 10.0)));
postAttributes(device.getId(), AttributeScope.SERVER_SCOPE, new StringDataEntry("firmware", "v1.0"));
// THEN
verifyAvailableKeysByQueryV2(() -> {
AvailableEntityKeysV2 result = findAvailableEntityKeysByQueryV2(
buildDeviceQuery("Test device"), true, false, null, false);
assertThat(result.timeseries()).extracting(KeyInfo::key).contains("temperature");
assertThat(result.attributes()).isNull();
});
}
@Test
public void testFindAvailableKeysByQueryV2_attributesOnly() throws Throwable {
// GIVEN
var device = createDevice("Test device");
postTelemetry(device.getId(), new BasicTsKvEntry(System.currentTimeMillis(), new DoubleDataEntry("temperature", 10.0)));
postAttributes(device.getId(), AttributeScope.SERVER_SCOPE, new StringDataEntry("firmware", "v1.0"));
// THEN
verifyAvailableKeysByQueryV2(() -> {
AvailableEntityKeysV2 result = findAvailableEntityKeysByQueryV2(
buildDeviceQuery("Test device"), false, true, null, false);
assertThat(result.timeseries()).isNull();
assertThat(result.attributes().get(AttributeScope.SERVER_SCOPE))
.extracting(KeyInfo::key).contains("firmware");
});
}
@Test
public void testFindAvailableKeysByQueryV2_noMatchingEntities() throws Throwable {
// THEN
verifyAvailableKeysByQueryV2(() -> {
AvailableEntityKeysV2 result = findAvailableEntityKeysByQueryV2(
buildDeviceQuery("NonExistentDevice_" + UUID.randomUUID()), true, true, null, true);
assertThat(result.entityTypes()).isEmpty();
assertThat(result.timeseries()).isEmpty();
assertThat(result.attributes()).isEmpty();
});
}
@Test
public void testFindAvailableKeysByQueryV2_assetUsesServerScopeOnly() throws Throwable {
// GIVEN
var asset = new Asset();
asset.setName("Test asset");
asset.setType("default");
asset = doPost("/api/asset", asset, Asset.class);
postAttributes(asset.getId(), AttributeScope.SERVER_SCOPE, new StringDataEntry("location", "warehouse"));
// WHEN
var filter = new SingleEntityFilter();
filter.setSingleEntity(AliasEntityId.fromEntityId(asset.getId()));
var query = new EntityDataQuery(filter, new EntityDataPageLink(1, 0, null, null), Collections.emptyList(), null, null);
// THEN
verifyAvailableKeysByQueryV2(() -> {
AvailableEntityKeysV2 result = findAvailableEntityKeysByQueryV2(query, false, true, null, false);
assertThat(result.entityTypes()).containsExactly(EntityType.ASSET);
assertThat(result.attributes()).containsOnlyKeys(AttributeScope.SERVER_SCOPE);
assertThat(result.attributes().get(AttributeScope.SERVER_SCOPE))
.extracting(KeyInfo::key).containsExactly("location");
});
}
@Test
public void testFindAvailableKeysByQueryV2_rejectsWhenNoKeyTypeRequested() throws Exception {
// WHEN / THEN
EntityDataQuery query = buildDeviceQuery("NonExistent");
doPostAsync("/api/v2/entitiesQuery/find/keys?includeTimeseries=false&includeAttributes=false",
query, 30_000L).andExpect(status().isBadRequest());
}
protected AvailableEntityKeysV2 findAvailableEntityKeysByQueryV2(EntityDataQuery query,
boolean includeTimeseries, boolean includeAttributes,
List<AttributeScope> scopes, boolean includeSamples) throws Exception {
StringBuilder url = new StringBuilder("/api/v2/entitiesQuery/find/keys?")
.append("includeTimeseries=").append(includeTimeseries)
.append("&includeAttributes=").append(includeAttributes)
.append("&includeSamples=").append(includeSamples);
if (scopes != null) {
for (AttributeScope scope : scopes) {
url.append("&scopes=").append(scope);
}
}
return doPostAsyncWithTypedResponse(url.toString(), query,
new TypeReference<>() {}, status().isOk());
}
private static void assertKeySample(List<KeyInfo> keys, String expectedKey, JsonNode expectedValue, long expectedTs) {
KeyInfo keyInfo = findKeyInfo(keys, expectedKey);
assertThat(keyInfo.sample()).isNotNull();
assertThat(keyInfo.sample().value()).isEqualTo(expectedValue);
assertThat(keyInfo.sample().ts()).isEqualTo(expectedTs);
}
private static void assertKeySample(List<KeyInfo> keys, String expectedKey, JsonNode expectedValue) {
KeyInfo keyInfo = findKeyInfo(keys, expectedKey);
assertThat(keyInfo.sample()).isNotNull();
assertThat(keyInfo.sample().value()).isEqualTo(expectedValue);
assertThat(keyInfo.sample().ts()).isGreaterThan(0);
}
private static KeyInfo findKeyInfo(List<KeyInfo> keys, String key) {
return keys.stream()
.filter(ki -> ki.key().equals(key)).findFirst().orElseThrow();
}
private static EntityDataQuery buildDeviceQuery(String deviceName) {
var filter = new DeviceTypeFilter();
filter.setDeviceTypes(Collections.singletonList("default"));
filter.setDeviceNameFilter(deviceName);
return new EntityDataQuery(filter, new EntityDataPageLink(1, 0, null, null), Collections.emptyList(), null, null);
}
} }

7
application/src/test/java/org/thingsboard/server/controller/EntityRelationControllerTest.java

@ -633,13 +633,6 @@ public class EntityRelationControllerTest extends AbstractControllerTest {
deleteDifferentTenant(); deleteDifferentTenant();
} }
private Device createDevice(String name) {
var device = new Device();
device.setName(name);
device.setType("default");
return doPost("/api/device", device, Device.class);
}
private ResultActions getRelation(EntityRelation relation) throws Exception { private ResultActions getRelation(EntityRelation relation) throws Exception {
return doGet("/api/relation?" + return doGet("/api/relation?" +
"fromId=" + relation.getFrom().getId() + "fromId=" + relation.getFrom().getId() +

23
application/src/test/java/org/thingsboard/server/controller/TelemetryControllerTest.java

@ -102,24 +102,29 @@ public class TelemetryControllerTest extends AbstractControllerTest {
Assert.assertEquals(11L, thirdIntervalResult.get("value").asLong()); Assert.assertEquals(11L, thirdIntervalResult.get("value").asLong());
Assert.assertEquals(thirdIntervalTs, thirdIntervalResult.get("ts").asLong()); Assert.assertEquals(thirdIntervalTs, thirdIntervalResult.get("ts").asLong());
result = doGetAsync("/api/plugins/telemetry/DEVICE/" + device.getId() + ObjectNode resultByMonth = doGetAsync("/api/plugins/telemetry/DEVICE/" + device.getId() +
"/values/timeseries?keys=t&startTs={startTs}&endTs={endTs}&agg={agg}&intervalType={intervalType}&timeZone={timeZone}", "/values/timeseries?keys=t&startTs={startTs}&endTs={endTs}&agg={agg}&intervalType={intervalType}&timeZone={timeZone}",
ObjectNode.class, startTs, endTs, "SUM", "MONTH", "Europe/Kyiv"); ObjectNode.class, startTs, endTs, "SUM", "MONTH", "Europe/Kyiv");
Assert.assertNotNull(result); Assert.assertNotNull(resultByMonth);
Assert.assertNotNull(result.get("t")); Assert.assertNotNull(resultByMonth.get("t"));
Assert.assertEquals(1, result.get("t").size()); Assert.assertEquals(1, resultByMonth.get("t").size());
var monthResult = result.get("t").get(0); var monthResult = resultByMonth.get("t").get(0);
Assert.assertEquals(22L, monthResult.get("value").asLong()); Assert.assertEquals(22L, monthResult.get("value").asLong());
Assert.assertEquals(middleOfTheInterval, monthResult.get("ts").asLong()); Assert.assertEquals(middleOfTheInterval, monthResult.get("ts").asLong());
// get all latest (without keys parameter) // check timeseries history with key parameter (instead of keys) has the same result as with keys parameter
ObjectNode allLatest = doGetAsync("/api/plugins/telemetry/DEVICE/" + device.getId() + ObjectNode resultByKey = doGetAsync("/api/plugins/telemetry/DEVICE/" + device.getId() +
"/values/timeseries?key=t&startTs={startTs}&endTs={endTs}&agg={agg}&intervalType={intervalType}&timeZone={timeZone}",
ObjectNode.class, startTs, endTs, "SUM", "WEEK_ISO", "Europe/Kyiv");
Assert.assertEquals(result, resultByKey);
// check timeseries history without keys and key results into empty object
ObjectNode resultWithoutKeyAndKeys = doGetAsync("/api/plugins/telemetry/DEVICE/" + device.getId() +
"/values/timeseries?startTs={startTs}&endTs={endTs}&agg={agg}&intervalType={intervalType}&timeZone={timeZone}", "/values/timeseries?startTs={startTs}&endTs={endTs}&agg={agg}&intervalType={intervalType}&timeZone={timeZone}",
ObjectNode.class, startTs, endTs, "SUM", "WEEK_ISO", "Europe/Kyiv"); ObjectNode.class, startTs, endTs, "SUM", "WEEK_ISO", "Europe/Kyiv");
Assert.assertNotNull(allLatest); Assert.assertTrue(resultWithoutKeyAndKeys.isEmpty());
Assert.assertNotNull(allLatest.get("t"));
} }
@Test @Test

101
application/src/test/java/org/thingsboard/server/service/job/JobManagerTest.java

@ -15,6 +15,7 @@
*/ */
package org.thingsboard.server.service.job; package org.thingsboard.server.service.job;
import com.google.common.util.concurrent.SettableFuture;
import lombok.SneakyThrows; import lombok.SneakyThrows;
import org.junit.After; import org.junit.After;
import org.junit.Before; import org.junit.Before;
@ -36,6 +37,7 @@ import org.thingsboard.server.common.data.job.JobType;
import org.thingsboard.server.common.data.job.task.DummyTaskResult; import org.thingsboard.server.common.data.job.task.DummyTaskResult;
import org.thingsboard.server.common.data.job.task.DummyTaskResult.DummyTaskFailure; import org.thingsboard.server.common.data.job.task.DummyTaskResult.DummyTaskFailure;
import org.thingsboard.server.common.data.page.PageLink; import org.thingsboard.server.common.data.page.PageLink;
import org.thingsboard.server.common.msg.queue.TbCallback;
import org.thingsboard.server.controller.AbstractControllerTest; import org.thingsboard.server.controller.AbstractControllerTest;
import org.thingsboard.server.dao.job.JobDao; import org.thingsboard.server.dao.job.JobDao;
import org.thingsboard.server.dao.service.DaoSqlTest; import org.thingsboard.server.dao.service.DaoSqlTest;
@ -44,10 +46,12 @@ import org.thingsboard.server.queue.task.JobStatsService;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.Comparator; import java.util.Comparator;
import java.util.List; import java.util.List;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.TimeUnit; import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicInteger; import java.util.concurrent.atomic.AtomicInteger;
import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.awaitility.Awaitility.await; import static org.awaitility.Awaitility.await;
import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.doAnswer; import static org.mockito.Mockito.doAnswer;
@ -518,19 +522,114 @@ public class JobManagerTest extends AbstractControllerTest {
}); });
} }
@Test
public void testSubmitJob_finishCallback_success() {
SettableFuture<Void> future = SettableFuture.create();
int tasksCount = 3;
submitJob(DummyJobConfiguration.builder()
.successfulTasksCount(tasksCount)
.taskProcessingTimeMs(100)
.build(), "test-job", TbCallback.wrap(future));
await().atMost(TIMEOUT, TimeUnit.SECONDS).untilAsserted(() -> {
assertThat(future.isDone()).isTrue();
assertThat(future.get()).isNull();
});
}
@Test
public void testSubmitJob_finishCallback_taskFailure() {
SettableFuture<Void> future = SettableFuture.create();
submitJob(DummyJobConfiguration.builder()
.successfulTasksCount(1)
.failedTasksCount(2)
.errors(List.of("task error"))
.retries(0)
.taskProcessingTimeMs(100)
.build(), "test-job", TbCallback.wrap(future));
await().atMost(TIMEOUT, TimeUnit.SECONDS).untilAsserted(() -> {
assertThat(future.isDone()).isTrue();
assertThatThrownBy(future::get)
.isInstanceOf(ExecutionException.class)
.cause()
.hasMessage("task error; task error");
});
}
@Test
public void testSubmitJob_finishCallback_generalError() {
SettableFuture<Void> future = SettableFuture.create();
submitJob(DummyJobConfiguration.builder()
.generalError("Something went wrong")
.submittedTasksBeforeGeneralError(0)
.build(), "test-job", TbCallback.wrap(future));
await().atMost(TIMEOUT, TimeUnit.SECONDS).untilAsserted(() -> {
assertThat(future.isDone()).isTrue();
assertThatThrownBy(future::get)
.isInstanceOf(ExecutionException.class)
.cause()
.hasMessage("Something went wrong");
});
}
@Test
public void testSubmitJob_finishCallback_cancelled() throws Exception {
SettableFuture<Void> future = SettableFuture.create();
JobId jobId = submitJob(DummyJobConfiguration.builder()
.successfulTasksCount(200)
.taskProcessingTimeMs(50)
.build(), "test-job", TbCallback.wrap(future)).getId();
Thread.sleep(500);
cancelJob(jobId);
await().atMost(TIMEOUT, TimeUnit.SECONDS).untilAsserted(() -> {
assertThat(future.isDone()).isTrue();
assertThatThrownBy(future::get)
.isInstanceOf(ExecutionException.class)
.cause()
.hasMessage("The task was cancelled");
});
}
@Test
public void testSubmitJob_finishCallback_zeroTasks() {
SettableFuture<Void> future = SettableFuture.create();
submitJob(DummyJobConfiguration.builder()
.successfulTasksCount(0)
.build(), "test-job", TbCallback.wrap(future));
await().atMost(TIMEOUT, TimeUnit.SECONDS).untilAsserted(() -> {
assertThat(future.isDone()).isTrue();
assertThat(future.get()).isNull();
});
}
private Job submitJob(DummyJobConfiguration configuration) { private Job submitJob(DummyJobConfiguration configuration) {
return submitJob(configuration, "test-job"); return submitJob(configuration, "test-job");
} }
@SneakyThrows @SneakyThrows
private Job submitJob(DummyJobConfiguration configuration, String key) { private Job submitJob(DummyJobConfiguration configuration, String key) {
return submitJob(configuration, key, null);
}
@SneakyThrows
private Job submitJob(DummyJobConfiguration configuration, String key, TbCallback callback) {
return jobManager.submitJob(Job.builder() return jobManager.submitJob(Job.builder()
.tenantId(tenantId) .tenantId(tenantId)
.type(JobType.DUMMY) .type(JobType.DUMMY)
.key(key) .key(key)
.entityId(jobEntity.getId()) .entityId(jobEntity.getId())
.configuration(configuration) .configuration(configuration)
.build()).get(); .build(), callback).get();
} }
private List<DummyTaskFailure> getFailures(JobResult jobResult) { private List<DummyTaskFailure> getFailures(JobResult jobResult) {

9
application/src/test/java/org/thingsboard/server/service/resource/DefaultResourceDataCacheTest.java

@ -15,6 +15,7 @@
*/ */
package org.thingsboard.server.service.resource; package org.thingsboard.server.service.resource;
import org.awaitility.Awaitility;
import org.junit.Test; import org.junit.Test;
import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.test.context.bean.override.mockito.MockitoSpyBean; import org.springframework.test.context.bean.override.mockito.MockitoSpyBean;
@ -29,6 +30,8 @@ import org.thingsboard.server.dao.resource.ResourceService;
import org.thingsboard.server.dao.resource.TbResourceDataCache; import org.thingsboard.server.dao.resource.TbResourceDataCache;
import org.thingsboard.server.dao.service.DaoSqlTest; import org.thingsboard.server.dao.service.DaoSqlTest;
import java.util.concurrent.TimeUnit;
import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.Mockito.clearInvocations; import static org.mockito.Mockito.clearInvocations;
import static org.mockito.Mockito.timeout; import static org.mockito.Mockito.timeout;
@ -61,6 +64,8 @@ public class DefaultResourceDataCacheTest extends AbstractControllerTest {
TbResourceInfo savedResource = tbResourceService.save(resource); TbResourceInfo savedResource = tbResourceService.save(resource);
verify(resourceDataCache, timeout(2000).times(1)).evictResourceData(tenantId, savedResource.getId()); verify(resourceDataCache, timeout(2000).times(1)).evictResourceData(tenantId, savedResource.getId());
Awaitility.await().atMost(2, TimeUnit.SECONDS).untilAsserted(() ->
assertThat(resourceDataCache.getResourceDataInfoAsync(tenantId, savedResource.getId()).get()).isNotNull());
TbResourceDataInfo cachedData = resourceDataCache.getResourceDataInfoAsync(tenantId, savedResource.getId()).get(); TbResourceDataInfo cachedData = resourceDataCache.getResourceDataInfoAsync(tenantId, savedResource.getId()).get();
assertThat(cachedData.getData()).isEqualTo(data); assertThat(cachedData.getData()).isEqualTo(data);
assertThat(JacksonUtil.treeToValue(cachedData.getDescriptor(), GeneralFileDescriptor.class)).isEqualTo(descriptor); assertThat(JacksonUtil.treeToValue(cachedData.getDescriptor(), GeneralFileDescriptor.class)).isEqualTo(descriptor);
@ -76,8 +81,8 @@ public class DefaultResourceDataCacheTest extends AbstractControllerTest {
TbResource resourceById = resourceService.findResourceById(tenantId, savedResource.getId()); TbResource resourceById = resourceService.findResourceById(tenantId, savedResource.getId());
tbResourceService.delete(resourceById, true, null); tbResourceService.delete(resourceById, true, null);
verify(resourceDataCache, timeout(2000).times(2)).evictResourceData(tenantId, savedResource.getId()); verify(resourceDataCache, timeout(2000).times(2)).evictResourceData(tenantId, savedResource.getId());
TbResourceDataInfo cachedDataAfterDeletion = resourceDataCache.getResourceDataInfoAsync(tenantId, savedResource.getId()).get(); Awaitility.await().atMost(2, TimeUnit.SECONDS).untilAsserted(() ->
assertThat(cachedDataAfterDeletion).isEqualTo(null); assertThat(resourceDataCache.getResourceDataInfoAsync(tenantId, savedResource.getId()).get()).isNull());
} }
} }

43
application/src/test/java/org/thingsboard/server/service/script/TbelInvokeDocsIoTest.java

@ -15,6 +15,7 @@
*/ */
package org.thingsboard.server.service.script; package org.thingsboard.server.service.script;
import org.junit.jupiter.api.RepeatedTest;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import org.thingsboard.common.util.JacksonUtil; import org.thingsboard.common.util.JacksonUtil;
import org.thingsboard.script.api.tbel.TbDate; import org.thingsboard.script.api.tbel.TbDate;
@ -779,7 +780,7 @@ class TbelInvokeDocsIoTest extends AbstractTbelInvokeTest {
assertEquals(expected.toString(), actual.toString()); assertEquals(expected.toString(), actual.toString());
} }
@Test @Test
public void setsCreateNewSetFromCreateSetTbMethod_Test() throws ExecutionException, InterruptedException { public void setsCreateNewSetFromCreateSetTbMethod_Test() throws ExecutionException, InterruptedException {
msgStr = """ msgStr = """
{"list": ["B", "A", "C", "A"]} {"list": ["B", "A", "C", "A"]}
@ -800,7 +801,7 @@ class TbelInvokeDocsIoTest extends AbstractTbelInvokeTest {
assertEquals(expected.toString(), actual.toString()); assertEquals(expected.toString(), actual.toString());
} }
@Test @Test
public void setsForeachForLoop_Test() throws ExecutionException, InterruptedException { public void setsForeachForLoop_Test() throws ExecutionException, InterruptedException {
msgStr = """ msgStr = """
{"list": ["A", "B", "C"]} {"list": ["A", "B", "C"]}
@ -975,13 +976,13 @@ class TbelInvokeDocsIoTest extends AbstractTbelInvokeTest {
ArrayList<Object> listSortDesc = new ArrayList<>(List.of("hello", "C", "B", "A", 34567, 34)); ArrayList<Object> listSortDesc = new ArrayList<>(List.of("hello", "C", "B", "A", 34567, 34));
Set<Object> expectedDesc = new LinkedHashSet<>(listSortDesc); Set<Object> expectedDesc = new LinkedHashSet<>(listSortDesc);
Object actual = invokeScript(evalScript(decoderStr), msgStr); Object actual = invokeScript(evalScript(decoderStr), msgStr);
assertEquals(expectedAsc.toString(), ((LinkedHashMap<?, ?>)actual).get("set1").toString()); assertEquals(expectedAsc.toString(), ((LinkedHashMap<?, ?>) actual).get("set1").toString());
assertEquals(expectedAsc.toString(), ((LinkedHashMap<?, ?>)actual).get("set1_asc").toString()); assertEquals(expectedAsc.toString(), ((LinkedHashMap<?, ?>) actual).get("set1_asc").toString());
assertEquals(expectedDesc.toString(), ((LinkedHashMap<?, ?>)actual).get("set1_desc").toString()); assertEquals(expectedDesc.toString(), ((LinkedHashMap<?, ?>) actual).get("set1_desc").toString());
assertEquals(expected.toString(), ((LinkedHashMap<?, ?>)actual).get("set2").toString()); assertEquals(expected.toString(), ((LinkedHashMap<?, ?>) actual).get("set2").toString());
assertEquals(expectedAsc.toString(), ((LinkedHashMap<?, ?>)actual).get("set3").toString()); assertEquals(expectedAsc.toString(), ((LinkedHashMap<?, ?>) actual).get("set3").toString());
assertEquals(expectedAsc.toString(), ((LinkedHashMap<?, ?>)actual).get("set3_asc").toString()); assertEquals(expectedAsc.toString(), ((LinkedHashMap<?, ?>) actual).get("set3_asc").toString());
assertEquals(expectedDesc.toString(), ((LinkedHashMap<?, ?>)actual).get("set3_desc").toString()); assertEquals(expectedDesc.toString(), ((LinkedHashMap<?, ?>) actual).get("set3_desc").toString());
} }
@Test @Test
@ -1002,9 +1003,9 @@ class TbelInvokeDocsIoTest extends AbstractTbelInvokeTest {
List<Object> listOrigin = new ArrayList<>(List.of("C", "B", "A", 34567, "B", "C", "hello", 34)); List<Object> listOrigin = new ArrayList<>(List.of("C", "B", "A", 34567, "B", "C", "hello", 34));
Set<Object> expectedSet = new LinkedHashSet<>(listOrigin); Set<Object> expectedSet = new LinkedHashSet<>(listOrigin);
Object actual = invokeScript(evalScript(decoderStr), msgStr); Object actual = invokeScript(evalScript(decoderStr), msgStr);
assertEquals(expectedSet.toString(), ((LinkedHashMap<?, ?>)actual).get("set1").toString()); assertEquals(expectedSet.toString(), ((LinkedHashMap<?, ?>) actual).get("set1").toString());
assertEquals(true, ((LinkedHashMap<?, ?>)actual).get("result1")); assertEquals(true, ((LinkedHashMap<?, ?>) actual).get("result1"));
assertEquals(false, ((LinkedHashMap<?, ?>)actual).get("result2")); assertEquals(false, ((LinkedHashMap<?, ?>) actual).get("result2"));
} }
@Test @Test
@ -1025,9 +1026,9 @@ class TbelInvokeDocsIoTest extends AbstractTbelInvokeTest {
Set<Object> expectedSet = new LinkedHashSet<>(listOrigin); Set<Object> expectedSet = new LinkedHashSet<>(listOrigin);
List<Object> expectedToList = new ArrayList<>(expectedSet); List<Object> expectedToList = new ArrayList<>(expectedSet);
Object actual = invokeScript(evalScript(decoderStr), msgStr); Object actual = invokeScript(evalScript(decoderStr), msgStr);
assertEquals(listOrigin.toString(), ((LinkedHashMap<?, ?>)actual).get("list").toString()); assertEquals(listOrigin.toString(), ((LinkedHashMap<?, ?>) actual).get("list").toString());
assertEquals(expectedSet.toString(), ((LinkedHashMap<?, ?>)actual).get("set1").toString()); assertEquals(expectedSet.toString(), ((LinkedHashMap<?, ?>) actual).get("set1").toString());
assertEquals(expectedToList.toString(), ((LinkedHashMap<?, ?>)actual).get("tolist").toString()); assertEquals(expectedToList.toString(), ((LinkedHashMap<?, ?>) actual).get("tolist").toString());
} }
@Test @Test
@ -1713,7 +1714,7 @@ class TbelInvokeDocsIoTest extends AbstractTbelInvokeTest {
assertEquals(expected, actual); assertEquals(expected, actual);
} }
@Test @RepeatedTest(value = 3, name = "{displayName} {currentRepetition}/{totalRepetitions}")
public void parseBytes_Test() throws ExecutionException, InterruptedException { public void parseBytes_Test() throws ExecutionException, InterruptedException {
byte[] bytesExecutionArrayList = new byte[]{(byte) 0xAA, (byte) 0xBB, (byte) 0xCC, (byte) 0xDD}; byte[] bytesExecutionArrayList = new byte[]{(byte) 0xAA, (byte) 0xBB, (byte) 0xCC, (byte) 0xDD};
msgStr = "{}"; msgStr = "{}";
@ -2355,7 +2356,8 @@ class TbelInvokeDocsIoTest extends AbstractTbelInvokeTest {
expected.put("date_1", d1.toString()); expected.put("date_1", d1.toString());
TbDate d2 = new TbDate(2023, 8, 6, 4, 4, 5, "Europe/Berlin"); TbDate d2 = new TbDate(2023, 8, 6, 4, 4, 5, "Europe/Berlin");
expected.put("dLocal_2", d2.toLocaleString()); 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("dIso_2", d2.toISOString());
expected.put("date_2", d2.toString()); expected.put("date_2", d2.toString());
Object actual = invokeScript(evalScript(decoderStr), msgStr); Object actual = invokeScript(evalScript(decoderStr), msgStr);
@ -2387,7 +2389,8 @@ class TbelInvokeDocsIoTest extends AbstractTbelInvokeTest {
""", s2); """, s2);
LinkedHashMap<String, Object> expected = new LinkedHashMap<>(); LinkedHashMap<String, Object> expected = new LinkedHashMap<>();
TbDate d1 = new TbDate(2023, 8, 6, 4, 4, 5); 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("dIso_1", d1.toISOString());
expected.put("d1", d1.toString()); expected.put("d1", d1.toString());
TbDate d2 = new TbDate(s2); TbDate d2 = new TbDate(s2);
@ -2419,7 +2422,8 @@ class TbelInvokeDocsIoTest extends AbstractTbelInvokeTest {
TbDate d = new TbDate(2023, 8, 6, 4, 4, 5, "Europe/Berlin"); TbDate d = new TbDate(2023, 8, 6, 4, 4, 5, "Europe/Berlin");
expected.put("dIso", d.toISOString()); expected.put("dIso", d.toISOString());
expected.put("dLocal_utc", d.toLocaleString("UTC")); 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"); expected.put("dLocal_de", "06.08.23, 04:04:05");
Object actual = invokeScript(evalScript(decoderStr), msgStr); Object actual = invokeScript(evalScript(decoderStr), msgStr);
assertEquals(expected, actual); assertEquals(expected, actual);
@ -2821,5 +2825,6 @@ class TbelInvokeDocsIoTest extends AbstractTbelInvokeTest {
} }
return list; return list;
} }
} }

11
common/dao-api/src/main/java/org/thingsboard/server/dao/attributes/AttributesService.java

@ -27,9 +27,6 @@ import java.util.Collection;
import java.util.List; import java.util.List;
import java.util.Optional; import java.util.Optional;
/**
* @author Andrew Shvayka
*/
public interface AttributesService { public interface AttributesService {
ListenableFuture<Optional<AttributeKvEntry>> find(TenantId tenantId, EntityId entityId, AttributeScope scope, String attributeKey); ListenableFuture<Optional<AttributeKvEntry>> find(TenantId tenantId, EntityId entityId, AttributeScope scope, String attributeKey);
@ -48,7 +45,13 @@ public interface AttributesService {
List<String> findAllKeysByEntityIds(TenantId tenantId, List<EntityId> entityIds); List<String> findAllKeysByEntityIds(TenantId tenantId, List<EntityId> entityIds);
List<String> findAllKeysByEntityIds(TenantId tenantId, List<EntityId> entityIds, AttributeScope scope); List<String> findAllKeysByEntityIdsAndScope(TenantId tenantId, List<EntityId> entityIds, AttributeScope scope);
ListenableFuture<List<String>> findAllKeysByEntityIdsAndScopeAsync(TenantId tenantId, List<EntityId> entityIds, AttributeScope scope);
List<AttributeKvEntry> findLatestByEntityIdsAndScope(TenantId tenantId, List<EntityId> entityIds, AttributeScope scope);
ListenableFuture<List<AttributeKvEntry>> findLatestByEntityIdsAndScopeAsync(TenantId tenantId, List<EntityId> entityIds, AttributeScope scope);
int removeAllByEntityId(TenantId tenantId, EntityId entityId); int removeAllByEntityId(TenantId tenantId, EntityId entityId);

3
common/dao-api/src/main/java/org/thingsboard/server/dao/entity/EntityService.java

@ -16,6 +16,7 @@
package org.thingsboard.server.dao.entity; package org.thingsboard.server.dao.entity;
import com.google.common.util.concurrent.FluentFuture; import com.google.common.util.concurrent.FluentFuture;
import com.google.common.util.concurrent.ListenableFuture;
import org.thingsboard.server.common.data.EntityInfo; import org.thingsboard.server.common.data.EntityInfo;
import org.thingsboard.server.common.data.id.CustomerId; import org.thingsboard.server.common.data.id.CustomerId;
import org.thingsboard.server.common.data.id.EntityId; import org.thingsboard.server.common.data.id.EntityId;
@ -51,4 +52,6 @@ public interface EntityService {
PageData<EntityData> findEntityDataByQuery(TenantId tenantId, CustomerId customerId, EntityDataQuery query); PageData<EntityData> findEntityDataByQuery(TenantId tenantId, CustomerId customerId, EntityDataQuery query);
ListenableFuture<PageData<EntityData>> findEntityDataByQueryAsync(TenantId tenantId, CustomerId customerId, EntityDataQuery query);
} }

32
common/dao-api/src/main/java/org/thingsboard/server/dao/nosql/ResultSetSizeLimitExceededException.java

@ -0,0 +1,32 @@
/**
* Copyright © 2016-2026 The Thingsboard Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.thingsboard.server.dao.nosql;
import lombok.Getter;
@Getter
public class ResultSetSizeLimitExceededException extends IllegalArgumentException {
private final long limitBytes;
private final long actualBytes;
public ResultSetSizeLimitExceededException(long limitBytes, long actualBytes) {
super("Result set size exceeds the maximum allowed limit. Please narrow your query");
this.limitBytes = limitBytes;
this.actualBytes = actualBytes;
}
}

24
common/dao-api/src/main/java/org/thingsboard/server/dao/nosql/TbResultSet.java

@ -34,6 +34,7 @@ import java.util.ArrayList;
import java.util.List; import java.util.List;
import java.util.concurrent.CompletionStage; import java.util.concurrent.CompletionStage;
import java.util.concurrent.Executor; import java.util.concurrent.Executor;
import java.util.concurrent.atomic.AtomicLong;
import java.util.function.Function; import java.util.function.Function;
public class TbResultSet implements AsyncResultSet { public class TbResultSet implements AsyncResultSet {
@ -89,9 +90,14 @@ public class TbResultSet implements AsyncResultSet {
} }
public ListenableFuture<List<Row>> allRows(Executor executor) { public ListenableFuture<List<Row>> allRows(Executor executor) {
return allRows(executor, 0);
}
public ListenableFuture<List<Row>> allRows(Executor executor, long maxResultSetSizeBytes) {
List<Row> allRows = new ArrayList<>(); List<Row> allRows = new ArrayList<>();
SettableFuture<List<Row>> resultFuture = SettableFuture.create(); SettableFuture<List<Row>> resultFuture = SettableFuture.create();
this.processRows(originalStatement, delegate, allRows, resultFuture, executor); AtomicLong accumulatedBytes = new AtomicLong(0);
this.processRows(originalStatement, delegate, allRows, resultFuture, executor, maxResultSetSizeBytes, accumulatedBytes);
return resultFuture; return resultFuture;
} }
@ -99,7 +105,19 @@ public class TbResultSet implements AsyncResultSet {
AsyncResultSet resultSet, AsyncResultSet resultSet,
List<Row> allRows, List<Row> allRows,
SettableFuture<List<Row>> resultFuture, SettableFuture<List<Row>> resultFuture,
Executor executor) { Executor executor,
long maxResultSetSizeBytes,
AtomicLong accumulatedBytes) {
if (maxResultSetSizeBytes > 0) {
int pageSizeInBytes = resultSet.getExecutionInfo().getResponseSizeInBytes();
if (pageSizeInBytes > 0) {
accumulatedBytes.addAndGet(pageSizeInBytes);
}
if (accumulatedBytes.get() > maxResultSetSizeBytes) {
resultFuture.setException(new ResultSetSizeLimitExceededException(maxResultSetSizeBytes, accumulatedBytes.get()));
return;
}
}
allRows.addAll(loadRows(resultSet)); allRows.addAll(loadRows(resultSet));
if (resultSet.hasMorePages()) { if (resultSet.hasMorePages()) {
ByteBuffer nextPagingState = resultSet.getExecutionInfo().getPagingState(); ByteBuffer nextPagingState = resultSet.getExecutionInfo().getPagingState();
@ -110,7 +128,7 @@ public class TbResultSet implements AsyncResultSet {
@Override @Override
public void onSuccess(@Nullable TbResultSet result) { public void onSuccess(@Nullable TbResultSet result) {
processRows(nextStatement, result, processRows(nextStatement, result,
allRows, resultFuture, executor); allRows, resultFuture, executor, maxResultSetSizeBytes, accumulatedBytes);
} }
@Override @Override

7
common/dao-api/src/main/java/org/thingsboard/server/dao/timeseries/TimeseriesService.java

@ -30,9 +30,6 @@ import java.util.Collection;
import java.util.List; import java.util.List;
import java.util.Optional; import java.util.Optional;
/**
* @author Andrew Shvayka
*/
public interface TimeseriesService { public interface TimeseriesService {
ListenableFuture<List<ReadTsKvQueryResult>> findAllByQueries(TenantId tenantId, EntityId entityId, List<ReadTsKvQuery> queries); ListenableFuture<List<ReadTsKvQueryResult>> findAllByQueries(TenantId tenantId, EntityId entityId, List<ReadTsKvQuery> queries);
@ -65,6 +62,10 @@ public interface TimeseriesService {
ListenableFuture<List<String>> findAllKeysByEntityIdsAsync(TenantId tenantId, List<EntityId> entityIds); ListenableFuture<List<String>> findAllKeysByEntityIdsAsync(TenantId tenantId, List<EntityId> entityIds);
List<TsKvEntry> findLatestByEntityIds(TenantId tenantId, List<EntityId> entityIds);
ListenableFuture<List<TsKvEntry>> findLatestByEntityIdsAsync(TenantId tenantId, List<EntityId> entityIds);
void cleanup(long systemTtl); void cleanup(long systemTtl);
} }

140
common/dao-api/src/test/java/org/thingsboard/server/dao/nosql/TbResultSetTest.java

@ -0,0 +1,140 @@
/**
* Copyright © 2016-2026 The Thingsboard Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.thingsboard.server.dao.nosql;
import com.datastax.oss.driver.api.core.cql.AsyncResultSet;
import com.datastax.oss.driver.api.core.cql.ColumnDefinitions;
import com.datastax.oss.driver.api.core.cql.ExecutionInfo;
import com.datastax.oss.driver.api.core.cql.Row;
import com.datastax.oss.driver.api.core.cql.Statement;
import com.google.common.util.concurrent.ListenableFuture;
import com.google.common.util.concurrent.MoreExecutors;
import com.google.common.util.concurrent.SettableFuture;
import org.junit.jupiter.api.Test;
import java.nio.ByteBuffer;
import java.util.List;
import java.util.concurrent.ExecutionException;
import java.util.function.Function;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.Mockito.doReturn;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
class TbResultSetTest {
@Test
void allRows_withinLimit_returnsAllRows() throws Exception {
Row row = mock(Row.class);
AsyncResultSet asyncResultSet = createMockResultSet(List.of(row), false, 1000);
Statement<?> statement = mock(Statement.class);
TbResultSet tbResultSet = new TbResultSet(statement, asyncResultSet, s -> null);
ListenableFuture<List<Row>> future = tbResultSet.allRows(MoreExecutors.directExecutor(), 5000);
List<Row> result = future.get();
assertThat(result).hasSize(1);
assertThat(result.get(0)).isSameAs(row);
}
@Test
void allRows_exceedsLimitOnFirstPage_failsWithException() {
Row row = mock(Row.class);
AsyncResultSet asyncResultSet = createMockResultSet(List.of(row), false, 6000);
Statement<?> statement = mock(Statement.class);
TbResultSet tbResultSet = new TbResultSet(statement, asyncResultSet, s -> null);
ListenableFuture<List<Row>> future = tbResultSet.allRows(MoreExecutors.directExecutor(), 5000);
assertThatThrownBy(future::get)
.isInstanceOf(ExecutionException.class)
.hasCauseInstanceOf(ResultSetSizeLimitExceededException.class);
}
@Test
void allRows_exceedsLimitOnSecondPage_failsAfterSecondPage() {
Row row1 = mock(Row.class);
Row row2 = mock(Row.class);
Statement<?> statement = mock(Statement.class);
doReturn(statement).when(statement).setPagingState((ByteBuffer) null);
AsyncResultSet page2 = createMockResultSet(List.of(row2), false, 3000);
TbResultSet tbResultSetPage2 = new TbResultSet(statement, page2, s -> null);
SettableFuture<TbResultSet> page2Future = SettableFuture.create();
page2Future.set(tbResultSetPage2);
TbResultSetFuture tbPage2Future = new TbResultSetFuture(page2Future);
ExecutionInfo page1ExecInfo = mock(ExecutionInfo.class);
when(page1ExecInfo.getResponseSizeInBytes()).thenReturn(3000);
when(page1ExecInfo.getPagingState()).thenReturn(null);
AsyncResultSet page1 = createMockResultSet(List.of(row1), true, 3000);
when(page1.getExecutionInfo()).thenReturn(page1ExecInfo);
Function<Statement, TbResultSetFuture> executeAsync = s -> tbPage2Future;
TbResultSet tbResultSet = new TbResultSet(statement, page1, executeAsync);
ListenableFuture<List<Row>> future = tbResultSet.allRows(MoreExecutors.directExecutor(), 5000);
assertThatThrownBy(future::get)
.isInstanceOf(ExecutionException.class)
.hasCauseInstanceOf(ResultSetSizeLimitExceededException.class);
}
@Test
void allRows_unlimitedWithZero_returnsAllRowsRegardlessOfSize() throws Exception {
Row row = mock(Row.class);
AsyncResultSet asyncResultSet = createMockResultSet(List.of(row), false, 999999);
Statement<?> statement = mock(Statement.class);
TbResultSet tbResultSet = new TbResultSet(statement, asyncResultSet, s -> null);
ListenableFuture<List<Row>> future = tbResultSet.allRows(MoreExecutors.directExecutor(), 0);
List<Row> result = future.get();
assertThat(result).hasSize(1);
}
@Test
void allRows_noLimitOverload_returnsAllRows() throws Exception {
Row row = mock(Row.class);
AsyncResultSet asyncResultSet = createMockResultSet(List.of(row), false, 999999);
Statement<?> statement = mock(Statement.class);
TbResultSet tbResultSet = new TbResultSet(statement, asyncResultSet, s -> null);
ListenableFuture<List<Row>> future = tbResultSet.allRows(MoreExecutors.directExecutor());
List<Row> result = future.get();
assertThat(result).hasSize(1);
}
private AsyncResultSet createMockResultSet(List<Row> rows, boolean hasMorePages, int responseSizeInBytes) {
AsyncResultSet resultSet = mock(AsyncResultSet.class);
ExecutionInfo executionInfo = mock(ExecutionInfo.class);
ColumnDefinitions columnDefs = mock(ColumnDefinitions.class);
when(executionInfo.getResponseSizeInBytes()).thenReturn(responseSizeInBytes);
when(executionInfo.getPagingState()).thenReturn(null);
when(resultSet.getExecutionInfo()).thenReturn(executionInfo);
when(resultSet.getColumnDefinitions()).thenReturn(columnDefs);
when(resultSet.currentPage()).thenReturn(rows);
when(resultSet.hasMorePages()).thenReturn(hasMorePages);
when(resultSet.remaining()).thenReturn(rows.size());
return resultSet;
}
}

34
common/data/src/main/java/org/thingsboard/server/common/data/job/Job.java

@ -15,6 +15,7 @@
*/ */
package org.thingsboard.server.common.data.job; package org.thingsboard.server.common.data.job;
import com.fasterxml.jackson.annotation.JsonIgnore;
import jakarta.validation.Valid; import jakarta.validation.Valid;
import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull; import jakarta.validation.constraints.NotNull;
@ -29,6 +30,7 @@ import org.thingsboard.server.common.data.HasTenantId;
import org.thingsboard.server.common.data.id.EntityId; import org.thingsboard.server.common.data.id.EntityId;
import org.thingsboard.server.common.data.id.JobId; import org.thingsboard.server.common.data.id.JobId;
import org.thingsboard.server.common.data.id.TenantId; import org.thingsboard.server.common.data.id.TenantId;
import org.thingsboard.server.common.data.job.task.TaskResult;
import java.util.Set; import java.util.Set;
import java.util.UUID; import java.util.UUID;
@ -82,4 +84,36 @@ public class Job extends BaseData<JobId> implements HasTenantId {
return (C) configuration; return (C) configuration;
} }
@JsonIgnore
public String getError() {
if (status == JobStatus.CANCELLED) {
return "The task was cancelled";
}
if (result.getGeneralError() != null) {
return result.getGeneralError();
}
if (result.getFailedCount() > 0 && result.getResults() != null) {
StringBuilder errorMessage = new StringBuilder();
for (TaskResult taskResult : result.getResults()) {
if (taskResult.isSuccess() || taskResult.isDiscarded()) {
continue;
}
String error = taskResult.getError();
if (error == null) {
continue;
}
if (!errorMessage.isEmpty()) {
if (errorMessage.length() + 2 + error.length() > 256) {
errorMessage.append("...");
break;
}
errorMessage.append("; ");
}
errorMessage.append(error);
}
return errorMessage.toString();
}
return "Task failed";
}
} }

5
common/data/src/main/java/org/thingsboard/server/common/data/job/task/DummyTaskResult.java

@ -64,6 +64,11 @@ public class DummyTaskResult extends TaskResult {
return JobType.DUMMY; return JobType.DUMMY;
} }
@Override
public String getError() {
return failure != null ? failure.getError() : null;
}
@Data @Data
@NoArgsConstructor @NoArgsConstructor
@EqualsAndHashCode(callSuper = true) @EqualsAndHashCode(callSuper = true)

3
common/data/src/main/java/org/thingsboard/server/common/data/job/task/TaskResult.java

@ -46,4 +46,7 @@ public abstract class TaskResult {
@JsonIgnore @JsonIgnore
public abstract JobType getJobType(); public abstract JobType getJobType();
@JsonIgnore
public abstract String getError();
} }

99
common/data/src/main/java/org/thingsboard/server/common/data/query/AvailableEntityKeysV2.java

@ -0,0 +1,99 @@
/**
* Copyright © 2016-2026 The Thingsboard Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.thingsboard.server.common.data.query;
import com.fasterxml.jackson.annotation.JsonInclude;
import com.fasterxml.jackson.databind.JsonNode;
import io.swagger.v3.oas.annotations.media.ArraySchema;
import io.swagger.v3.oas.annotations.media.Schema;
import org.jspecify.annotations.Nullable;
import org.thingsboard.server.common.data.AttributeScope;
import org.thingsboard.server.common.data.EntityType;
import java.util.List;
import java.util.Map;
import java.util.Set;
@Schema(
description = """
Contains unique time series and attribute key names discovered from entities matching a query,
optionally including a sample value for each key."""
)
@JsonInclude(JsonInclude.Include.NON_NULL)
public record AvailableEntityKeysV2(
@Schema(
description = "Set of entity types found among the matched entities.",
example = "[\"DEVICE\", \"ASSET\"]",
requiredMode = Schema.RequiredMode.REQUIRED
)
Set<EntityType> entityTypes,
@ArraySchema(
arraySchema = @Schema(
description = """
List of unique time series keys available on the matched entities, sorted alphabetically.
Omitted when timeseries keys were not requested.""",
nullable = true
),
schema = @Schema(implementation = KeyInfo.class)
)
@Nullable List<KeyInfo> timeseries,
@Schema(
description = """
Map of attribute scope to the list of unique attribute keys available on the matched entities.
Only scopes supported by the matched entity types are included.
Omitted when attribute keys were not requested or when none of the requested scopes apply to the matched entity types.""",
nullable = true
)
@Nullable Map<AttributeScope, List<KeyInfo>> attributes
) {
@Schema(description = "Key name with an optional sample value.")
@JsonInclude(JsonInclude.Include.NON_NULL)
public record KeyInfo(
@Schema(
description = "Key name.",
example = "temperature",
requiredMode = Schema.RequiredMode.REQUIRED
)
String key,
@Schema(
description = "Most recent sample value for this key across the matched entities. Omitted when samples were not requested.",
nullable = true
)
@Nullable KeySample sample
) {}
@Schema(description = "Most recent value and its timestamp.")
public record KeySample(
@Schema(
description = "Timestamp in milliseconds since epoch.", example = "1707000000000",
requiredMode = Schema.RequiredMode.REQUIRED
)
long ts,
@Schema(
description = "Sample value.",
example = "23.5",
requiredMode = Schema.RequiredMode.REQUIRED,
implementation = Object.class
)
JsonNode value
) {}
}

4
common/message/src/test/java/org/thingsboard/server/common/msg/tools/TbRateLimitsTest.java

@ -42,7 +42,7 @@ public class TbRateLimitsTest {
assertThat(rateLimits.tryConsume()).as("new token is available").isFalse(); assertThat(rateLimits.tryConsume()).as("new token is available").isFalse();
int expectedRefillTime = (int) (((double) period / capacity) * 1000); int expectedRefillTime = (int) (((double) period / capacity) * 1000);
int gap = 500; int gap = 1000;
for (int i = 0; i < capacity; i++) { for (int i = 0; i < capacity; i++) {
await("token refill for rate limit " + rateLimitConfig) await("token refill for rate limit " + rateLimitConfig)
@ -71,7 +71,7 @@ public class TbRateLimitsTest {
assertThat(rateLimits.tryConsume()).as("new token is available").isFalse(); assertThat(rateLimits.tryConsume()).as("new token is available").isFalse();
int expectedRefillTime = period * 1000; int expectedRefillTime = period * 1000;
int gap = 500; int gap = 1000;
await("tokens refill for rate limit " + rateLimitConfig) await("tokens refill for rate limit " + rateLimitConfig)
.pollInterval(new FixedPollInterval(10, TimeUnit.MILLISECONDS)) .pollInterval(new FixedPollInterval(10, TimeUnit.MILLISECONDS))

8
common/script/script-api/src/main/java/org/thingsboard/script/api/tbel/TbUtils.java

@ -898,11 +898,11 @@ public class TbUtils {
public static int parseBytesToInt(byte[] data, int offset, int length, boolean bigEndian) { public static int parseBytesToInt(byte[] data, int offset, int length, boolean bigEndian) {
validationNumberByLength(data, offset, length, BYTES_LEN_INT_MAX); validationNumberByLength(data, offset, length, BYTES_LEN_INT_MAX);
var bb = ByteBuffer.allocate(4); var bb = ByteBuffer.allocate(BYTES_LEN_INT_MAX);
if (!bigEndian) { if (!bigEndian) {
bb.order(ByteOrder.LITTLE_ENDIAN); bb.order(ByteOrder.LITTLE_ENDIAN);
} }
bb.position(bigEndian ? 4 - length : 0); bb.position(bigEndian ? BYTES_LEN_INT_MAX - length : 0);
bb.put(data, offset, length); bb.put(data, offset, length);
bb.position(0); bb.position(0);
return bb.getInt(); return bb.getInt();
@ -923,11 +923,11 @@ public class TbUtils {
public static long parseBytesToUnsignedInt(byte[] data, int offset, int length, boolean bigEndian) { public static long parseBytesToUnsignedInt(byte[] data, int offset, int length, boolean bigEndian) {
validationNumberByLength(data, offset, length, BYTES_LEN_INT_MAX); validationNumberByLength(data, offset, length, BYTES_LEN_INT_MAX);
ByteBuffer bb = ByteBuffer.allocate(8); ByteBuffer bb = ByteBuffer.allocate(BYTES_LEN_LONG_MAX);
if (!bigEndian) { if (!bigEndian) {
bb.order(ByteOrder.LITTLE_ENDIAN); bb.order(ByteOrder.LITTLE_ENDIAN);
} }
bb.position(bigEndian ? 8 - length : 0); bb.position(bigEndian ? BYTES_LEN_LONG_MAX - length : 0);
bb.put(data, offset, length); bb.put(data, offset, length);
bb.position(0); bb.position(0);

134
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("timeZone", "America/New_York")
.put("dateStyle", "full") .put("dateStyle", "full")
.toString())); .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("timeZone", "Europe/Kiev")
.put("dateStyle", "full") .put("dateStyle", "full")
.toString())); .toString()));
@ -244,12 +246,18 @@ class TbDateTest {
Assertions.assertNotNull(d.toLocaleTimeString()); Assertions.assertNotNull(d.toLocaleTimeString());
Assertions.assertNotNull(d.toLocaleTimeString("en-US")); 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("오후 9:04:05", d.toLocaleTimeString("ko-KR", "America/New_York"));
Assertions.assertEquals("04:04:05", d.toLocaleTimeString( "uk-UA", "Europe/Kiev")); 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 م", 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("timeZone", "America/New_York")
.put("timeStyle", "full") .put("timeStyle", "full")
.toString())); .toString()));
@ -292,14 +300,27 @@ class TbDateTest {
Assertions.assertNotNull(d.toLocaleString()); Assertions.assertNotNull(d.toLocaleString());
Assertions.assertNotNull(d.toLocaleString("en-US")); 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("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")); 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 م" : // Java 11 has no comma, Java 17 uses ASCII comma, Java 21+ uses Arabic comma (U+060C)
"5\u200F/9\u200F/2023, 9:04:05 م"; String expected_ar;
Assertions.assertEquals(expected_ver, d.toLocaleString( "ar-EG", "America/New_York")); 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("timeZone", "America/New_York")
.put("dateStyle", "full") .put("dateStyle", "full")
.put("timeStyle", "full") .put("timeStyle", "full")
@ -309,15 +330,26 @@ class TbDateTest {
.put("dateStyle", "full") .put("dateStyle", "full")
.put("timeStyle", "full") .put("timeStyle", "full")
.toString())); .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("timeZone", "Europe/Kiev")
.put("dateStyle", "full") .put("dateStyle", "full")
.put("timeStyle", "full") .put("timeStyle", "full")
.toString())); .toString()));
expected_ver = Runtime.version().feature() == 11 ? "الثلاثاء، 5 سبتمبر 2023 9:04:05 م التوقيت الصيفي الشرقي لأمريكا الشمالية" : // Java 11 has no connector, Java 17 uses "في", Java 21+ uses Arabic comma "،"
"الثلاثاء، 5 سبتمبر 2023 في 9:04:05 م التوقيت الصيفي الشرقي لأمريكا الشمالية"; String expected_ar_full;
Assertions.assertEquals(expected_ver, d.toLocaleString("ar-EG", JacksonUtil.newObjectNode() 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("timeZone", "America/New_York")
.put("dateStyle", "full") .put("dateStyle", "full")
.put("timeStyle", "full") .put("timeStyle", "full")
@ -825,16 +857,44 @@ class TbDateTest {
@Test @Test
public void toStringAsJs() { public void toStringAsJs() {
TbDate d1 = new TbDate(1975, 12, 31, 23,15,30, 567,"-04:00"); 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")); // Java 21+ changed Ukrainian locale: "о" (at) replaced with "," and NNBSP before "р." per CLDR 42+
Assertions.assertEquals("Thursday, January 1, 1976 at 6:15:30 AM Eastern European Standard Time", d1.toString("en-US", "Europe/Kyiv")); String expected_ukUA_full = Runtime.version().feature() >= 21
Assertions.assertEquals("1976 Jan 1, Thu 06:15:30 Eastern European Time", d1.toString("UTC", "Europe/Kyiv")); ? "четвер, 1 січня 1976\u202Fр., 06:15:30 за східноєвропейським стандартним часом"
Assertions.assertEquals("Wednesday, December 31, 1975 at 10:15:30 PM Eastern Standard Time", d1.toString("en-US", "America/New_York")); : "четвер, 1 січня 1976 р. о 06:15:30 за східноєвропейським стандартним часом";
Assertions.assertEquals("1975 Dec 31, Wed 22:15:30 Eastern Standard Time", d1.toString("GMT", "America/New_York")); Assertions.assertEquals(expected_ukUA_full, d1.toString("uk-UA", "Europe/Kyiv"));
Assertions.assertEquals("1975 Dec 31, Wed 22:15:30 Eastern Standard Time", d1.toString("UTC", "America/New_York")); // 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(d1.toUTCString("UTC"), d1.toUTCString());
Assertions.assertEquals("четвер, 1 січня 1976 р., 03:15:30", d1.toUTCString("uk-UA")); // Java 21+ uses NNBSP before "р." per CLDR 42+
Assertions.assertEquals("Thursday, January 1, 1976, 3:15:30 AM", d1.toUTCString("en-US")); 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.toJSON());
Assertions.assertEquals("1976-01-01T03:15:30.567Z", d1.toISOString()); 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("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("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("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("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("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("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("uk-UA", "Europe/Kyiv"));
Assertions.assertEquals("06:15:30", d1.toLocaleTimeString("UTC", "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("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 за східноєвропейським стандартним часом", d1.toTimeString("uk-UA", "Europe/Kyiv"));
Assertions.assertEquals("06:15:30 Eastern European Time", d1.toTimeString("UTC", "Europe/Kyiv")); // Java 21+ changed timezone display for UTC locale
Assertions.assertEquals("10:15:30 PM Eastern Standard Time", d1.toTimeString("en-US", "America/New_York")); String expected_UTC_time_Kyiv = Runtime.version().feature() >= 21
Assertions.assertEquals("22:15:30 Eastern Standard Time", d1.toTimeString("UTC", "America/New_York")); ? "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 @Test

95
common/script/script-api/src/test/java/org/thingsboard/script/api/tbel/TbUtilsTest.java

@ -155,6 +155,101 @@ public class TbUtilsTest {
Assertions.assertEquals(expected, TbUtils.parseBytesToInt(data, 0, 3, false)); Assertions.assertEquals(expected, TbUtils.parseBytesToInt(data, 0, 3, false));
} }
@Test
public void parseBytesToInt_doesNotChangeInputData() {
byte[] data = new byte[]{(byte) 0xAA, (byte) 0xBB, (byte) 0xCC, (byte) 0xDD};
byte[] copy = data.clone();
TbUtils.parseBytesToInt(data, 0, 4, true);
Assertions.assertArrayEquals(copy, data);
TbUtils.parseBytesToInt(data, 0, 4, false);
Assertions.assertArrayEquals(copy, data);
TbUtils.parseBytesToUnsignedInt(data, 0, 4, true);
Assertions.assertArrayEquals(copy, data);
TbUtils.parseBytesToUnsignedInt(data, 0, 4, false);
Assertions.assertArrayEquals(copy, data);
TbUtils.parseBytesToLong(data, 0, 4, true);
Assertions.assertArrayEquals(copy, data);
TbUtils.parseBytesToLong(data, 0, 4, false);
Assertions.assertArrayEquals(copy, data);
TbUtils.parseBytesToFloat(data, 0, 4, true);
Assertions.assertArrayEquals(copy, data);
TbUtils.parseBytesToFloat(data, 0, 4, false);
Assertions.assertArrayEquals(copy, data);
TbUtils.parseBytesIntToFloat(data, 0, 4, true);
Assertions.assertArrayEquals(copy, data);
TbUtils.parseBytesIntToFloat(data, 0, 4, false);
Assertions.assertArrayEquals(copy, data);
TbUtils.parseBytesToDouble(data, 0, 4, true);
Assertions.assertArrayEquals(copy, data);
TbUtils.parseBytesToDouble(data, 0, 4, false);
Assertions.assertArrayEquals(copy, data);
TbUtils.parseBytesLongToDouble(data, 0, 4, true);
Assertions.assertArrayEquals(copy, data);
TbUtils.parseBytesLongToDouble(data, 0, 4, false);
Assertions.assertArrayEquals(copy, data);
List<Byte> listData = toList(new byte[]{(byte) 0xAA, (byte) 0xBB, (byte) 0xCC, (byte) 0xDD});
List<Byte> listCopy = new ArrayList<>(listData);
TbUtils.parseBytesToInt(listData, 0, 4, true);
Assertions.assertEquals(listCopy, listData);
TbUtils.parseBytesToInt(listData, 0, 4, false);
Assertions.assertEquals(listCopy, listData);
TbUtils.parseBytesToUnsignedInt(listData, 0, 4, true);
Assertions.assertEquals(listCopy, listData);
TbUtils.parseBytesToUnsignedInt(listData, 0, 4, false);
Assertions.assertEquals(listCopy, listData);
TbUtils.parseBytesToLong(listData, 0, 4, true);
Assertions.assertEquals(listCopy, listData);
TbUtils.parseBytesToLong(listData, 0, 4, false);
Assertions.assertEquals(listCopy, listData);
TbUtils.parseBytesToFloat(listData, 0, 4, true);
Assertions.assertEquals(listCopy, listData);
TbUtils.parseBytesToFloat(listData, 0, 4, false);
Assertions.assertEquals(listCopy, listData);
TbUtils.parseBytesIntToFloat(listData, 0, 4, true);
Assertions.assertEquals(listCopy, listData);
TbUtils.parseBytesIntToFloat(listData, 0, 4, false);
Assertions.assertEquals(listCopy, listData);
TbUtils.parseBytesToDouble(listData, 0, 4, true);
Assertions.assertEquals(listCopy, listData);
TbUtils.parseBytesToDouble(listData, 0, 4, false);
Assertions.assertEquals(listCopy, listData);
TbUtils.parseBytesLongToDouble(listData, 0, 4, true);
Assertions.assertEquals(listCopy, listData);
TbUtils.parseBytesLongToDouble(listData, 0, 4, false);
Assertions.assertEquals(listCopy, listData);
}
@Test
public void compare_parseBytesToInt_and_parseBytesToUnsignedInt() {
byte[] data = new byte[]{(byte) 0xFF, (byte) 0xFF, (byte) 0xFF, (byte) 0xFF};
// 4 bytes: parseBytesToInt returns -1, parseBytesToUnsignedInt returns 4294967295L
Assertions.assertEquals(-1, TbUtils.parseBytesToInt(data, 0, 4, true));
Assertions.assertEquals(4294967295L, TbUtils.parseBytesToUnsignedInt(data, 0, 4, true));
// 2 bytes (0xFFFF): both return 65535 (no sign extension for parseBytesToInt when length < 4)
Assertions.assertEquals(65535, TbUtils.parseBytesToInt(data, 0, 2, true));
Assertions.assertEquals(65535L, TbUtils.parseBytesToUnsignedInt(data, 0, 2, true));
// 2 bytes with high bit set (0x8000)
byte[] data2 = new byte[]{(byte) 0x80, (byte) 0x00};
Assertions.assertEquals(32768, TbUtils.parseBytesToInt(data2, 0, 2, true));
Assertions.assertEquals(32768L, TbUtils.parseBytesToUnsignedInt(data2, 0, 2, true));
}
@Test @Test
public void toFlatMap() { public void toFlatMap() {
ExecutionHashMap<String, Object> inputMap = new ExecutionHashMap<>(16, ctx); ExecutionHashMap<String, Object> inputMap = new ExecutionHashMap<>(16, ctx);

4
common/util/src/main/java/org/thingsboard/common/util/ThingsBoardThreadFactory.java

@ -33,9 +33,7 @@ public class ThingsBoardThreadFactory implements ThreadFactory {
} }
private ThingsBoardThreadFactory(String name) { private ThingsBoardThreadFactory(String name) {
SecurityManager s = System.getSecurityManager(); group = Thread.currentThread().getThreadGroup();
group = (s != null) ? s.getThreadGroup() :
Thread.currentThread().getThreadGroup();
namePrefix = name + "-" + namePrefix = name + "-" +
poolNumber.getAndIncrement() + poolNumber.getAndIncrement() +
"-thread-"; "-thread-";

16
common/version-control/src/main/java/org/thingsboard/server/service/sync/DefaultGitSyncService.java

@ -18,6 +18,7 @@ package org.thingsboard.server.service.sync;
import jakarta.annotation.PreDestroy; import jakarta.annotation.PreDestroy;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.StringUtils; import org.apache.commons.lang3.StringUtils;
import org.eclipse.jgit.revwalk.RevCommit;
import org.springframework.beans.factory.annotation.Value; import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import org.thingsboard.common.util.ThingsBoardExecutors; import org.thingsboard.common.util.ThingsBoardExecutors;
@ -47,6 +48,8 @@ public class DefaultGitSyncService implements GitSyncService {
private final Map<String, GitRepository> repositories = new ConcurrentHashMap<>(); private final Map<String, GitRepository> repositories = new ConcurrentHashMap<>();
private final Map<String, Runnable> updateListeners = new ConcurrentHashMap<>(); private final Map<String, Runnable> updateListeners = new ConcurrentHashMap<>();
private RevCommit lastCommit;
@Override @Override
public void registerSync(String key, String repoUri, String branch, long fetchFrequencyMs, Runnable onUpdate) { public void registerSync(String key, String repoUri, String branch, long fetchFrequencyMs, Runnable onUpdate) {
RepositorySettings settings = new RepositorySettings(); RepositorySettings settings = new RepositorySettings();
@ -84,7 +87,7 @@ public class DefaultGitSyncService implements GitSyncService {
@Override @Override
public List<RepoFile> listFiles(String key, String path, int depth, FileType type) { public List<RepoFile> listFiles(String key, String path, int depth, FileType type) {
GitRepository repository = getRepository(key); GitRepository repository = getRepository(key);
return repository.listFilesAtCommit(getBranchRef(repository), path, depth).stream() return repository.listFilesAtCommit(lastCommit, path, depth).stream()
.filter(file -> type == null || file.type() == type) .filter(file -> type == null || file.type() == type)
.toList(); .toList();
} }
@ -93,7 +96,7 @@ public class DefaultGitSyncService implements GitSyncService {
@Override @Override
public byte[] getFileContent(String key, String path) { public byte[] getFileContent(String key, String path) {
GitRepository repository = getRepository(key); GitRepository repository = getRepository(key);
return repository.getFileContentAtCommit(path, getBranchRef(repository)); return repository.getFileContentAtCommit(path, lastCommit);
} }
@Override @Override
@ -137,6 +140,15 @@ public class DefaultGitSyncService implements GitSyncService {
} }
private void onUpdate(String key) { private void onUpdate(String key) {
GitRepository repository = getRepository(key);
String branchRef = getBranchRef(repository);
try {
lastCommit = repository.resolveCommit(branchRef);
} catch (Throwable e) {
log.error("[{}] Failed to resolve commit for ref {}", key, branchRef, e);
return;
}
Runnable listener = updateListeners.get(key); Runnable listener = updateListeners.get(key);
if (listener != null) { if (listener != null) {
log.debug("[{}] Handling repository update", key); log.debug("[{}] Handling repository update", key);

35
common/version-control/src/main/java/org/thingsboard/server/service/sync/vc/GitRepository.java

@ -277,13 +277,17 @@ public class GitRepository {
return listFilesAtCommit(commitId, path, -1).stream().map(RepoFile::path).toList(); return listFilesAtCommit(commitId, path, -1).stream().map(RepoFile::path).toList();
} }
@SneakyThrows
public List<RepoFile> listFilesAtCommit(String commitId, String path, int depth) { public List<RepoFile> listFilesAtCommit(String commitId, String path, int depth) {
log.debug("Executing listFilesAtCommit [{}][{}][{}]", settings.getRepositoryUri(), commitId, path); RevCommit commit = resolveCommit(commitId);
return listFilesAtCommit(commit, path, depth);
}
@SneakyThrows
public List<RepoFile> listFilesAtCommit(RevCommit commit, String path, int depth) {
log.debug("Executing listFilesAtCommit [{}][{}][{}]", settings.getRepositoryUri(), commit, path);
List<RepoFile> files = new ArrayList<>(); List<RepoFile> files = new ArrayList<>();
RevCommit revCommit = resolveCommit(commitId);
try (TreeWalk treeWalk = new TreeWalk(git.getRepository())) { try (TreeWalk treeWalk = new TreeWalk(git.getRepository())) {
treeWalk.reset(revCommit.getTree().getId()); treeWalk.reset(commit.getTree().getId());
if (StringUtils.isNotEmpty(path)) { if (StringUtils.isNotEmpty(path)) {
treeWalk.setFilter(PathFilter.create(path)); treeWalk.setFilter(PathFilter.create(path));
} }
@ -301,11 +305,15 @@ public class GitRepository {
return files; return files;
} }
@SneakyThrows
public byte[] getFileContentAtCommit(String file, String commitId) { public byte[] getFileContentAtCommit(String file, String commitId) {
log.debug("Executing getFileContentAtCommit [{}][{}][{}]", settings.getRepositoryUri(), commitId, file); RevCommit commit = resolveCommit(commitId);
RevCommit revCommit = resolveCommit(commitId); return getFileContentAtCommit(file, commit);
try (TreeWalk treeWalk = TreeWalk.forPath(git.getRepository(), file, revCommit.getTree())) { }
@SneakyThrows
public byte[] getFileContentAtCommit(String file, RevCommit commit) {
log.debug("Executing getFileContentAtCommit [{}][{}][{}]", settings.getRepositoryUri(), commit, file);
try (TreeWalk treeWalk = TreeWalk.forPath(git.getRepository(), file, commit.getTree())) {
if (treeWalk == null) { if (treeWalk == null) {
throw new IllegalArgumentException("File not found"); throw new IllegalArgumentException("File not found");
} }
@ -373,9 +381,9 @@ public class GitRepository {
for (RemoteRefUpdate update : pushResult.getRemoteUpdates()) { for (RemoteRefUpdate update : pushResult.getRemoteUpdates()) {
RemoteRefUpdate.Status status = update.getStatus(); RemoteRefUpdate.Status status = update.getStatus();
if (status == REJECTED_NONFASTFORWARD || status == REJECTED_NODELETE || if (status == REJECTED_NONFASTFORWARD || status == REJECTED_NODELETE ||
status == REJECTED_REMOTE_CHANGED || status == REJECTED_OTHER_REASON) { status == REJECTED_REMOTE_CHANGED || status == REJECTED_OTHER_REASON) {
throw new RuntimeException("Remote repository answered with error: " + throw new RuntimeException("Remote repository answered with error: " +
Optional.ofNullable(update.getMessage()).orElseGet(status::name)); Optional.ofNullable(update.getMessage()).orElseGet(status::name));
} }
} }
}); });
@ -450,7 +458,8 @@ public class GitRepository {
revCommit.getFullMessage(), revCommit.getAuthorIdent().getName(), revCommit.getAuthorIdent().getEmailAddress()); revCommit.getFullMessage(), revCommit.getAuthorIdent().getName(), revCommit.getAuthorIdent().getEmailAddress());
} }
private RevCommit resolveCommit(String id) throws IOException { @SneakyThrows
public RevCommit resolveCommit(String id) {
return git.getRepository().parseCommit(resolve(id)); return git.getRepository().parseCommit(resolve(id));
} }
@ -481,8 +490,8 @@ public class GitRepository {
private static final Function<PageLink, Comparator<RevCommit>> revCommitComparatorFunction = pageLink -> { private static final Function<PageLink, Comparator<RevCommit>> revCommitComparatorFunction = pageLink -> {
SortOrder sortOrder = pageLink.getSortOrder(); SortOrder sortOrder = pageLink.getSortOrder();
if (sortOrder != null if (sortOrder != null
&& sortOrder.getProperty().equals("timestamp") && sortOrder.getProperty().equals("timestamp")
&& SortOrder.Direction.ASC.equals(sortOrder.getDirection())) { && SortOrder.Direction.ASC.equals(sortOrder.getDirection())) {
return Comparator.comparingInt(RevCommit::getCommitTime); return Comparator.comparingInt(RevCommit::getCommitTime);
} }
return null; return null;

6
dao/src/main/java/org/thingsboard/server/dao/attributes/AttributesDao.java

@ -55,6 +55,12 @@ public interface AttributesDao {
List<String> findAllKeysByEntityIdsAndScope(TenantId tenantId, List<EntityId> entityIds, AttributeScope scope); List<String> findAllKeysByEntityIdsAndScope(TenantId tenantId, List<EntityId> entityIds, AttributeScope scope);
ListenableFuture<List<String>> findAllKeysByEntityIdsAndScopeAsync(TenantId tenantId, List<EntityId> entityIds, AttributeScope scope);
List<AttributeKvEntry> findLatestByEntityIdsAndScope(TenantId tenantId, List<EntityId> entityIds, AttributeScope scope);
ListenableFuture<List<AttributeKvEntry>> findLatestByEntityIdsAndScopeAsync(TenantId tenantId, List<EntityId> entityIds, AttributeScope scope);
List<Pair<AttributeScope, String>> removeAllByEntityId(TenantId tenantId, EntityId entityId); List<Pair<AttributeScope, String>> removeAllByEntityId(TenantId tenantId, EntityId entityId);
} }

20
dao/src/main/java/org/thingsboard/server/dao/attributes/BaseAttributesService.java

@ -45,9 +45,6 @@ import java.util.Optional;
import static org.thingsboard.server.dao.attributes.AttributeUtils.validate; import static org.thingsboard.server.dao.attributes.AttributeUtils.validate;
/**
* @author Andrew Shvayka
*/
@Service @Service
@ConditionalOnProperty(prefix = "cache.attributes", value = "enabled", havingValue = "false", matchIfMissing = true) @ConditionalOnProperty(prefix = "cache.attributes", value = "enabled", havingValue = "false", matchIfMissing = true)
@Primary @Primary
@ -92,7 +89,7 @@ public class BaseAttributesService implements AttributesService {
} }
@Override @Override
public List<String> findAllKeysByEntityIds(TenantId tenantId, List<EntityId> entityIds, AttributeScope scope) { public List<String> findAllKeysByEntityIdsAndScope(TenantId tenantId, List<EntityId> entityIds, AttributeScope scope) {
if (scope == null) { if (scope == null) {
return attributesDao.findAllKeysByEntityIds(tenantId, entityIds); return attributesDao.findAllKeysByEntityIds(tenantId, entityIds);
} else { } else {
@ -100,6 +97,21 @@ public class BaseAttributesService implements AttributesService {
} }
} }
@Override
public ListenableFuture<List<String>> findAllKeysByEntityIdsAndScopeAsync(TenantId tenantId, List<EntityId> entityIds, AttributeScope scope) {
return attributesDao.findAllKeysByEntityIdsAndScopeAsync(tenantId, entityIds, scope);
}
@Override
public List<AttributeKvEntry> findLatestByEntityIdsAndScope(TenantId tenantId, List<EntityId> entityIds, AttributeScope scope) {
return attributesDao.findLatestByEntityIdsAndScope(tenantId, entityIds, scope);
}
@Override
public ListenableFuture<List<AttributeKvEntry>> findLatestByEntityIdsAndScopeAsync(TenantId tenantId, List<EntityId> entityIds, AttributeScope scope) {
return attributesDao.findLatestByEntityIdsAndScopeAsync(tenantId, entityIds, scope);
}
@Override @Override
public ListenableFuture<AttributesSaveResult> save(TenantId tenantId, EntityId entityId, AttributeScope scope, AttributeKvEntry attribute) { public ListenableFuture<AttributesSaveResult> save(TenantId tenantId, EntityId entityId, AttributeScope scope, AttributeKvEntry attribute) {
validate(entityId, scope); validate(entityId, scope);

18
dao/src/main/java/org/thingsboard/server/dao/attributes/CachedAttributesService.java

@ -66,6 +66,7 @@ import static org.thingsboard.server.dao.attributes.AttributeUtils.validate;
@Primary @Primary
@Slf4j @Slf4j
public class CachedAttributesService implements AttributesService { public class CachedAttributesService implements AttributesService {
private static final String STATS_NAME = "attributes.cache"; private static final String STATS_NAME = "attributes.cache";
public static final String LOCAL_CACHE_TYPE = "caffeine"; public static final String LOCAL_CACHE_TYPE = "caffeine";
@ -212,7 +213,7 @@ public class CachedAttributesService implements AttributesService {
} }
@Override @Override
public List<String> findAllKeysByEntityIds(TenantId tenantId, List<EntityId> entityIds, AttributeScope scope) { public List<String> findAllKeysByEntityIdsAndScope(TenantId tenantId, List<EntityId> entityIds, AttributeScope scope) {
if (scope == null) { if (scope == null) {
return attributesDao.findAllKeysByEntityIds(tenantId, entityIds); return attributesDao.findAllKeysByEntityIds(tenantId, entityIds);
} else { } else {
@ -220,6 +221,21 @@ public class CachedAttributesService implements AttributesService {
} }
} }
@Override
public ListenableFuture<List<String>> findAllKeysByEntityIdsAndScopeAsync(TenantId tenantId, List<EntityId> entityIds, AttributeScope scope) {
return attributesDao.findAllKeysByEntityIdsAndScopeAsync(tenantId, entityIds, scope);
}
@Override
public List<AttributeKvEntry> findLatestByEntityIdsAndScope(TenantId tenantId, List<EntityId> entityIds, AttributeScope scope) {
return attributesDao.findLatestByEntityIdsAndScope(tenantId, entityIds, scope);
}
@Override
public ListenableFuture<List<AttributeKvEntry>> findLatestByEntityIdsAndScopeAsync(TenantId tenantId, List<EntityId> entityIds, AttributeScope scope) {
return attributesDao.findLatestByEntityIdsAndScopeAsync(tenantId, entityIds, scope);
}
@Override @Override
public ListenableFuture<AttributesSaveResult> save(TenantId tenantId, EntityId entityId, AttributeScope scope, AttributeKvEntry attribute) { public ListenableFuture<AttributesSaveResult> save(TenantId tenantId, EntityId entityId, AttributeScope scope, AttributeKvEntry attribute) {
validate(entityId, scope); validate(entityId, scope);

86
dao/src/main/java/org/thingsboard/server/dao/entity/BaseEntityService.java

@ -16,6 +16,9 @@
package org.thingsboard.server.dao.entity; package org.thingsboard.server.dao.entity;
import com.google.common.util.concurrent.FluentFuture; import com.google.common.util.concurrent.FluentFuture;
import com.google.common.util.concurrent.Futures;
import com.google.common.util.concurrent.ListenableFuture;
import com.google.common.util.concurrent.MoreExecutors;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Lazy; import org.springframework.context.annotation.Lazy;
@ -55,6 +58,7 @@ import org.thingsboard.server.common.msg.edqs.EdqsService;
import org.thingsboard.server.common.stats.EdqsStatsService; import org.thingsboard.server.common.stats.EdqsStatsService;
import org.thingsboard.server.dao.exception.IncorrectParameterException; import org.thingsboard.server.dao.exception.IncorrectParameterException;
import org.thingsboard.server.dao.model.ModelConstants; import org.thingsboard.server.dao.model.ModelConstants;
import org.thingsboard.server.dao.sql.JpaExecutorService;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.Collections; import java.util.Collections;
@ -104,6 +108,9 @@ public class BaseEntityService extends AbstractEntityService implements EntitySe
@Autowired @Autowired
private EdqsStatsService edqsStatsService; private EdqsStatsService edqsStatsService;
@Autowired
private JpaExecutorService jpaExecutorService;
@Override @Override
public long countEntitiesByQuery(TenantId tenantId, CustomerId customerId, EntityCountQuery query) { public long countEntitiesByQuery(TenantId tenantId, CustomerId customerId, EntityCountQuery query) {
log.trace("Executing countEntitiesByQuery, tenantId [{}], customerId [{}], query [{}]", tenantId, customerId, query); log.trace("Executing countEntitiesByQuery, tenantId [{}], customerId [{}], query [{}]", tenantId, customerId, query);
@ -142,41 +149,78 @@ public class BaseEntityService extends AbstractEntityService implements EntitySe
EdqsResponse response = processEdqsRequest(tenantId, customerId, request); EdqsResponse response = processEdqsRequest(tenantId, customerId, request);
result = response.getEntityDataQueryResult(); result = response.getEntityDataQueryResult();
} else { } else {
if (!isValidForOptimization(query)) { result = findEntityDataByQueryInternal(tenantId, customerId, query);
result = entityQueryDao.findEntityDataByQuery(tenantId, customerId, query);
} else {
// 1 step - find entity data by filter and sort columns
PageData<EntityData> entityDataByQuery = findEntityIdsByFilterAndSorterColumns(tenantId, customerId, query);
if (entityDataByQuery == null || entityDataByQuery.getData().isEmpty()) {
result = entityDataByQuery;
} else {
// 2 step - find entity data by entity ids from the 1st step
List<EntityData> entities = fetchEntityDataByIdsFromInitialQuery(tenantId, customerId, query, entityDataByQuery.getData());
result = new PageData<>(entities, entityDataByQuery.getTotalPages(), entityDataByQuery.getTotalElements(), entityDataByQuery.hasNext());
}
}
} }
edqsStatsService.reportEntityDataQuery(tenantId, query, System.nanoTime() - startNs); edqsStatsService.reportEntityDataQuery(tenantId, query, System.nanoTime() - startNs);
return result; return result;
} }
@Override
public ListenableFuture<PageData<EntityData>> findEntityDataByQueryAsync(TenantId tenantId, CustomerId customerId, EntityDataQuery query) {
log.trace("Executing findEntityDataByQueryAsync, tenantId [{}], customerId [{}], query [{}]", tenantId, customerId, query);
try {
validateId(tenantId, id -> INCORRECT_TENANT_ID + id);
validateId(customerId, id -> INCORRECT_CUSTOMER_ID + id);
validateEntityDataQuery(query);
} catch (Exception e) {
return Futures.immediateFailedFuture(e);
}
if (edqsService.isApiEnabled() && validForEdqs(query) && !tenantId.isSysTenantId()) {
EdqsRequest request = EdqsRequest.builder()
.entityDataQuery(query)
.build();
long startNs = System.nanoTime();
return Futures.transform(processEdqsRequestAsync(tenantId, customerId, request), response -> {
edqsStatsService.reportEntityDataQuery(tenantId, query, System.nanoTime() - startNs);
return response.getEntityDataQueryResult();
}, MoreExecutors.directExecutor());
}
return jpaExecutorService.submit(() -> {
long startNs = System.nanoTime();
PageData<EntityData> result = findEntityDataByQueryInternal(tenantId, customerId, query);
edqsStatsService.reportEntityDataQuery(tenantId, query, System.nanoTime() - startNs);
return result;
});
}
private PageData<EntityData> findEntityDataByQueryInternal(TenantId tenantId, CustomerId customerId, EntityDataQuery query) {
if (!isValidForOptimization(query)) {
return entityQueryDao.findEntityDataByQuery(tenantId, customerId, query);
}
// 1 step - find entity data by filter and sort columns
PageData<EntityData> entityDataByQuery = findEntityIdsByFilterAndSorterColumns(tenantId, customerId, query);
if (entityDataByQuery == null || entityDataByQuery.getData().isEmpty()) {
return entityDataByQuery;
}
// 2 step - find entity data by entity ids from the 1st step
List<EntityData> entities = fetchEntityDataByIdsFromInitialQuery(tenantId, customerId, query, entityDataByQuery.getData());
return new PageData<>(entities, entityDataByQuery.getTotalPages(), entityDataByQuery.getTotalElements(), entityDataByQuery.hasNext());
}
private boolean validForEdqs(EntityCountQuery query) { // for compatibility with PE private boolean validForEdqs(EntityCountQuery query) { // for compatibility with PE
return true; return true;
} }
private EdqsResponse processEdqsRequest(TenantId tenantId, CustomerId customerId, EdqsRequest request) { private EdqsResponse processEdqsRequest(TenantId tenantId, CustomerId customerId, EdqsRequest request) {
EdqsResponse response;
try { try {
log.debug("[{}] Sending request to EDQS: {}", tenantId, request); return processEdqsRequestAsync(tenantId, customerId, request).get();
response = edqsApiService.processRequest(tenantId, customerId, request).get();
} catch (InterruptedException | ExecutionException e) { } catch (InterruptedException | ExecutionException e) {
throw new RuntimeException(e); throw new RuntimeException(e);
} }
log.debug("[{}] Received response from EDQS: {}", tenantId, response); }
if (response.getError() != null) {
throw new RuntimeException(response.getError()); private ListenableFuture<EdqsResponse> processEdqsRequestAsync(TenantId tenantId, CustomerId customerId, EdqsRequest request) {
} log.debug("[{}] Sending request to EDQS: {}", tenantId, request);
return response; return Futures.transform(edqsApiService.processRequest(tenantId, customerId, request), response -> {
log.debug("[{}] Received response from EDQS: {}", tenantId, response);
if (response.getError() != null) {
throw new RuntimeException(response.getError());
}
return response;
}, MoreExecutors.directExecutor());
} }
@Override @Override

6
dao/src/main/java/org/thingsboard/server/dao/model/sqlts/latest/TsKvLatestEntity.java

@ -65,6 +65,12 @@ import static org.thingsboard.server.dao.model.ModelConstants.VERSION_COLUMN;
query = SearchTsKvLatestRepository.FIND_ALL_BY_ENTITY_ID_QUERY, query = SearchTsKvLatestRepository.FIND_ALL_BY_ENTITY_ID_QUERY,
resultSetMapping = "tsKvLatestFindMapping", resultSetMapping = "tsKvLatestFindMapping",
resultClass = TsKvLatestEntity.class resultClass = TsKvLatestEntity.class
),
@NamedNativeQuery(
name = SearchTsKvLatestRepository.FIND_LATEST_BY_ENTITY_IDS,
query = SearchTsKvLatestRepository.FIND_LATEST_BY_ENTITY_IDS_QUERY,
resultSetMapping = "tsKvLatestFindMapping",
resultClass = TsKvLatestEntity.class
) )
}) })
public final class TsKvLatestEntity extends AbstractTsKvEntity { public final class TsKvLatestEntity extends AbstractTsKvEntity {

3
dao/src/main/java/org/thingsboard/server/dao/nosql/CassandraAbstractAsyncDao.java

@ -37,6 +37,9 @@ public abstract class CassandraAbstractAsyncDao extends CassandraAbstractDao {
@Value("${cassandra.query.result_processing_threads:50}") @Value("${cassandra.query.result_processing_threads:50}")
private int threadPoolSize; private int threadPoolSize;
@Value("${cassandra.query.max_result_set_size_in_bytes:52428800}")
protected long maxResultSetSizeBytes;
@PostConstruct @PostConstruct
public void startExecutor() { public void startExecutor() {
readResultsProcessingExecutor = ThingsBoardExecutors.newWorkStealingPool(threadPoolSize, "cassandra-callback"); readResultsProcessingExecutor = ThingsBoardExecutors.newWorkStealingPool(threadPoolSize, "cassandra-callback");

36
dao/src/main/java/org/thingsboard/server/dao/sql/alarm/AlarmRepository.java

@ -98,10 +98,8 @@ public interface AlarmRepository extends JpaRepository<AlarmEntity, UUID> {
"AND ea.entityType = :affectedEntityType " + "AND ea.entityType = :affectedEntityType " +
"AND (:startTime IS NULL OR (a.createdTime >= :startTime AND ea.createdTime >= :startTime)) " + "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 (:endTime IS NULL OR (a.createdTime <= :endTime AND ea.createdTime <= :endTime)) " +
"AND ((:#{#alarmTypes == null} = true) OR a.type IN (:alarmTypes)) " + //HHH-15968 "AND ((:alarmTypes) IS NULL OR a.type IN (:alarmTypes)) " +
"AND ((:#{#alarmSeverities == null} = true) OR a.severity IN (:alarmSeverities)) " + //HHH-15968 "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 ((:clearFilterEnabled) = FALSE OR a.cleared = :clearFilter) " +
"AND ((:ackFilterEnabled) = FALSE OR a.acknowledged = :ackFilter) " + "AND ((:ackFilterEnabled) = FALSE OR a.acknowledged = :ackFilter) " +
"AND (:assigneeId IS NULL OR a.assigneeId = :assigneeId) " + "AND (:assigneeId IS NULL OR a.assigneeId = :assigneeId) " +
@ -119,10 +117,8 @@ public interface AlarmRepository extends JpaRepository<AlarmEntity, UUID> {
"AND ea.entityType = :affectedEntityType " + "AND ea.entityType = :affectedEntityType " +
"AND (:startTime IS NULL OR (a.createdTime >= :startTime AND ea.createdTime >= :startTime)) " + "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 (:endTime IS NULL OR (a.createdTime <= :endTime AND ea.createdTime <= :endTime)) " +
"AND ((:#{#alarmTypes == null} = true) OR a.type IN (:alarmTypes)) " + //HHH-15968 "AND ((:alarmTypes) IS NULL OR a.type IN (:alarmTypes)) " +
"AND ((:#{#alarmSeverities == null} = true) OR a.severity IN (:alarmSeverities)) " + //HHH-15968 "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 ((:clearFilterEnabled) = FALSE OR a.cleared = :clearFilter) " +
"AND ((:ackFilterEnabled) = FALSE OR a.acknowledged = :ackFilter) " + "AND ((:ackFilterEnabled) = FALSE OR a.acknowledged = :ackFilter) " +
"AND (:assigneeId IS NULL OR a.assigneeId = :assigneeId) " + "AND (:assigneeId IS NULL OR a.assigneeId = :assigneeId) " +
@ -183,10 +179,8 @@ public interface AlarmRepository extends JpaRepository<AlarmEntity, UUID> {
"WHERE a.tenantId = :tenantId " + "WHERE a.tenantId = :tenantId " +
"AND (:startTime IS NULL OR a.createdTime >= :startTime) " + "AND (:startTime IS NULL OR a.createdTime >= :startTime) " +
"AND (:endTime IS NULL OR a.createdTime <= :endTime) " + "AND (:endTime IS NULL OR a.createdTime <= :endTime) " +
"AND ((:#{#alarmTypes == null} = true) OR a.type IN (:alarmTypes)) " + //HHH-15968 "AND ((:alarmTypes) IS NULL OR a.type IN (:alarmTypes)) " +
"AND ((:#{#alarmSeverities == null} = true) OR a.severity IN (:alarmSeverities)) " + //HHH-15968 "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 ((:clearFilterEnabled) = FALSE OR a.cleared = :clearFilter) " +
"AND ((:ackFilterEnabled) = FALSE OR a.acknowledged = :ackFilter) " + "AND ((:ackFilterEnabled) = FALSE OR a.acknowledged = :ackFilter) " +
"AND (:assigneeId IS NULL OR a.assigneeId = :assigneeId) " + "AND (:assigneeId IS NULL OR a.assigneeId = :assigneeId) " +
@ -199,10 +193,8 @@ public interface AlarmRepository extends JpaRepository<AlarmEntity, UUID> {
"WHERE a.tenantId = :tenantId " + "WHERE a.tenantId = :tenantId " +
"AND (:startTime IS NULL OR a.createdTime >= :startTime) " + "AND (:startTime IS NULL OR a.createdTime >= :startTime) " +
"AND (:endTime IS NULL OR a.createdTime <= :endTime) " + "AND (:endTime IS NULL OR a.createdTime <= :endTime) " +
"AND ((:#{#alarmTypes == null} = true) OR a.type IN (:alarmTypes)) " + //HHH-15968 "AND ((:alarmTypes) IS NULL OR a.type IN (:alarmTypes)) " +
"AND ((:#{#alarmSeverities == null} = true) OR a.severity IN (:alarmSeverities)) " + //HHH-15968 "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 ((:clearFilterEnabled) = FALSE OR a.cleared = :clearFilter) " +
"AND ((:ackFilterEnabled) = FALSE OR a.acknowledged = :ackFilter) " + "AND ((:ackFilterEnabled) = FALSE OR a.acknowledged = :ackFilter) " +
"AND (:assigneeId IS NULL OR a.assigneeId = :assigneeId) " + "AND (:assigneeId IS NULL OR a.assigneeId = :assigneeId) " +
@ -263,10 +255,8 @@ public interface AlarmRepository extends JpaRepository<AlarmEntity, UUID> {
"WHERE a.tenantId = :tenantId AND a.customerId = :customerId " + "WHERE a.tenantId = :tenantId AND a.customerId = :customerId " +
"AND (:startTime IS NULL OR a.createdTime >= :startTime) " + "AND (:startTime IS NULL OR a.createdTime >= :startTime) " +
"AND (:endTime IS NULL OR a.createdTime <= :endTime) " + "AND (:endTime IS NULL OR a.createdTime <= :endTime) " +
"AND ((:#{#alarmTypes == null} = true) OR a.type IN (:alarmTypes)) " + //HHH-15968 "AND ((:alarmTypes) IS NULL OR a.type IN (:alarmTypes)) " +
"AND ((:#{#alarmSeverities == null} = true) OR a.severity IN (:alarmSeverities)) " + //HHH-15968 "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 ((:clearFilterEnabled) = FALSE OR a.cleared = :clearFilter) " +
"AND ((:ackFilterEnabled) = FALSE OR a.acknowledged = :ackFilter) " + "AND ((:ackFilterEnabled) = FALSE OR a.acknowledged = :ackFilter) " +
"AND (:assigneeId IS NULL OR a.assigneeId = :assigneeId) " + "AND (:assigneeId IS NULL OR a.assigneeId = :assigneeId) " +
@ -280,10 +270,8 @@ public interface AlarmRepository extends JpaRepository<AlarmEntity, UUID> {
"WHERE a.tenantId = :tenantId AND a.customerId = :customerId " + "WHERE a.tenantId = :tenantId AND a.customerId = :customerId " +
"AND (:startTime IS NULL OR a.createdTime >= :startTime) " + "AND (:startTime IS NULL OR a.createdTime >= :startTime) " +
"AND (:endTime IS NULL OR a.createdTime <= :endTime) " + "AND (:endTime IS NULL OR a.createdTime <= :endTime) " +
"AND ((:#{#alarmTypes == null} = true) OR a.type IN (:alarmTypes)) " + //HHH-15968 "AND ((:alarmTypes) IS NULL OR a.type IN (:alarmTypes)) " +
"AND ((:#{#alarmSeverities == null} = true) OR a.severity IN (:alarmSeverities)) " + //HHH-15968 "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 ((:clearFilterEnabled) = FALSE OR a.cleared = :clearFilter) " +
"AND ((:ackFilterEnabled) = FALSE OR a.acknowledged = :ackFilter) " + "AND ((:ackFilterEnabled) = FALSE OR a.acknowledged = :ackFilter) " +
"AND (:assigneeId IS NULL OR a.assigneeId = :assigneeId) " + "AND (:assigneeId IS NULL OR a.assigneeId = :assigneeId) " +

57
dao/src/main/java/org/thingsboard/server/dao/sql/attributes/AttributeKvRepository.java

@ -20,6 +20,14 @@ import org.springframework.data.jpa.repository.Modifying;
import org.springframework.data.jpa.repository.Query; import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param; import org.springframework.data.repository.query.Param;
import org.springframework.transaction.annotation.Transactional; import org.springframework.transaction.annotation.Transactional;
import org.thingsboard.server.common.data.kv.AttributeKvEntry;
import org.thingsboard.server.common.data.kv.BaseAttributeKvEntry;
import org.thingsboard.server.common.data.kv.BooleanDataEntry;
import org.thingsboard.server.common.data.kv.DoubleDataEntry;
import org.thingsboard.server.common.data.kv.JsonDataEntry;
import org.thingsboard.server.common.data.kv.KvEntry;
import org.thingsboard.server.common.data.kv.LongDataEntry;
import org.thingsboard.server.common.data.kv.StringDataEntry;
import org.thingsboard.server.dao.model.sql.AttributeKvCompositeKey; import org.thingsboard.server.dao.model.sql.AttributeKvCompositeKey;
import org.thingsboard.server.dao.model.sql.AttributeKvEntity; import org.thingsboard.server.dao.model.sql.AttributeKvEntity;
@ -60,6 +68,19 @@ public interface AttributeKvRepository extends JpaRepository<AttributeKvEntity,
List<Integer> findAllKeysByEntityIdsAndAttributeType(@Param("entityIds") List<UUID> entityIds, List<Integer> findAllKeysByEntityIdsAndAttributeType(@Param("entityIds") List<UUID> entityIds,
@Param("attributeType") int attributeType); @Param("attributeType") int attributeType);
@Query(value = """
SELECT DISTINCT ON (a.attribute_key)
kd.key AS strKey,
a.bool_v AS boolV, a.str_v AS strV, a.long_v AS longV,
a.dbl_v AS dblV, a.json_v AS jsonV,
a.last_update_ts AS lastUpdateTs, a.version AS version
FROM attribute_kv a
INNER JOIN key_dictionary kd ON a.attribute_key = kd.key_id
WHERE a.entity_id IN :entityIds AND a.attribute_type = :attributeType
ORDER BY a.attribute_key, a.last_update_ts DESC""", nativeQuery = true)
List<AttributeKvProjection> findLatestByEntityIdsAndAttributeType(@Param("entityIds") List<UUID> entityIds,
@Param("attributeType") int attributeType);
@Query(value = "SELECT attribute_key, attribute_type, entity_id, bool_v, dbl_v, json_v, last_update_ts, long_v, str_v, version FROM attribute_kv WHERE (entity_id, attribute_type, attribute_key) > " + @Query(value = "SELECT attribute_key, attribute_type, entity_id, bool_v, dbl_v, json_v, last_update_ts, long_v, str_v, version FROM attribute_kv WHERE (entity_id, attribute_type, attribute_key) > " +
"(:entityId, :attributeType, :attributeKey) ORDER BY entity_id, attribute_type, attribute_key LIMIT :batchSize", nativeQuery = true) "(:entityId, :attributeType, :attributeKey) ORDER BY entity_id, attribute_type, attribute_key LIMIT :batchSize", nativeQuery = true)
List<AttributeKvEntity> findNextBatch(@Param("entityId") UUID entityId, List<AttributeKvEntity> findNextBatch(@Param("entityId") UUID entityId,
@ -67,4 +88,40 @@ public interface AttributeKvRepository extends JpaRepository<AttributeKvEntity,
@Param("attributeKey") int attributeKey, @Param("attributeKey") int attributeKey,
@Param("batchSize") int batchSize); @Param("batchSize") int batchSize);
interface AttributeKvProjection {
String getStrKey();
Boolean getBoolV();
String getStrV();
Long getLongV();
Double getDblV();
String getJsonV();
Long getLastUpdateTs();
Long getVersion();
static AttributeKvEntry toAttributeKvEntry(AttributeKvProjection p) {
KvEntry kvEntry = null;
if (p.getStrV() != null) {
kvEntry = new StringDataEntry(p.getStrKey(), p.getStrV());
} else if (p.getBoolV() != null) {
kvEntry = new BooleanDataEntry(p.getStrKey(), p.getBoolV());
} else if (p.getDblV() != null) {
kvEntry = new DoubleDataEntry(p.getStrKey(), p.getDblV());
} else if (p.getLongV() != null) {
kvEntry = new LongDataEntry(p.getStrKey(), p.getLongV());
} else if (p.getJsonV() != null) {
kvEntry = new JsonDataEntry(p.getStrKey(), p.getJsonV());
}
return new BaseAttributeKvEntry(kvEntry, p.getLastUpdateTs(), p.getVersion());
}
}
} }

24
dao/src/main/java/org/thingsboard/server/dao/sql/attributes/JpaAttributeDao.java

@ -20,6 +20,7 @@ import com.google.common.util.concurrent.ListenableFuture;
import jakarta.annotation.PostConstruct; import jakarta.annotation.PostConstruct;
import jakarta.annotation.PreDestroy; import jakarta.annotation.PreDestroy;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.apache.commons.collections4.CollectionUtils;
import org.apache.commons.lang3.tuple.Pair; import org.apache.commons.lang3.tuple.Pair;
import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value; import org.springframework.beans.factory.annotation.Value;
@ -46,6 +47,7 @@ import org.thingsboard.server.dao.util.SqlDao;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.Collection; import java.util.Collection;
import java.util.Collections;
import java.util.Comparator; import java.util.Comparator;
import java.util.List; import java.util.List;
import java.util.Optional; import java.util.Optional;
@ -185,6 +187,28 @@ public class JpaAttributeDao extends JpaAbstractDaoListeningExecutorService impl
.toList(); .toList();
} }
@Override
public ListenableFuture<List<String>> findAllKeysByEntityIdsAndScopeAsync(TenantId tenantId, List<EntityId> entityIds, AttributeScope scope) {
return service.submit(() -> findAllKeysByEntityIdsAndScope(tenantId, entityIds, scope));
}
@Override
public List<AttributeKvEntry> findLatestByEntityIdsAndScope(TenantId tenantId, List<EntityId> entityIds, AttributeScope scope) {
if (CollectionUtils.isEmpty(entityIds)) {
return Collections.emptyList();
}
var uniqueIds = entityIds.stream().map(EntityId::getId).distinct().toList();
return attributeKvRepository.findLatestByEntityIdsAndAttributeType(uniqueIds, scope.getId())
.stream()
.map(AttributeKvRepository.AttributeKvProjection::toAttributeKvEntry)
.toList();
}
@Override
public ListenableFuture<List<AttributeKvEntry>> findLatestByEntityIdsAndScopeAsync(TenantId tenantId, List<EntityId> entityIds, AttributeScope scope) {
return service.submit(() -> findLatestByEntityIdsAndScope(tenantId, entityIds, scope));
}
@Override @Override
public ListenableFuture<Long> save(TenantId tenantId, EntityId entityId, AttributeScope attributeScope, AttributeKvEntry attribute) { public ListenableFuture<Long> save(TenantId tenantId, EntityId entityId, AttributeScope attributeScope, AttributeKvEntry attribute) {
AttributeKvEntity entity = new AttributeKvEntity(); AttributeKvEntity entity = new AttributeKvEntity();

12
dao/src/main/java/org/thingsboard/server/dao/sql/audit/AuditLogRepository.java

@ -33,8 +33,7 @@ public interface AuditLogRepository extends JpaRepository<AuditLogEntity, UUID>
"a.tenantId = :tenantId " + "a.tenantId = :tenantId " +
"AND (:startTime IS NULL OR a.createdTime >= :startTime) " + "AND (:startTime IS NULL OR a.createdTime >= :startTime) " +
"AND (:endTime IS NULL OR a.createdTime <= :endTime) " + "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 " + "AND (:textSearch IS NULL OR ilike(a.entityType, CONCAT('%', :textSearch, '%')) = true " +
"OR ilike(a.entityName, CONCAT('%', :textSearch, '%')) = true " + "OR ilike(a.entityName, CONCAT('%', :textSearch, '%')) = true " +
"OR ilike(a.userName, CONCAT('%', :textSearch, '%')) = true " + "OR ilike(a.userName, CONCAT('%', :textSearch, '%')) = true " +
@ -54,8 +53,7 @@ public interface AuditLogRepository extends JpaRepository<AuditLogEntity, UUID>
"AND a.entityType = :entityType AND a.entityId = :entityId " + "AND a.entityType = :entityType AND a.entityId = :entityId " +
"AND (:startTime IS NULL OR a.createdTime >= :startTime) " + "AND (:startTime IS NULL OR a.createdTime >= :startTime) " +
"AND (:endTime IS NULL OR a.createdTime <= :endTime) " + "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 " + "AND (:textSearch IS NULL OR ilike(a.entityName, CONCAT('%', :textSearch, '%')) = true " +
"OR ilike(a.userName, CONCAT('%', :textSearch, '%')) = true " + "OR ilike(a.userName, CONCAT('%', :textSearch, '%')) = true " +
"OR ilike(a.actionType, CONCAT('%', :textSearch, '%')) = true " + "OR ilike(a.actionType, CONCAT('%', :textSearch, '%')) = true " +
@ -75,8 +73,7 @@ public interface AuditLogRepository extends JpaRepository<AuditLogEntity, UUID>
"AND a.customerId = :customerId " + "AND a.customerId = :customerId " +
"AND (:startTime IS NULL OR a.createdTime >= :startTime) " + "AND (:startTime IS NULL OR a.createdTime >= :startTime) " +
"AND (:endTime IS NULL OR a.createdTime <= :endTime) " + "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 " + "AND (:textSearch IS NULL OR ilike(a.entityType, CONCAT('%', :textSearch, '%')) = true " +
"OR ilike(a.entityName, CONCAT('%', :textSearch, '%')) = true " + "OR ilike(a.entityName, CONCAT('%', :textSearch, '%')) = true " +
"OR ilike(a.userName, CONCAT('%', :textSearch, '%')) = true " + "OR ilike(a.userName, CONCAT('%', :textSearch, '%')) = true " +
@ -96,8 +93,7 @@ public interface AuditLogRepository extends JpaRepository<AuditLogEntity, UUID>
"AND a.userId = :userId " + "AND a.userId = :userId " +
"AND (:startTime IS NULL OR a.createdTime >= :startTime) " + "AND (:startTime IS NULL OR a.createdTime >= :startTime) " +
"AND (:endTime IS NULL OR a.createdTime <= :endTime) " + "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 " + "AND (:textSearch IS NULL OR ilike(a.entityType, CONCAT('%', :textSearch, '%')) = true " +
"OR ilike(a.entityName, CONCAT('%', :textSearch, '%')) = true " + "OR ilike(a.entityName, CONCAT('%', :textSearch, '%')) = true " +
"OR ilike(a.actionType, CONCAT('%', :textSearch, '%')) = true " + "OR ilike(a.actionType, CONCAT('%', :textSearch, '%')) = true " +

10
dao/src/main/java/org/thingsboard/server/dao/sqlts/CachedRedisSqlTimeseriesLatestDao.java

@ -172,4 +172,14 @@ public class CachedRedisSqlTimeseriesLatestDao extends BaseAbstractSqlTimeseries
return sqlDao.findAllKeysByEntityIdsAsync(tenantId, entityIds); return sqlDao.findAllKeysByEntityIdsAsync(tenantId, entityIds);
} }
@Override
public List<TsKvEntry> findLatestByEntityIds(TenantId tenantId, List<EntityId> entityIds) {
return sqlDao.findLatestByEntityIds(tenantId, entityIds);
}
@Override
public ListenableFuture<List<TsKvEntry>> findLatestByEntityIdsAsync(TenantId tenantId, List<EntityId> entityIds) {
return sqlDao.findLatestByEntityIdsAsync(tenantId, entityIds);
}
} }

17
dao/src/main/java/org/thingsboard/server/dao/sqlts/SqlTimeseriesLatestDao.java

@ -22,6 +22,7 @@ import com.google.common.util.concurrent.MoreExecutors;
import jakarta.annotation.PostConstruct; import jakarta.annotation.PostConstruct;
import jakarta.annotation.PreDestroy; import jakarta.annotation.PreDestroy;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.apache.commons.collections4.CollectionUtils;
import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value; import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component; import org.springframework.stereotype.Component;
@ -54,6 +55,7 @@ import org.thingsboard.server.dao.timeseries.TimeseriesLatestDao;
import org.thingsboard.server.dao.util.SqlTsLatestAnyDao; import org.thingsboard.server.dao.util.SqlTsLatestAnyDao;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.Collections;
import java.util.Comparator; import java.util.Comparator;
import java.util.HashMap; import java.util.HashMap;
import java.util.List; import java.util.List;
@ -189,6 +191,21 @@ public class SqlTimeseriesLatestDao extends BaseAbstractSqlTimeseriesDao impleme
return service.submit(() -> findAllKeysByEntityIds(tenantId, entityIds)); return service.submit(() -> findAllKeysByEntityIds(tenantId, entityIds));
} }
@Override
public List<TsKvEntry> findLatestByEntityIds(TenantId tenantId, List<EntityId> entityIds) {
if (CollectionUtils.isEmpty(entityIds)) {
return Collections.emptyList();
}
return DaoUtil.convertDataList(
searchTsKvLatestRepository.findLatestByEntityIds(entityIds.stream().map(EntityId::getId).toList())
);
}
@Override
public ListenableFuture<List<TsKvEntry>> findLatestByEntityIdsAsync(TenantId tenantId, List<EntityId> entityIds) {
return service.submit(() -> findLatestByEntityIds(tenantId, entityIds));
}
private ListenableFuture<TsKvLatestRemovingResult> getNewLatestEntryFuture(TenantId tenantId, EntityId entityId, DeleteTsKvQuery query, Long version) { private ListenableFuture<TsKvLatestRemovingResult> getNewLatestEntryFuture(TenantId tenantId, EntityId entityId, DeleteTsKvQuery query, Long version) {
ListenableFuture<List<TsKvEntry>> future = findNewLatestEntryFuture(tenantId, entityId, query); ListenableFuture<List<TsKvEntry>> future = findNewLatestEntryFuture(tenantId, entityId, query);
return Futures.transformAsync(future, entryList -> { return Futures.transformAsync(future, entryList -> {

20
dao/src/main/java/org/thingsboard/server/dao/sqlts/latest/SearchTsKvLatestRepository.java

@ -34,6 +34,20 @@ public class SearchTsKvLatestRepository {
" ts_kv_latest.bool_v AS boolValue, ts_kv_latest.long_v AS longValue, ts_kv_latest.dbl_v AS doubleValue, ts_kv_latest.json_v AS jsonValue, ts_kv_latest.ts AS ts, ts_kv_latest.version AS version FROM ts_kv_latest " + " ts_kv_latest.bool_v AS boolValue, ts_kv_latest.long_v AS longValue, ts_kv_latest.dbl_v AS doubleValue, ts_kv_latest.json_v AS jsonValue, ts_kv_latest.ts AS ts, ts_kv_latest.version AS version FROM ts_kv_latest " +
"INNER JOIN key_dictionary ON ts_kv_latest.key = key_dictionary.key_id WHERE ts_kv_latest.entity_id = cast(:id AS uuid)"; "INNER JOIN key_dictionary ON ts_kv_latest.key = key_dictionary.key_id WHERE ts_kv_latest.entity_id = cast(:id AS uuid)";
public static final String FIND_LATEST_BY_ENTITY_IDS = "findLatestByEntityIds";
public static final String FIND_LATEST_BY_ENTITY_IDS_QUERY = """
SELECT DISTINCT ON (ts_kv_latest.key)
ts_kv_latest.entity_id AS entityId, ts_kv_latest.key AS key,
key_dictionary.key AS strKey, ts_kv_latest.str_v AS strValue,
ts_kv_latest.bool_v AS boolValue, ts_kv_latest.long_v AS longValue,
ts_kv_latest.dbl_v AS doubleValue, ts_kv_latest.json_v AS jsonValue,
ts_kv_latest.ts AS ts, ts_kv_latest.version AS version
FROM ts_kv_latest
INNER JOIN key_dictionary ON ts_kv_latest.key = key_dictionary.key_id
WHERE ts_kv_latest.entity_id IN (:entityIds)
ORDER BY ts_kv_latest.key, ts_kv_latest.ts DESC""";
@PersistenceContext @PersistenceContext
private EntityManager entityManager; private EntityManager entityManager;
@ -43,4 +57,10 @@ public class SearchTsKvLatestRepository {
.getResultList(); .getResultList();
} }
public List<TsKvLatestEntity> findLatestByEntityIds(List<UUID> entityIds) {
return entityManager.createNamedQuery(FIND_LATEST_BY_ENTITY_IDS, TsKvLatestEntity.class)
.setParameter("entityIds", entityIds)
.getResultList();
}
} }

13
dao/src/main/java/org/thingsboard/server/dao/timeseries/BaseTimeseriesService.java

@ -55,9 +55,6 @@ import java.util.stream.Collectors;
import static org.thingsboard.server.common.data.StringUtils.isBlank; import static org.thingsboard.server.common.data.StringUtils.isBlank;
/**
* @author Andrew Shvayka
*/
@Service @Service
@Slf4j @Slf4j
public class BaseTimeseriesService implements TimeseriesService { public class BaseTimeseriesService implements TimeseriesService {
@ -161,6 +158,16 @@ public class BaseTimeseriesService implements TimeseriesService {
return timeseriesLatestDao.findAllKeysByEntityIdsAsync(tenantId, entityIds); return timeseriesLatestDao.findAllKeysByEntityIdsAsync(tenantId, entityIds);
} }
@Override
public List<TsKvEntry> findLatestByEntityIds(TenantId tenantId, List<EntityId> entityIds) {
return timeseriesLatestDao.findLatestByEntityIds(tenantId, entityIds);
}
@Override
public ListenableFuture<List<TsKvEntry>> findLatestByEntityIdsAsync(TenantId tenantId, List<EntityId> entityIds) {
return timeseriesLatestDao.findLatestByEntityIdsAsync(tenantId, entityIds);
}
@Override @Override
public void cleanup(long systemTtl) { public void cleanup(long systemTtl) {
timeseriesDao.cleanup(systemTtl); timeseriesDao.cleanup(systemTtl);

92
dao/src/main/java/org/thingsboard/server/dao/timeseries/CassandraBaseTimeseriesDao.java

@ -52,6 +52,7 @@ import org.thingsboard.server.common.data.kv.TsKvEntry;
import org.thingsboard.server.common.data.kv.TsKvEntryAggWrapper; import org.thingsboard.server.common.data.kv.TsKvEntryAggWrapper;
import org.thingsboard.server.common.data.kv.TsKvQuery; import org.thingsboard.server.common.data.kv.TsKvQuery;
import org.thingsboard.server.dao.model.ModelConstants; import org.thingsboard.server.dao.model.ModelConstants;
import org.thingsboard.server.dao.nosql.ResultSetSizeLimitExceededException;
import org.thingsboard.server.dao.nosql.TbResultSet; import org.thingsboard.server.dao.nosql.TbResultSet;
import org.thingsboard.server.dao.nosql.TbResultSetFuture; import org.thingsboard.server.dao.nosql.TbResultSetFuture;
import org.thingsboard.server.dao.sqlts.AggregationTimeseriesDao; import org.thingsboard.server.dao.sqlts.AggregationTimeseriesDao;
@ -256,7 +257,8 @@ public class CassandraBaseTimeseriesDao extends AbstractCassandraBaseTimeseriesD
@Override @Override
public void onFailure(Throwable t) { public void onFailure(Throwable t) {
log.error("[{}][{}] Failed to fetch partitions for interval {}-{}", entityId.getEntityType().name(), entityId.getId(), minPartition, maxPartition, t); log.error("[{}][{}][{}] Failed to fetch partitions for interval {}-{}", tenantId, entityId.getEntityType(), entityId.getId(), minPartition, maxPartition, t);
resultFuture.setException(t);
} }
}, readResultsProcessingExecutor); }, readResultsProcessingExecutor);
return resultFuture; return resultFuture;
@ -330,7 +332,8 @@ public class CassandraBaseTimeseriesDao extends AbstractCassandraBaseTimeseriesD
@Override @Override
public void onFailure(Throwable t) { public void onFailure(Throwable t) {
log.error("[{}][{}] Failed to fetch partitions for interval {}-{}", entityId.getEntityType().name(), entityId.getId(), toPartitionTs(query.getStartTs()), toPartitionTs(query.getEndTs()), t); log.error("[{}][{}][{}] Failed to fetch partitions for interval {}-{}", tenantId, entityId.getEntityType(), entityId.getId(), toPartitionTs(query.getStartTs()), toPartitionTs(query.getEndTs()), t);
resultFuture.setException(t);
} }
}, readResultsProcessingExecutor); }, readResultsProcessingExecutor);
@ -372,8 +375,7 @@ public class CassandraBaseTimeseriesDao extends AbstractCassandraBaseTimeseriesD
cursor.addData(convertResultToTsKvEntryList(Collections.emptyList())); cursor.addData(convertResultToTsKvEntryList(Collections.emptyList()));
findAllAsyncSequentiallyWithLimit(tenantId, cursor, resultFuture); findAllAsyncSequentiallyWithLimit(tenantId, cursor, resultFuture);
} else { } else {
Futures.addCallback(result.allRows(readResultsProcessingExecutor), new FutureCallback<List<Row>>() { Futures.addCallback(result.allRows(readResultsProcessingExecutor, maxResultSetSizeBytes), new FutureCallback<List<Row>>() {
@Override @Override
public void onSuccess(@Nullable List<Row> result) { public void onSuccess(@Nullable List<Row> result) {
cursor.addData(convertResultToTsKvEntryList(result == null ? Collections.emptyList() : result)); cursor.addData(convertResultToTsKvEntryList(result == null ? Collections.emptyList() : result));
@ -382,7 +384,13 @@ public class CassandraBaseTimeseriesDao extends AbstractCassandraBaseTimeseriesD
@Override @Override
public void onFailure(Throwable t) { public void onFailure(Throwable t) {
log.error("[{}][{}] Failed to fetch data for query {}-{}", stmt, t); if (t instanceof ResultSetSizeLimitExceededException e) {
log.warn("[{}][{}][{}] Result set size limit exceeded for key [{}], query [{}]: {} bytes, limit {} bytes",
tenantId, cursor.getEntityType(), cursor.getEntityId(), cursor.getKey(), stmt.getPreparedStatement().getQuery(), e.getActualBytes(), e.getLimitBytes());
} else {
log.error("[{}][{}][{}] Failed to fetch data for key [{}], query [{}]", tenantId, cursor.getEntityType(), cursor.getEntityId(), cursor.getKey(), stmt.getPreparedStatement().getQuery(), t);
}
resultFuture.setException(t);
} }
}, readResultsProcessingExecutor); }, readResultsProcessingExecutor);
@ -392,7 +400,8 @@ public class CassandraBaseTimeseriesDao extends AbstractCassandraBaseTimeseriesD
@Override @Override
public void onFailure(Throwable t) { public void onFailure(Throwable t) {
log.error("[{}][{}] Failed to fetch data for query {}-{}", stmt, t); log.error("[{}][{}][{}] Failed to fetch data for key [{}], query [{}]", tenantId, cursor.getEntityType(), cursor.getEntityId(), cursor.getKey(), stmt.getPreparedStatement().getQuery(), t);
resultFuture.setException(t);
} }
}, readResultsProcessingExecutor); }, readResultsProcessingExecutor);
} }
@ -425,7 +434,7 @@ public class CassandraBaseTimeseriesDao extends AbstractCassandraBaseTimeseriesD
} }
if (!isUseTsKeyValuePartitioningOnRead()) { if (!isUseTsKeyValuePartitioningOnRead()) {
final long estimatedPartitionCount = estimatePartitionCount(minPartition, maxPartition); final long estimatedPartitionCount = estimatePartitionCount(minPartition, maxPartition);
if (estimatedPartitionCount <= useTsKeyValuePartitioningOnReadMaxEstimatedPartitionCount) { if (estimatedPartitionCount <= useTsKeyValuePartitioningOnReadMaxEstimatedPartitionCount) {
return Futures.immediateFuture(calculatePartitions(minPartition, maxPartition, (int) estimatedPartitionCount)); return Futures.immediateFuture(calculatePartitions(minPartition, maxPartition, (int) estimatedPartitionCount));
} }
} }
@ -446,7 +455,7 @@ public class CassandraBaseTimeseriesDao extends AbstractCassandraBaseTimeseriesD
} }
List<Long> calculatePartitions(long minPartition, long maxPartition) { List<Long> calculatePartitions(long minPartition, long maxPartition) {
return calculatePartitions(minPartition, maxPartition, 0); return calculatePartitions(minPartition, maxPartition, 0);
} }
List<Long> calculatePartitions(long minPartition, long maxPartition, int estimatedPartitionCount) { List<Long> calculatePartitions(long minPartition, long maxPartition, int estimatedPartitionCount) {
@ -533,6 +542,7 @@ public class CassandraBaseTimeseriesDao extends AbstractCassandraBaseTimeseriesD
public void onFailure(Throwable t) { public void onFailure(Throwable t) {
} }
} }
private long computeTtl(long ttl) { private long computeTtl(long ttl) {
@ -569,7 +579,8 @@ public class CassandraBaseTimeseriesDao extends AbstractCassandraBaseTimeseriesD
@Override @Override
public void onFailure(Throwable t) { public void onFailure(Throwable t) {
log.error("[{}][{}] Failed to delete data for query {}-{}", stmt, t); log.error("[{}][{}][{}] Failed to delete data for key [{}], query [{}]", tenantId, cursor.getEntityType(), cursor.getEntityId(), cursor.getKey(), stmt.getPreparedStatement().getQuery(), t);
resultFuture.setException(t);
} }
}, readResultsProcessingExecutor); }, readResultsProcessingExecutor);
} }
@ -581,12 +592,12 @@ public class CassandraBaseTimeseriesDao extends AbstractCassandraBaseTimeseriesD
try { try {
if (deleteStmt == null) { if (deleteStmt == null) {
deleteStmt = prepare("DELETE FROM " + ModelConstants.TS_KV_CF + deleteStmt = prepare("DELETE FROM " + ModelConstants.TS_KV_CF +
" WHERE " + ModelConstants.ENTITY_TYPE_COLUMN + EQUALS_PARAM " WHERE " + ModelConstants.ENTITY_TYPE_COLUMN + EQUALS_PARAM
+ "AND " + ModelConstants.ENTITY_ID_COLUMN + EQUALS_PARAM + "AND " + ModelConstants.ENTITY_ID_COLUMN + EQUALS_PARAM
+ "AND " + ModelConstants.KEY_COLUMN + EQUALS_PARAM + "AND " + ModelConstants.KEY_COLUMN + EQUALS_PARAM
+ "AND " + ModelConstants.PARTITION_COLUMN + EQUALS_PARAM + "AND " + ModelConstants.PARTITION_COLUMN + EQUALS_PARAM
+ "AND " + ModelConstants.TS_COLUMN + " >= ? " + "AND " + ModelConstants.TS_COLUMN + " >= ? "
+ "AND " + ModelConstants.TS_COLUMN + " < ?"); + "AND " + ModelConstants.TS_COLUMN + " < ?");
} }
} finally { } finally {
stmtCreationLock.unlock(); stmtCreationLock.unlock();
@ -661,13 +672,13 @@ public class CassandraBaseTimeseriesDao extends AbstractCassandraBaseTimeseriesD
private String getPreparedStatementQuery(DataType type) { private String getPreparedStatementQuery(DataType type) {
return INSERT_INTO + ModelConstants.TS_KV_CF + return INSERT_INTO + ModelConstants.TS_KV_CF +
"(" + ModelConstants.ENTITY_TYPE_COLUMN + "(" + ModelConstants.ENTITY_TYPE_COLUMN +
"," + ModelConstants.ENTITY_ID_COLUMN + "," + ModelConstants.ENTITY_ID_COLUMN +
"," + ModelConstants.KEY_COLUMN + "," + ModelConstants.KEY_COLUMN +
"," + ModelConstants.PARTITION_COLUMN + "," + ModelConstants.PARTITION_COLUMN +
"," + ModelConstants.TS_COLUMN + "," + ModelConstants.TS_COLUMN +
"," + getColumnName(type) + ")" + "," + getColumnName(type) + ")" +
" VALUES(?, ?, ?, ?, ?, ?)"; " VALUES(?, ?, ?, ?, ?, ?)";
} }
private String getPreparedStatementQueryWithTtl(DataType type) { private String getPreparedStatementQueryWithTtl(DataType type) {
@ -680,11 +691,11 @@ public class CassandraBaseTimeseriesDao extends AbstractCassandraBaseTimeseriesD
try { try {
if (partitionInsertStmt == null) { if (partitionInsertStmt == null) {
partitionInsertStmt = prepare(INSERT_INTO + ModelConstants.TS_KV_PARTITIONS_CF + partitionInsertStmt = prepare(INSERT_INTO + ModelConstants.TS_KV_PARTITIONS_CF +
"(" + ModelConstants.ENTITY_TYPE_COLUMN + "(" + ModelConstants.ENTITY_TYPE_COLUMN +
"," + ModelConstants.ENTITY_ID_COLUMN + "," + ModelConstants.ENTITY_ID_COLUMN +
"," + ModelConstants.PARTITION_COLUMN + "," + ModelConstants.PARTITION_COLUMN +
"," + ModelConstants.KEY_COLUMN + ")" + "," + ModelConstants.KEY_COLUMN + ")" +
" VALUES(?, ?, ?, ?)"); " VALUES(?, ?, ?, ?)");
} }
} finally { } finally {
stmtCreationLock.unlock(); stmtCreationLock.unlock();
@ -699,11 +710,11 @@ public class CassandraBaseTimeseriesDao extends AbstractCassandraBaseTimeseriesD
try { try {
if (partitionInsertTtlStmt == null) { if (partitionInsertTtlStmt == null) {
partitionInsertTtlStmt = prepare(INSERT_INTO + ModelConstants.TS_KV_PARTITIONS_CF + partitionInsertTtlStmt = prepare(INSERT_INTO + ModelConstants.TS_KV_PARTITIONS_CF +
"(" + ModelConstants.ENTITY_TYPE_COLUMN + "(" + ModelConstants.ENTITY_TYPE_COLUMN +
"," + ModelConstants.ENTITY_ID_COLUMN + "," + ModelConstants.ENTITY_ID_COLUMN +
"," + ModelConstants.PARTITION_COLUMN + "," + ModelConstants.PARTITION_COLUMN +
"," + ModelConstants.KEY_COLUMN + ")" + "," + ModelConstants.KEY_COLUMN + ")" +
" VALUES(?, ?, ?, ?) USING TTL ?"); " VALUES(?, ?, ?, ?) USING TTL ?");
} }
} finally { } finally {
stmtCreationLock.unlock(); stmtCreationLock.unlock();
@ -809,16 +820,17 @@ public class CassandraBaseTimeseriesDao extends AbstractCassandraBaseTimeseriesD
fetchStmts[type.ordinal()] = fetchStmts[Aggregation.SUM.ordinal()]; fetchStmts[type.ordinal()] = fetchStmts[Aggregation.SUM.ordinal()];
} else { } else {
fetchStmts[type.ordinal()] = prepare(SELECT_PREFIX + fetchStmts[type.ordinal()] = prepare(SELECT_PREFIX +
String.join(", ", ModelConstants.getFetchColumnNames(type)) + " FROM " + ModelConstants.TS_KV_CF String.join(", ", ModelConstants.getFetchColumnNames(type)) + " FROM " + ModelConstants.TS_KV_CF
+ " WHERE " + ModelConstants.ENTITY_TYPE_COLUMN + EQUALS_PARAM + " WHERE " + ModelConstants.ENTITY_TYPE_COLUMN + EQUALS_PARAM
+ "AND " + ModelConstants.ENTITY_ID_COLUMN + EQUALS_PARAM + "AND " + ModelConstants.ENTITY_ID_COLUMN + EQUALS_PARAM
+ "AND " + ModelConstants.KEY_COLUMN + EQUALS_PARAM + "AND " + ModelConstants.KEY_COLUMN + EQUALS_PARAM
+ "AND " + ModelConstants.PARTITION_COLUMN + EQUALS_PARAM + "AND " + ModelConstants.PARTITION_COLUMN + EQUALS_PARAM
+ "AND " + ModelConstants.TS_COLUMN + " >= ? " + "AND " + ModelConstants.TS_COLUMN + " >= ? "
+ "AND " + ModelConstants.TS_COLUMN + " < ?" + "AND " + ModelConstants.TS_COLUMN + " < ?"
+ (type == Aggregation.NONE ? " ORDER BY " + ModelConstants.TS_COLUMN + " " + orderBy + " LIMIT ?" : "")); + (type == Aggregation.NONE ? " ORDER BY " + ModelConstants.TS_COLUMN + " " + orderBy + " LIMIT ?" : ""));
} }
} }
return fetchStmts; return fetchStmts;
} }
} }

12
dao/src/main/java/org/thingsboard/server/dao/timeseries/CassandraBaseTimeseriesLatestDao.java

@ -104,6 +104,16 @@ public class CassandraBaseTimeseriesLatestDao extends AbstractCassandraBaseTimes
return Futures.immediateFuture(Collections.emptyList()); return Futures.immediateFuture(Collections.emptyList());
} }
@Override
public List<TsKvEntry> findLatestByEntityIds(TenantId tenantId, List<EntityId> entityIds) {
return Collections.emptyList();
}
@Override
public ListenableFuture<List<TsKvEntry>> findLatestByEntityIdsAsync(TenantId tenantId, List<EntityId> entityIds) {
return Futures.immediateFuture(Collections.emptyList());
}
@Override @Override
public ListenableFuture<Long> saveLatest(TenantId tenantId, EntityId entityId, TsKvEntry tsKvEntry) { public ListenableFuture<Long> saveLatest(TenantId tenantId, EntityId entityId, TsKvEntry tsKvEntry) {
BoundStatementBuilder stmtBuilder = new BoundStatementBuilder(getLatestStmt().bind()); BoundStatementBuilder stmtBuilder = new BoundStatementBuilder(getLatestStmt().bind());
@ -184,7 +194,7 @@ public class CassandraBaseTimeseriesLatestDao extends AbstractCassandraBaseTimes
} }
private ListenableFuture<List<TsKvEntry>> convertAsyncResultSetToTsKvEntryList(TbResultSet rs) { private ListenableFuture<List<TsKvEntry>> convertAsyncResultSetToTsKvEntryList(TbResultSet rs) {
return Futures.transform(rs.allRows(readResultsProcessingExecutor), return Futures.transform(rs.allRows(readResultsProcessingExecutor, maxResultSetSizeBytes),
rows -> this.convertResultToTsKvEntryList(rows), readResultsProcessingExecutor); rows -> this.convertResultToTsKvEntryList(rows), readResultsProcessingExecutor);
} }

4
dao/src/main/java/org/thingsboard/server/dao/timeseries/SimpleListenableFuture.java

@ -26,4 +26,8 @@ public class SimpleListenableFuture<V> extends AbstractFuture<V> {
return super.set(value); return super.set(value);
} }
public boolean setException(Throwable throwable) {
return super.setException(throwable);
}
} }

9
dao/src/main/java/org/thingsboard/server/dao/timeseries/TimeseriesLatestDao.java

@ -52,4 +52,13 @@ public interface TimeseriesLatestDao {
ListenableFuture<List<String>> findAllKeysByEntityIdsAsync(TenantId tenantId, List<EntityId> entityIds); ListenableFuture<List<String>> findAllKeysByEntityIdsAsync(TenantId tenantId, List<EntityId> entityIds);
/**
* For each unique timeseries key across the given entities, returns the single most recent {@link TsKvEntry}
* (i.e. the entry with the highest timestamp). If the same key exists on multiple entities,
* only the freshest value is kept. Useful for discovering available keys together with a representative sample value.
*/
List<TsKvEntry> findLatestByEntityIds(TenantId tenantId, List<EntityId> entityIds);
ListenableFuture<List<TsKvEntry>> findLatestByEntityIdsAsync(TenantId tenantId, List<EntityId> entityIds);
} }

103
dao/src/test/java/org/thingsboard/server/dao/service/attributes/BaseAttributesServiceTest.java

@ -23,7 +23,6 @@ import com.google.common.util.concurrent.MoreExecutors;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.awaitility.Awaitility; import org.awaitility.Awaitility;
import org.junit.Assert; import org.junit.Assert;
import org.junit.Before;
import org.junit.Test; import org.junit.Test;
import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Autowired;
import org.thingsboard.server.common.data.AttributeScope; import org.thingsboard.server.common.data.AttributeScope;
@ -31,8 +30,12 @@ import org.thingsboard.server.common.data.id.DeviceId;
import org.thingsboard.server.common.data.id.TenantId; import org.thingsboard.server.common.data.id.TenantId;
import org.thingsboard.server.common.data.kv.AttributeKvEntry; import org.thingsboard.server.common.data.kv.AttributeKvEntry;
import org.thingsboard.server.common.data.kv.BaseAttributeKvEntry; import org.thingsboard.server.common.data.kv.BaseAttributeKvEntry;
import org.thingsboard.server.common.data.kv.BooleanDataEntry;
import org.thingsboard.server.common.data.kv.DoubleDataEntry;
import org.thingsboard.server.common.data.kv.KvEntry; import org.thingsboard.server.common.data.kv.KvEntry;
import org.thingsboard.server.common.data.kv.LongDataEntry;
import org.thingsboard.server.common.data.kv.StringDataEntry; import org.thingsboard.server.common.data.kv.StringDataEntry;
import org.thingsboard.server.dao.attributes.AttributesDao;
import org.thingsboard.server.dao.attributes.AttributesService; import org.thingsboard.server.dao.attributes.AttributesService;
import org.thingsboard.server.dao.service.AbstractServiceTest; import org.thingsboard.server.dao.service.AbstractServiceTest;
@ -40,6 +43,7 @@ import java.util.ArrayList;
import java.util.Arrays; import java.util.Arrays;
import java.util.Collections; import java.util.Collections;
import java.util.List; import java.util.List;
import java.util.Map;
import java.util.Optional; import java.util.Optional;
import java.util.UUID; import java.util.UUID;
import java.util.concurrent.Executors; import java.util.concurrent.Executors;
@ -48,9 +52,6 @@ import java.util.stream.Collectors;
import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThat;
/**
* @author Andrew Shvayka
*/
@Slf4j @Slf4j
public abstract class BaseAttributesServiceTest extends AbstractServiceTest { public abstract class BaseAttributesServiceTest extends AbstractServiceTest {
@ -60,9 +61,8 @@ public abstract class BaseAttributesServiceTest extends AbstractServiceTest {
@Autowired @Autowired
private AttributesService attributesService; private AttributesService attributesService;
@Before @Autowired
public void before() { private AttributesDao attributesDao;
}
@Test @Test
public void saveAndFetch() throws Exception { public void saveAndFetch() throws Exception {
@ -223,7 +223,7 @@ public abstract class BaseAttributesServiceTest extends AbstractServiceTest {
saveAttribute(tenantId, deviceId, AttributeScope.SERVER_SCOPE, "key2", "123"); saveAttribute(tenantId, deviceId, AttributeScope.SERVER_SCOPE, "key2", "123");
Awaitility.await().atMost(30, TimeUnit.SECONDS).untilAsserted(() -> { Awaitility.await().atMost(30, TimeUnit.SECONDS).untilAsserted(() -> {
List<String> keys = attributesService.findAllKeysByEntityIds(tenantId, List.of(deviceId), AttributeScope.SERVER_SCOPE); List<String> keys = attributesService.findAllKeysByEntityIdsAndScope(tenantId, List.of(deviceId), AttributeScope.SERVER_SCOPE);
assertThat(keys).containsOnly("key1", "key2"); assertThat(keys).containsOnly("key1", "key2");
}); });
} }
@ -241,6 +241,84 @@ public abstract class BaseAttributesServiceTest extends AbstractServiceTest {
}); });
} }
@Test
public void findLatestByEntityIdsAndScope_returnsOneEntryPerKey() {
var device1 = new DeviceId(UUID.randomUUID());
var device2 = new DeviceId(UUID.randomUUID());
// Both devices have "temperature", device2 has a newer ts
saveAttribute(tenantId, device1, AttributeScope.SERVER_SCOPE, 1000, new DoubleDataEntry("temperature", 20.0));
saveAttribute(tenantId, device2, AttributeScope.SERVER_SCOPE, 2000, new DoubleDataEntry("temperature", 25.0));
// Only device1 has "humidity"
saveAttribute(tenantId, device1, AttributeScope.SERVER_SCOPE, 1500, new LongDataEntry("humidity", 60L));
// Only device2 has "active"
saveAttribute(tenantId, device2, AttributeScope.SERVER_SCOPE, 3000, new BooleanDataEntry("active", true));
List<AttributeKvEntry> results = attributesDao.findLatestByEntityIdsAndScope(tenantId, List.of(device1, device2), AttributeScope.SERVER_SCOPE);
Map<String, AttributeKvEntry> byKey = results.stream().collect(Collectors.toMap(AttributeKvEntry::getKey, e -> e));
Assert.assertEquals(3, byKey.size());
// "temperature" should pick device2's value (ts=2000 > ts=1000)
AttributeKvEntry temp = byKey.get("temperature");
Assert.assertNotNull(temp);
Assert.assertEquals(25.0, temp.getDoubleValue().orElseThrow(), 0.0);
Assert.assertEquals(2000, temp.getLastUpdateTs());
// "humidity" — only device1 has it
AttributeKvEntry humidity = byKey.get("humidity");
Assert.assertNotNull(humidity);
Assert.assertEquals(60L, (long) humidity.getLongValue().orElseThrow());
Assert.assertEquals(1500, humidity.getLastUpdateTs());
// "active" — only device2 has it
AttributeKvEntry active = byKey.get("active");
Assert.assertNotNull(active);
Assert.assertEquals(true, active.getBooleanValue().orElseThrow());
Assert.assertEquals(3000, active.getLastUpdateTs());
}
@Test
public void findLatestByEntityIdsAndScope_emptyList() {
List<AttributeKvEntry> results = attributesDao.findLatestByEntityIdsAndScope(tenantId, List.of(), AttributeScope.SERVER_SCOPE);
Assert.assertTrue(results.isEmpty());
}
@Test
public void findLatestByEntityIdsAndScope_singleEntity() throws Exception {
var device = new DeviceId(UUID.randomUUID());
saveAttribute(tenantId, device, AttributeScope.SERVER_SCOPE, 1000, new StringDataEntry("key1", "value1"));
saveAttribute(tenantId, device, AttributeScope.SERVER_SCOPE, 2000, new StringDataEntry("key2", "value2"));
// sync
List<AttributeKvEntry> results = attributesDao.findLatestByEntityIdsAndScope(tenantId, List.of(device), AttributeScope.SERVER_SCOPE);
Assert.assertEquals(2, results.size());
Map<String, AttributeKvEntry> byKey = results.stream().collect(Collectors.toMap(AttributeKvEntry::getKey, e -> e));
Assert.assertEquals("value1", byKey.get("key1").getStrValue().orElseThrow());
Assert.assertEquals(1000, byKey.get("key1").getLastUpdateTs());
Assert.assertEquals("value2", byKey.get("key2").getStrValue().orElseThrow());
Assert.assertEquals(2000, byKey.get("key2").getLastUpdateTs());
// async — same result
List<AttributeKvEntry> asyncResults = attributesDao.findLatestByEntityIdsAndScopeAsync(tenantId, List.of(device), AttributeScope.SERVER_SCOPE).get();
Assert.assertEquals(results, asyncResults);
}
@Test
public void findLatestByEntityIdsAndScope_filtersScope() {
var device = new DeviceId(UUID.randomUUID());
saveAttribute(tenantId, device, AttributeScope.SERVER_SCOPE, 1000, new StringDataEntry("serverKey", "sv"));
saveAttribute(tenantId, device, AttributeScope.CLIENT_SCOPE, 1000, new StringDataEntry("clientKey", "cv"));
List<AttributeKvEntry> serverResults = attributesDao.findLatestByEntityIdsAndScope(tenantId, List.of(device), AttributeScope.SERVER_SCOPE);
Assert.assertEquals(1, serverResults.size());
Assert.assertEquals("serverKey", serverResults.get(0).getKey());
List<AttributeKvEntry> clientResults = attributesDao.findLatestByEntityIdsAndScope(tenantId, List.of(device), AttributeScope.CLIENT_SCOPE);
Assert.assertEquals(1, clientResults.size());
Assert.assertEquals("clientKey", clientResults.get(0).getKey());
}
private void testConcurrentFetchAndUpdate(TenantId tenantId, DeviceId deviceId, ListeningExecutorService pool) throws Exception { private void testConcurrentFetchAndUpdate(TenantId tenantId, DeviceId deviceId, ListeningExecutorService pool) throws Exception {
var scope = AttributeScope.SERVER_SCOPE; var scope = AttributeScope.SERVER_SCOPE;
var key = "TEST"; var key = "TEST";
@ -313,6 +391,15 @@ public abstract class BaseAttributesServiceTest extends AbstractServiceTest {
} }
} }
private void saveAttribute(TenantId tenantId, DeviceId deviceId, AttributeScope scope, long ts, KvEntry value) {
try {
attributesService.save(tenantId, deviceId, scope, Collections.singletonList(new BaseAttributeKvEntry(ts, value))).get(10, TimeUnit.SECONDS);
} catch (Exception e) {
log.warn("Failed to save attribute", e.getCause());
throw new RuntimeException(e);
}
}
private void equalsIgnoreVersion(AttributeKvEntry expected, AttributeKvEntry actual) { private void equalsIgnoreVersion(AttributeKvEntry expected, AttributeKvEntry actual) {
Assert.assertEquals(expected.getKey(), actual.getKey()); Assert.assertEquals(expected.getKey(), actual.getKey());
Assert.assertEquals(expected.getValue(), actual.getValue()); Assert.assertEquals(expected.getValue(), actual.getValue());

42
dao/src/test/java/org/thingsboard/server/dao/service/timeseries/nosql/TimeseriesServiceNoSqlTest.java

@ -16,7 +16,10 @@
package org.thingsboard.server.dao.service.timeseries.nosql; package org.thingsboard.server.dao.service.timeseries.nosql;
import com.datastax.oss.driver.api.core.uuid.Uuids; import com.datastax.oss.driver.api.core.uuid.Uuids;
import org.apache.commons.lang3.exception.ExceptionUtils;
import org.junit.Test; import org.junit.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.test.util.ReflectionTestUtils;
import org.thingsboard.server.common.data.id.DeviceId; import org.thingsboard.server.common.data.id.DeviceId;
import org.thingsboard.server.common.data.kv.Aggregation; import org.thingsboard.server.common.data.kv.Aggregation;
import org.thingsboard.server.common.data.kv.BaseReadTsKvQuery; import org.thingsboard.server.common.data.kv.BaseReadTsKvQuery;
@ -27,9 +30,12 @@ import org.thingsboard.server.common.data.kv.JsonDataEntry;
import org.thingsboard.server.common.data.kv.LongDataEntry; import org.thingsboard.server.common.data.kv.LongDataEntry;
import org.thingsboard.server.common.data.kv.StringDataEntry; import org.thingsboard.server.common.data.kv.StringDataEntry;
import org.thingsboard.server.common.data.kv.TsKvEntry; import org.thingsboard.server.common.data.kv.TsKvEntry;
import org.thingsboard.server.dao.nosql.ResultSetSizeLimitExceededException;
import org.thingsboard.server.dao.service.DaoNoSqlTest; import org.thingsboard.server.dao.service.DaoNoSqlTest;
import org.thingsboard.server.dao.service.timeseries.BaseTimeseriesServiceTest; import org.thingsboard.server.dao.service.timeseries.BaseTimeseriesServiceTest;
import org.thingsboard.server.dao.timeseries.CassandraBaseTimeseriesDao;
import java.util.ArrayList;
import java.util.Collections; import java.util.Collections;
import java.util.List; import java.util.List;
import java.util.concurrent.ExecutionException; import java.util.concurrent.ExecutionException;
@ -37,6 +43,7 @@ import java.util.concurrent.TimeUnit;
import java.util.concurrent.TimeoutException; import java.util.concurrent.TimeoutException;
import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertTrue; import static org.junit.Assert.assertTrue;
@ -44,6 +51,9 @@ import static org.junit.Assert.assertTrue;
@DaoNoSqlTest @DaoNoSqlTest
public class TimeseriesServiceNoSqlTest extends BaseTimeseriesServiceTest { public class TimeseriesServiceNoSqlTest extends BaseTimeseriesServiceTest {
@Autowired
private CassandraBaseTimeseriesDao cassandraBaseTimeseriesDao;
@Test @Test
public void shouldSaveEntryOfEachTypeWithTtl() throws ExecutionException, InterruptedException, TimeoutException { public void shouldSaveEntryOfEachTypeWithTtl() throws ExecutionException, InterruptedException, TimeoutException {
long ttlInSec = TimeUnit.SECONDS.toSeconds(3); long ttlInSec = TimeUnit.SECONDS.toSeconds(3);
@ -94,4 +104,36 @@ public class TimeseriesServiceNoSqlTest extends BaseTimeseriesServiceTest {
double expectedValue = (doubleValue + longValue)/ 2; double expectedValue = (doubleValue + longValue)/ 2;
assertThat(listWithAgg.get(0).getDoubleValue().get()).isEqualTo(expectedValue); assertThat(listWithAgg.get(0).getDoubleValue().get()).isEqualTo(expectedValue);
} }
@Test
public void testResultSetSizeLimitExceeded() throws Exception {
DeviceId deviceId = new DeviceId(Uuids.timeBased());
String value = "x".repeat(500);
List<TsKvEntry> entries = new ArrayList<>();
for (int i = 0; i < 5; i++) {
entries.add(new BasicTsKvEntry(TimeUnit.MINUTES.toMillis(i + 1), new StringDataEntry("bigKey", value)));
}
tsService.save(tenantId, deviceId, entries, 0).get(MAX_TIMEOUT, TimeUnit.SECONDS);
long originalLimit = (long) ReflectionTestUtils.getField(cassandraBaseTimeseriesDao, "maxResultSetSizeBytes");
try {
// Set a very low limit to trigger the exception
ReflectionTestUtils.setField(cassandraBaseTimeseriesDao, "maxResultSetSizeBytes", 1024L);
assertThatThrownBy(() -> tsService.findAll(tenantId, deviceId, Collections.singletonList(
new BaseReadTsKvQuery("bigKey", 0L, TimeUnit.MINUTES.toMillis(6), 1000, 10, Aggregation.NONE)
)).get(MAX_TIMEOUT, TimeUnit.SECONDS))
.isInstanceOf(ExecutionException.class)
.satisfies(e -> assertThat(ExceptionUtils.getRootCause(e)).isInstanceOf(ResultSetSizeLimitExceededException.class));
} finally {
ReflectionTestUtils.setField(cassandraBaseTimeseriesDao, "maxResultSetSizeBytes", originalLimit);
}
// Verify query succeeds with original limit restored
List<TsKvEntry> result = tsService.findAll(tenantId, deviceId, Collections.singletonList(
new BaseReadTsKvQuery("bigKey", 0L, TimeUnit.MINUTES.toMillis(6), 1000, 10, Aggregation.NONE)
)).get(MAX_TIMEOUT, TimeUnit.SECONDS);
assertThat(result).hasSize(5);
}
} }

68
dao/src/test/java/org/thingsboard/server/dao/sqlts/SqlTimeseriesLatestDaoTest.java

@ -22,6 +22,9 @@ import org.junit.Test;
import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Autowired;
import org.thingsboard.server.common.data.id.DeviceId; import org.thingsboard.server.common.data.id.DeviceId;
import org.thingsboard.server.common.data.kv.BasicTsKvEntry; import org.thingsboard.server.common.data.kv.BasicTsKvEntry;
import org.thingsboard.server.common.data.kv.BooleanDataEntry;
import org.thingsboard.server.common.data.kv.DoubleDataEntry;
import org.thingsboard.server.common.data.kv.LongDataEntry;
import org.thingsboard.server.common.data.kv.StringDataEntry; import org.thingsboard.server.common.data.kv.StringDataEntry;
import org.thingsboard.server.common.data.kv.TsKvEntry; import org.thingsboard.server.common.data.kv.TsKvEntry;
import org.thingsboard.server.dao.service.AbstractServiceTest; import org.thingsboard.server.dao.service.AbstractServiceTest;
@ -30,7 +33,9 @@ import org.thingsboard.server.dao.timeseries.TimeseriesLatestDao;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.List; import java.util.List;
import java.util.Map;
import java.util.UUID; import java.util.UUID;
import java.util.stream.Collectors;
import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertNotNull;
@ -102,6 +107,69 @@ public class SqlTimeseriesLatestDaoTest extends AbstractServiceTest {
} }
} }
@Test
public void findLatestByEntityIds_returnsOneEntryPerKey() throws Exception {
DeviceId device1 = new DeviceId(UUID.randomUUID());
DeviceId device2 = new DeviceId(UUID.randomUUID());
// Both devices have "temperature" key, device2 has a newer ts
timeseriesLatestDao.saveLatest(tenantId, device1, new BasicTsKvEntry(1000, new DoubleDataEntry("temperature", 20.0))).get();
timeseriesLatestDao.saveLatest(tenantId, device2, new BasicTsKvEntry(2000, new DoubleDataEntry("temperature", 25.0))).get();
// Only device1 has "humidity"
timeseriesLatestDao.saveLatest(tenantId, device1, new BasicTsKvEntry(1500, new LongDataEntry("humidity", 60L))).get();
// Only device2 has "active"
timeseriesLatestDao.saveLatest(tenantId, device2, new BasicTsKvEntry(3000, new BooleanDataEntry("active", true))).get();
List<TsKvEntry> results = timeseriesLatestDao.findLatestByEntityIds(tenantId, List.of(device1, device2));
Map<String, TsKvEntry> byKey = results.stream().collect(Collectors.toMap(TsKvEntry::getKey, e -> e));
assertEquals(3, byKey.size());
// "temperature" should pick device2's value (ts=2000 > ts=1000)
TsKvEntry temp = byKey.get("temperature");
assertNotNull(temp);
assertEquals(25.0, temp.getDoubleValue().orElseThrow());
assertEquals(2000, temp.getTs());
// "humidity" — only device1 has it
TsKvEntry humidity = byKey.get("humidity");
assertNotNull(humidity);
assertEquals(60L, humidity.getLongValue().orElseThrow());
assertEquals(1500, humidity.getTs());
// "active" — only device2 has it
TsKvEntry active = byKey.get("active");
assertNotNull(active);
assertEquals(true, active.getBooleanValue().orElseThrow());
assertEquals(3000, active.getTs());
}
@Test
public void findLatestByEntityIds_emptyList() {
List<TsKvEntry> results = timeseriesLatestDao.findLatestByEntityIds(tenantId, List.of());
assertTrue(results.isEmpty());
}
@Test
public void findLatestByEntityIds_singleEntity() throws Exception {
DeviceId device = new DeviceId(UUID.randomUUID());
timeseriesLatestDao.saveLatest(tenantId, device, new BasicTsKvEntry(1000, new StringDataEntry("key1", "value1"))).get();
timeseriesLatestDao.saveLatest(tenantId, device, new BasicTsKvEntry(2000, new StringDataEntry("key2", "value2"))).get();
// sync
List<TsKvEntry> results = timeseriesLatestDao.findLatestByEntityIds(tenantId, List.of(device));
assertEquals(2, results.size());
Map<String, TsKvEntry> byKey = results.stream().collect(Collectors.toMap(TsKvEntry::getKey, e -> e));
assertEquals("value1", byKey.get("key1").getStrValue().orElseThrow());
assertEquals(1000, byKey.get("key1").getTs());
assertEquals("value2", byKey.get("key2").getStrValue().orElseThrow());
assertEquals(2000, byKey.get("key2").getTs());
// async — same result
List<TsKvEntry> asyncResults = timeseriesLatestDao.findLatestByEntityIdsAsync(tenantId, List.of(device)).get();
assertEquals(results, asyncResults);
}
private TsKvEntry createEntry(String key, long ts) { private TsKvEntry createEntry(String key, long ts) {
return new BasicTsKvEntry(ts, new StringDataEntry(key, RandomStringUtils.random(10))); return new BasicTsKvEntry(ts, new StringDataEntry(key, RandomStringUtils.random(10)));
} }

3
msa/js-executor/package.json

@ -41,6 +41,9 @@
"ts-node": "^10.9.2", "ts-node": "^10.9.2",
"typescript": "5.9.2" "typescript": "5.9.2"
}, },
"resolutions": {
"@yao-pkg/pkg/tar": ">=7.5.8"
},
"pkg": { "pkg": {
"assets": [ "assets": [
"node_modules/config/**/*.*" "node_modules/config/**/*.*"

30
msa/js-executor/yarn.lock

@ -1036,9 +1036,9 @@ mimic-response@^3.1.0:
integrity sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ== integrity sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==
minimatch@^3.1.2: minimatch@^3.1.2:
version "3.1.2" version "3.1.3"
resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-3.1.2.tgz#19cd194bfd3e428f049a70817c038d89ab4be35b" resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-3.1.3.tgz#6a5cba9b31f503887018f579c89f81f61162e624"
integrity sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw== integrity sha512-M2GCs7Vk83NxkUyQV1bkABc4yxgz9kILhHImZiBPAZ9ybuvCb0/H7lEl5XvIg3g+9d4eNotkZA5IWwYl0tibaA==
dependencies: dependencies:
brace-expansion "^1.1.7" brace-expansion "^1.1.7"
@ -1052,10 +1052,10 @@ minipass@^7.0.4, minipass@^7.1.2:
resolved "https://registry.yarnpkg.com/minipass/-/minipass-7.1.2.tgz#93a9626ce5e5e66bd4db86849e7515e92340a707" resolved "https://registry.yarnpkg.com/minipass/-/minipass-7.1.2.tgz#93a9626ce5e5e66bd4db86849e7515e92340a707"
integrity sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw== integrity sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==
minizlib@^3.0.1: minizlib@^3.1.0:
version "3.0.2" version "3.1.0"
resolved "https://registry.yarnpkg.com/minizlib/-/minizlib-3.0.2.tgz#f33d638eb279f664439aa38dc5f91607468cb574" resolved "https://registry.yarnpkg.com/minizlib/-/minizlib-3.1.0.tgz#6ad76c3a8f10227c9b51d1c9ac8e30b27f5a251c"
integrity sha512-oG62iEk+CYt5Xj2YqI5Xi9xWUeZhDI8jjQmC5oThVH5JGCTgIjr7ciJDzC7MBzYd//WvR1OTmP5Q38Q8ShQtVA== integrity sha512-KZxYo1BUkWD2TVFLr0MQoM8vUUigWD3LlD83a/75BqC+4qE0Hb1Vo5v1FgcfaNXvfXzr+5EhQ6ing/CaBijTlw==
dependencies: dependencies:
minipass "^7.1.2" minipass "^7.1.2"
@ -1064,11 +1064,6 @@ mkdirp-classic@^0.5.2, mkdirp-classic@^0.5.3:
resolved "https://registry.yarnpkg.com/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz#fa10c9115cc6d8865be221ba47ee9bed78601113" resolved "https://registry.yarnpkg.com/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz#fa10c9115cc6d8865be221ba47ee9bed78601113"
integrity sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A== integrity sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==
mkdirp@^3.0.1:
version "3.0.1"
resolved "https://registry.yarnpkg.com/mkdirp/-/mkdirp-3.0.1.tgz#e44e4c5607fb279c168241713cc6e0fea9adcb50"
integrity sha512-+NsyUUAZDmo6YVHzL/stxSu3t9YS1iljliy3BSDrXJ/dkn1KYdmtZODGGjLcc9XLgVVpH4KshHB8XmZgMhaBXg==
moment@^2.29.1: moment@^2.29.1:
version "2.30.1" version "2.30.1"
resolved "https://registry.yarnpkg.com/moment/-/moment-2.30.1.tgz#f8c91c07b7a786e30c59926df530b4eac96974ae" resolved "https://registry.yarnpkg.com/moment/-/moment-2.30.1.tgz#f8c91c07b7a786e30c59926df530b4eac96974ae"
@ -1553,16 +1548,15 @@ tar-stream@^2.1.4:
inherits "^2.0.3" inherits "^2.0.3"
readable-stream "^3.1.1" readable-stream "^3.1.1"
tar@^7.4.3: tar@>=7.5.8, tar@^7.4.3:
version "7.4.3" version "7.5.9"
resolved "https://registry.yarnpkg.com/tar/-/tar-7.4.3.tgz#88bbe9286a3fcd900e94592cda7a22b192e80571" resolved "https://registry.yarnpkg.com/tar/-/tar-7.5.9.tgz#817ac12a54bc4362c51340875b8985d7dc9724b8"
integrity sha512-5S7Va8hKfV7W5U6g3aYxXmlPoZVAwUMy9AOKyF2fVuZa2UD3qZjg578OrLRt8PcNN1PleVaL/5/yYATNL0ICUw== integrity sha512-BTLcK0xsDh2+PUe9F6c2TlRp4zOOBMTkoQHQIWSIzI0R7KG46uEwq4OPk2W7bZcprBMsuaeFsqwYr7pjh6CuHg==
dependencies: dependencies:
"@isaacs/fs-minipass" "^4.0.0" "@isaacs/fs-minipass" "^4.0.0"
chownr "^3.0.0" chownr "^3.0.0"
minipass "^7.1.2" minipass "^7.1.2"
minizlib "^3.0.1" minizlib "^3.1.0"
mkdirp "^3.0.1"
yallist "^5.0.0" yallist "^5.0.0"
text-hex@1.0.x: text-hex@1.0.x:

13
msa/tb/docker-cassandra/Dockerfile

@ -14,7 +14,7 @@
# limitations under the License. # limitations under the License.
# #
FROM thingsboard/openjdk17:bookworm-slim FROM thingsboard/openjdk25:trixie-slim
ENV PG_MAJOR=16 ENV PG_MAJOR=16
@ -44,15 +44,14 @@ COPY logback.xml ${pkg.name}.conf start-db.sh stop-db.sh start-tb.sh upgrade-tb.
RUN apt-get update \ RUN apt-get update \
&& apt-get install -y --no-install-recommends wget nmap procps gnupg2 \ && 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 \ && mkdir -p /etc/apt/keyrings \
&& wget -q https://www.postgresql.org/media/keys/ACCC4CF8.asc -O- | apt-key add - \ && wget -q https://www.postgresql.org/media/keys/ACCC4CF8.asc -O- | gpg --dearmor -o /etc/apt/keyrings/postgresql.gpg \
&& echo "deb https://debian.cassandra.apache.org 40x main" | tee -a /etc/apt/sources.list.d/cassandra.sources.list > /dev/null \ && 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- | apt-key add - \ && 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 update \
&& apt-get install -y --no-install-recommends cassandra cassandra-tools postgresql-${PG_MAJOR} \ && apt-get install -y --no-install-recommends cassandra cassandra-tools postgresql-${PG_MAJOR} \
&& rm -rf /var/lib/apt/lists/* \ && rm -rf /var/lib/apt/lists/* \
&& update-rc.d cassandra disable \
&& update-rc.d postgresql disable \
&& apt-get purge -y --auto-remove \ && apt-get purge -y --auto-remove \
&& sed -i.old '/ulimit/d' /etc/init.d/cassandra \ && sed -i.old '/ulimit/d' /etc/init.d/cassandra \
&& chmod a+x /tmp/*.sh \ && chmod a+x /tmp/*.sh \

8
msa/tb/docker-postgres/Dockerfile

@ -14,7 +14,7 @@
# limitations under the License. # limitations under the License.
# #
FROM thingsboard/openjdk17:bookworm-slim FROM thingsboard/openjdk25:trixie-slim
ENV PG_MAJOR 12 ENV PG_MAJOR 12
@ -36,12 +36,12 @@ COPY logback.xml ${pkg.name}.conf start-db.sh stop-db.sh start-tb.sh upgrade-tb.
RUN apt-get update \ RUN apt-get update \
&& apt-get install -y --no-install-recommends wget gnupg2 \ && 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 \ && mkdir -p /etc/apt/keyrings \
&& wget -q https://www.postgresql.org/media/keys/ACCC4CF8.asc -O- | apt-key add - \ && 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 update \
&& apt-get install -y --no-install-recommends postgresql-${PG_MAJOR} \ && apt-get install -y --no-install-recommends postgresql-${PG_MAJOR} \
&& rm -rf /var/lib/apt/lists/* \ && rm -rf /var/lib/apt/lists/* \
&& update-rc.d postgresql disable \
&& apt-get purge -y --auto-remove \ && apt-get purge -y --auto-remove \
&& chmod a+x /tmp/*.sh \ && chmod a+x /tmp/*.sh \
&& mv /tmp/start-tb.sh /usr/bin \ && mv /tmp/start-tb.sh /usr/bin \

3
msa/web-ui/package.json

@ -44,6 +44,9 @@
"ts-node": "^10.9.2", "ts-node": "^10.9.2",
"typescript": "5.9.2" "typescript": "5.9.2"
}, },
"resolutions": {
"@yao-pkg/pkg/tar": ">=7.5.8"
},
"pkg": { "pkg": {
"assets": [ "assets": [
"node_modules/config/**/*.*" "node_modules/config/**/*.*"

30
msa/web-ui/yarn.lock

@ -1098,9 +1098,9 @@ mimic-response@^3.1.0:
integrity sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ== integrity sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==
minimatch@^3.1.2: minimatch@^3.1.2:
version "3.1.2" version "3.1.3"
resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-3.1.2.tgz#19cd194bfd3e428f049a70817c038d89ab4be35b" resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-3.1.3.tgz#6a5cba9b31f503887018f579c89f81f61162e624"
integrity sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw== integrity sha512-M2GCs7Vk83NxkUyQV1bkABc4yxgz9kILhHImZiBPAZ9ybuvCb0/H7lEl5XvIg3g+9d4eNotkZA5IWwYl0tibaA==
dependencies: dependencies:
brace-expansion "^1.1.7" brace-expansion "^1.1.7"
@ -1114,10 +1114,10 @@ minipass@^7.0.4, minipass@^7.1.2:
resolved "https://registry.yarnpkg.com/minipass/-/minipass-7.1.2.tgz#93a9626ce5e5e66bd4db86849e7515e92340a707" resolved "https://registry.yarnpkg.com/minipass/-/minipass-7.1.2.tgz#93a9626ce5e5e66bd4db86849e7515e92340a707"
integrity sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw== integrity sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==
minizlib@^3.0.1: minizlib@^3.1.0:
version "3.0.2" version "3.1.0"
resolved "https://registry.yarnpkg.com/minizlib/-/minizlib-3.0.2.tgz#f33d638eb279f664439aa38dc5f91607468cb574" resolved "https://registry.yarnpkg.com/minizlib/-/minizlib-3.1.0.tgz#6ad76c3a8f10227c9b51d1c9ac8e30b27f5a251c"
integrity sha512-oG62iEk+CYt5Xj2YqI5Xi9xWUeZhDI8jjQmC5oThVH5JGCTgIjr7ciJDzC7MBzYd//WvR1OTmP5Q38Q8ShQtVA== integrity sha512-KZxYo1BUkWD2TVFLr0MQoM8vUUigWD3LlD83a/75BqC+4qE0Hb1Vo5v1FgcfaNXvfXzr+5EhQ6ing/CaBijTlw==
dependencies: dependencies:
minipass "^7.1.2" minipass "^7.1.2"
@ -1126,11 +1126,6 @@ mkdirp-classic@^0.5.2, mkdirp-classic@^0.5.3:
resolved "https://registry.yarnpkg.com/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz#fa10c9115cc6d8865be221ba47ee9bed78601113" resolved "https://registry.yarnpkg.com/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz#fa10c9115cc6d8865be221ba47ee9bed78601113"
integrity sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A== integrity sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==
mkdirp@^3.0.1:
version "3.0.1"
resolved "https://registry.yarnpkg.com/mkdirp/-/mkdirp-3.0.1.tgz#e44e4c5607fb279c168241713cc6e0fea9adcb50"
integrity sha512-+NsyUUAZDmo6YVHzL/stxSu3t9YS1iljliy3BSDrXJ/dkn1KYdmtZODGGjLcc9XLgVVpH4KshHB8XmZgMhaBXg==
moment@^2.29.1: moment@^2.29.1:
version "2.30.1" version "2.30.1"
resolved "https://registry.yarnpkg.com/moment/-/moment-2.30.1.tgz#f8c91c07b7a786e30c59926df530b4eac96974ae" resolved "https://registry.yarnpkg.com/moment/-/moment-2.30.1.tgz#f8c91c07b7a786e30c59926df530b4eac96974ae"
@ -1635,16 +1630,15 @@ tar-stream@^2.1.4:
inherits "^2.0.3" inherits "^2.0.3"
readable-stream "^3.1.1" readable-stream "^3.1.1"
tar@^7.4.3: tar@>=7.5.8, tar@^7.4.3:
version "7.4.3" version "7.5.9"
resolved "https://registry.yarnpkg.com/tar/-/tar-7.4.3.tgz#88bbe9286a3fcd900e94592cda7a22b192e80571" resolved "https://registry.yarnpkg.com/tar/-/tar-7.5.9.tgz#817ac12a54bc4362c51340875b8985d7dc9724b8"
integrity sha512-5S7Va8hKfV7W5U6g3aYxXmlPoZVAwUMy9AOKyF2fVuZa2UD3qZjg578OrLRt8PcNN1PleVaL/5/yYATNL0ICUw== integrity sha512-BTLcK0xsDh2+PUe9F6c2TlRp4zOOBMTkoQHQIWSIzI0R7KG46uEwq4OPk2W7bZcprBMsuaeFsqwYr7pjh6CuHg==
dependencies: dependencies:
"@isaacs/fs-minipass" "^4.0.0" "@isaacs/fs-minipass" "^4.0.0"
chownr "^3.0.0" chownr "^3.0.0"
minipass "^7.1.2" minipass "^7.1.2"
minizlib "^3.0.1" minizlib "^3.1.0"
mkdirp "^3.0.1"
yallist "^5.0.0" yallist "^5.0.0"
text-hex@1.0.x: text-hex@1.0.x:

56
packaging/java/build.gradle

@ -16,12 +16,14 @@
import org.apache.tools.ant.filters.ReplaceTokens import org.apache.tools.ant.filters.ReplaceTokens
plugins { plugins {
id "nebula.ospackage" version "8.6.3" id "com.netflix.nebula.ospackage" version "12.1.1"
} }
buildDir = projectBuildDir buildDir = projectBuildDir
version = projectVersion version = projectVersion
distsDirName = "./" base {
distsDirectory = layout.buildDirectory.dir("./")
}
// OS Package plugin configuration // OS Package plugin configuration
ospackage { ospackage {
@ -33,8 +35,8 @@ ospackage {
into pkgInstallFolder into pkgInstallFolder
user pkgUser user = pkgUser
permissionGroup pkgUser permissionGroup = pkgUser
// Copy the actual .jar file // Copy the actual .jar file
from(mainJar) { from(mainJar) {
@ -42,19 +44,25 @@ ospackage {
rename { String fileName -> rename { String fileName ->
"${pkgName}.jar" "${pkgName}.jar"
} }
fileMode 0500 filePermissions {
unix('r-x------') // 0500
}
into "bin" into "bin"
} }
if("${pkgCopyInstallScripts}".equalsIgnoreCase("true")) { if("${pkgCopyInstallScripts}".equalsIgnoreCase("true")) {
// Copy the install files // Copy the install files
from("${buildDir}/bin/install/install.sh") { from("${buildDir}/bin/install/install.sh") {
fileMode 0775 filePermissions {
unix('rwxrwxr-x') // 0775
}
into "bin/install" into "bin/install"
} }
from("${buildDir}/bin/install/upgrade.sh") { from("${buildDir}/bin/install/upgrade.sh") {
fileMode 0775 filePermissions {
unix('rwxrwxr-x') // 0775
}
into "bin/install" into "bin/install"
} }
@ -67,14 +75,18 @@ ospackage {
from("${buildDir}/conf") { from("${buildDir}/conf") {
exclude "${pkgName}.conf" exclude "${pkgName}.conf"
fileType CONFIG | NOREPLACE fileType CONFIG | NOREPLACE
fileMode 0754 filePermissions {
unix('rwxr-xr--') // 0754
}
into "conf" into "conf"
} }
// Copy the data files // Copy the data files
from("${buildDir}/data") { from("${buildDir}/data") {
fileType CONFIG | NOREPLACE fileType CONFIG | NOREPLACE
fileMode 0754 filePermissions {
unix('rwxr-xr--') // 0754
}
into "data" into "data"
} }
@ -92,7 +104,7 @@ buildRpm {
archiveVersion = projectVersion.replace('-', '') archiveVersion = projectVersion.replace('-', '')
archiveFileName = "${pkgName}.rpm" 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 // 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 " + 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 " + "java-21 or java-21-headless or jre-21 or jre-21-headless or " +
@ -102,7 +114,9 @@ buildRpm {
include "${pkgName}.conf" include "${pkgName}.conf"
filter(ReplaceTokens, tokens: ['pkg.platform': 'rpm']) filter(ReplaceTokens, tokens: ['pkg.platform': 'rpm'])
fileType CONFIG | NOREPLACE fileType CONFIG | NOREPLACE
fileMode 0754 filePermissions {
unix('rwxr-xr--') // 0754
}
into "${pkgInstallFolder}/conf" into "${pkgInstallFolder}/conf"
} }
@ -111,13 +125,15 @@ buildRpm {
preUninstall file("${buildDir}/control/rpm/prerm") preUninstall file("${buildDir}/control/rpm/prerm")
postUninstall file("${buildDir}/control/rpm/postrm") postUninstall file("${buildDir}/control/rpm/postrm")
user pkgUser user = pkgUser
permissionGroup pkgUser permissionGroup = pkgUser
// Copy the system unit files // Copy the system unit files
from("${buildDir}/control/template.service") { from("${buildDir}/control/template.service") {
addParentDirs = false addParentDirs = false
fileMode 0644 filePermissions {
unix('rw-r--r--') // 0644
}
into "/usr/lib/systemd/system" into "/usr/lib/systemd/system"
rename { String filename -> rename { String filename ->
"${pkgName}.service" "${pkgName}.service"
@ -143,7 +159,9 @@ buildDeb {
include "${pkgName}.conf" include "${pkgName}.conf"
filter(ReplaceTokens, tokens: ['pkg.platform': 'deb']) filter(ReplaceTokens, tokens: ['pkg.platform': 'deb'])
fileType CONFIG | NOREPLACE fileType CONFIG | NOREPLACE
fileMode 0754 filePermissions {
unix('rwxr-xr--') // 0754
}
into "${pkgInstallFolder}/conf" into "${pkgInstallFolder}/conf"
} }
@ -157,13 +175,15 @@ buildDeb {
preUninstall file("${buildDir}/control/deb/prerm") preUninstall file("${buildDir}/control/deb/prerm")
postUninstall file("${buildDir}/control/deb/postrm") postUninstall file("${buildDir}/control/deb/postrm")
user pkgUser user = pkgUser
permissionGroup pkgUser permissionGroup = pkgUser
// Copy the system unit files // Copy the system unit files
from("${buildDir}/control/template.service") { from("${buildDir}/control/template.service") {
addParentDirs = false addParentDirs = false
fileMode 0644 filePermissions {
unix('rw-r--r--') // 0644
}
into "/lib/systemd/system" into "/lib/systemd/system"
rename { String filename -> rename { String filename ->
"${pkgName}.service" "${pkgName}.service"

8
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" for /f tokens^=2-5^ delims^=.-_^" %%j in ('java -fullversion 2^>^&1') do set "jver=%%j%%k"
@ECHO CurrentVersion %jver% @ECHO CurrentVersion %jver%
if %jver% NEQ 170 GOTO JAVA_NOT_INSTALLED if %jver% NEQ 250 GOTO JAVA_NOT_INSTALLED
:JAVA_INSTALLED :JAVA_INSTALLED
@ECHO Java 17 found! @ECHO Java 25 found!
@ECHO Installing thingsboard ... @ECHO Installing thingsboard ...
SET loadDemo=false SET loadDemo=false
@ -50,8 +50,8 @@ POPD
GOTO END GOTO END
:JAVA_NOT_INSTALLED :JAVA_NOT_INSTALLED
@ECHO Java 17 is not installed. Only Java 17 is supported @ECHO Java 25 is not installed. Only Java 25 is supported
@ECHO Please go to https://adoptopenjdk.net/index.html and install Java 17. Then retry installation. @ECHO Please go to https://adoptopenjdk.net/index.html and install Java 25. Then retry installation.
PAUSE PAUSE
GOTO END GOTO END

39
packaging/js/build.gradle

@ -13,15 +13,16 @@
* See the License for the specific language governing permissions and * See the License for the specific language governing permissions and
* limitations under the License. * limitations under the License.
*/ */
import org.apache.tools.ant.filters.ReplaceTokens
plugins { plugins {
id "nebula.ospackage" version "8.6.3" id "com.netflix.nebula.ospackage" version "12.1.1"
} }
buildDir = projectBuildDir buildDir = projectBuildDir
version = projectVersion version = projectVersion
distsDirName = "./" base {
distsDirectory = layout.buildDirectory.dir("./")
}
// OS Package plugin configuration // OS Package plugin configuration
ospackage { ospackage {
@ -33,18 +34,22 @@ ospackage {
into pkgInstallFolder into pkgInstallFolder
user pkgUser user = pkgUser
permissionGroup pkgUser permissionGroup = pkgUser
// Copy the executable file // Copy the executable file
from("${buildDir}/package/linux/bin/${pkgName}") { from("${buildDir}/package/linux/bin/${pkgName}") {
fileMode 0500 filePermissions {
unix('r-x------') // 0500
}
into "bin" into "bin"
} }
// Copy the init file // Copy the init file
from("${buildDir}/package/linux/init/template") { from("${buildDir}/package/linux/init/template") {
fileMode 0500 filePermissions {
unix('r-x------') // 0500
}
into "init" into "init"
rename { String filename -> rename { String filename ->
"${pkgName}" "${pkgName}"
@ -54,7 +59,9 @@ ospackage {
// Copy the config files // Copy the config files
from("${buildDir}/package/linux/conf") { from("${buildDir}/package/linux/conf") {
fileType CONFIG | NOREPLACE fileType CONFIG | NOREPLACE
fileMode 0754 filePermissions {
unix('rwxr-xr--') // 0754
}
into "conf" into "conf"
} }
@ -78,13 +85,15 @@ buildRpm {
preUninstall file("${buildDir}/control/rpm/prerm") preUninstall file("${buildDir}/control/rpm/prerm")
postUninstall file("${buildDir}/control/rpm/postrm") postUninstall file("${buildDir}/control/rpm/postrm")
user pkgUser user = pkgUser
permissionGroup pkgUser permissionGroup = pkgUser
// Copy the system unit files // Copy the system unit files
from("${buildDir}/control/template.service") { from("${buildDir}/control/template.service") {
addParentDirs = false addParentDirs = false
fileMode 0644 filePermissions {
unix('rw-r--r--') // 0644
}
into "/usr/lib/systemd/system" into "/usr/lib/systemd/system"
rename { String filename -> rename { String filename ->
"${pkgName}.service" "${pkgName}.service"
@ -111,13 +120,15 @@ buildDeb {
preUninstall file("${buildDir}/control/deb/prerm") preUninstall file("${buildDir}/control/deb/prerm")
postUninstall file("${buildDir}/control/deb/postrm") postUninstall file("${buildDir}/control/deb/postrm")
user pkgUser user = pkgUser
permissionGroup pkgUser permissionGroup = pkgUser
// Copy the system unit files // Copy the system unit files
from("${buildDir}/control/template.service") { from("${buildDir}/control/template.service") {
addParentDirs = false addParentDirs = false
fileMode 0644 filePermissions {
unix('rw-r--r--') // 0644
}
into "/lib/systemd/system" into "/lib/systemd/system"
rename { String filename -> rename { String filename ->
"${pkgName}.service" "${pkgName}.service"

58
pom.xml

@ -28,8 +28,8 @@
<inceptionYear>2016</inceptionYear> <inceptionYear>2016</inceptionYear>
<properties> <properties>
<maven.compiler.source>17</maven.compiler.source> <maven.compiler.source>25</maven.compiler.source>
<maven.compiler.target>17</maven.compiler.target> <maven.compiler.target>25</maven.compiler.target>
<main.dir>${basedir}</main.dir> <main.dir>${basedir}</main.dir>
<pkg.disabled>true</pkg.disabled> <pkg.disabled>true</pkg.disabled>
<pkg.process-resources.phase>none</pkg.process-resources.phase> <pkg.process-resources.phase>none</pkg.process-resources.phase>
@ -38,7 +38,8 @@
<pkg.implementationTitle>${project.name}</pkg.implementationTitle> <pkg.implementationTitle>${project.name}</pkg.implementationTitle>
<pkg.unixLogFolder>/var/log/${pkg.name}</pkg.unixLogFolder> <pkg.unixLogFolder>/var/log/${pkg.name}</pkg.unixLogFolder>
<pkg.installFolder>/usr/share/${pkg.name}</pkg.installFolder> <pkg.installFolder>/usr/share/${pkg.name}</pkg.installFolder>
<spring-boot.version>3.4.10</spring-boot.version> <spring-boot.version>3.4.13</spring-boot.version>
<tomcat.version>10.1.52</tomcat.version> <!-- to fix CVE-2026-24734 and CVE-2025-66614. TODO: remove when fixed in spring-boot-dependencies -->
<javax.xml.bind-api.version>2.4.0-b180830.0359</javax.xml.bind-api.version> <javax.xml.bind-api.version>2.4.0-b180830.0359</javax.xml.bind-api.version>
<jedis.version>5.1.5</jedis.version> <jedis.version>5.1.5</jedis.version>
<jjwt.version>0.12.5</jjwt.version> <jjwt.version>0.12.5</jjwt.version>
@ -64,8 +65,8 @@
<zookeeper.version>3.9.3</zookeeper.version> <zookeeper.version>3.9.3</zookeeper.version>
<protobuf.version>3.25.5</protobuf.version> <!-- A Major v4 does not support by the pubsub yet--> <protobuf.version>3.25.5</protobuf.version> <!-- A Major v4 does not support by the pubsub yet-->
<grpc.version>1.76.0</grpc.version> <grpc.version>1.76.0</grpc.version>
<tbel.version>1.2.8</tbel.version> <tbel.version>1.2.9</tbel.version>
<lombok.version>1.18.38</lombok.version> <lombok.version>1.18.40</lombok.version>
<paho.client.version>1.2.5</paho.client.version> <paho.client.version>1.2.5</paho.client.version>
<paho.mqttv5.client.version>1.2.5</paho.mqttv5.client.version> <paho.mqttv5.client.version>1.2.5</paho.mqttv5.client.version>
<os-maven-plugin.version>1.7.1</os-maven-plugin.version> <os-maven-plugin.version>1.7.1</os-maven-plugin.version>
@ -111,6 +112,7 @@
<jakarta.el.version>4.0.2</jakarta.el.version> <jakarta.el.version>4.0.2</jakarta.el.version>
<antisamy.version>1.7.5</antisamy.version> <antisamy.version>1.7.5</antisamy.version>
<snmp4j.version>3.8.0</snmp4j.version> <snmp4j.version>3.8.0</snmp4j.version>
<bytebuddy.version>1.18.4</bytebuddy.version>
<langchain4j.version>1.8.0-TB</langchain4j.version> <langchain4j.version>1.8.0-TB</langchain4j.version>
<error_prone_annotations.version>2.38.0</error_prone_annotations.version> <error_prone_annotations.version>2.38.0</error_prone_annotations.version>
<animal-sniffer-annotations.version>1.24</animal-sniffer-annotations.version> <animal-sniffer-annotations.version>1.24</animal-sniffer-annotations.version>
@ -147,7 +149,6 @@
<firebase-admin.version>9.2.0</firebase-admin.version> <firebase-admin.version>9.2.0</firebase-admin.version>
<snappy.version>1.1.10.5</snappy.version> <snappy.version>1.1.10.5</snappy.version>
<rocksdbjni.version>9.10.0</rocksdbjni.version> <rocksdbjni.version>9.10.0</rocksdbjni.version>
<netty.version>4.1.128.Final</netty.version> <!-- to fix CVEs. TODO: remove when fixed in spring-boot-dependencies -->
</properties> </properties>
<modules> <modules>
@ -605,7 +606,7 @@
<artifactId>maven-compiler-plugin</artifactId> <artifactId>maven-compiler-plugin</artifactId>
<version>3.11.0</version> <version>3.11.0</version>
<configuration> <configuration>
<release>17</release> <release>25</release>
<compilerArgs> <compilerArgs>
<arg>-Xlint:deprecation</arg> <arg>-Xlint:deprecation</arg>
<arg>-Xlint:removal</arg> <arg>-Xlint:removal</arg>
@ -649,7 +650,7 @@
<plugin> <plugin>
<groupId>org.thingsboard</groupId> <groupId>org.thingsboard</groupId>
<artifactId>gradle-maven-plugin</artifactId> <artifactId>gradle-maven-plugin</artifactId>
<version>1.0.12</version> <version>1.0.15</version>
</plugin> </plugin>
<plugin> <plugin>
<groupId>com.github.eirslett</groupId> <groupId>com.github.eirslett</groupId>
@ -663,6 +664,7 @@
<configuration> <configuration>
<argLine> <argLine>
-XX:+UseStringDeduplication -XX:MaxGCPauseMillis=200 -XX:+UseStringDeduplication -XX:MaxGCPauseMillis=200
-XX:+EnableDynamicAgentLoading
--add-opens=java.base/java.lang.reflect=ALL-UNNAMED --add-opens=java.base/java.lang.reflect=ALL-UNNAMED
-Dqueue.edqs.local.rocksdb_path="target/rocksdb/fork_${surefire.forkNumber}/edqs" -Dqueue.edqs.local.rocksdb_path="target/rocksdb/fork_${surefire.forkNumber}/edqs"
-Dqueue.calculated_fields.rocks_db_path="target/rocksdb/fork_${surefire.forkNumber}/cf" -Dqueue.calculated_fields.rocks_db_path="target/rocksdb/fork_${surefire.forkNumber}/cf"
@ -837,6 +839,7 @@
<exclude>**/test/resources/lwm2m/**</exclude> <exclude>**/test/resources/lwm2m/**</exclude>
<exclude>**/resources/lwm2m/models/**</exclude> <exclude>**/resources/lwm2m/models/**</exclude>
<exclude>src/main/data/resources/**</exclude> <exclude>src/main/data/resources/**</exclude>
<exclude>.claude/**</exclude>
</excludes> </excludes>
<mapping> <mapping>
<proto>JAVADOC_STYLE</proto> <proto>JAVADOC_STYLE</proto>
@ -899,13 +902,24 @@
<dependencyManagement> <dependencyManagement>
<dependencies> <dependencies>
<!-- Temporary Tomcat version override -->
<dependency> <dependency>
<groupId>io.netty</groupId> <groupId>org.apache.tomcat.embed</groupId>
<artifactId>netty-bom</artifactId> <artifactId>tomcat-embed-core</artifactId>
<version>${netty.version}</version> <version>${tomcat.version}</version>
<type>pom</type> </dependency>
<scope>import</scope> <dependency>
<groupId>org.apache.tomcat.embed</groupId>
<artifactId>tomcat-embed-el</artifactId>
<version>${tomcat.version}</version>
</dependency>
<dependency>
<groupId>org.apache.tomcat.embed</groupId>
<artifactId>tomcat-embed-websocket</artifactId>
<version>${tomcat.version}</version>
</dependency> </dependency>
<!-- End of Tomcat version override -->
<dependency> <dependency>
<groupId>org.springframework.boot</groupId> <groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-dependencies</artifactId> <artifactId>spring-boot-dependencies</artifactId>
@ -913,6 +927,16 @@
<type>pom</type> <type>pom</type>
<scope>import</scope> <scope>import</scope>
</dependency> </dependency>
<dependency>
<groupId>net.bytebuddy</groupId>
<artifactId>byte-buddy</artifactId>
<version>${bytebuddy.version}</version>
</dependency>
<dependency>
<groupId>net.bytebuddy</groupId>
<artifactId>byte-buddy-agent</artifactId>
<version>${bytebuddy.version}</version>
</dependency>
<dependency> <dependency>
<groupId>org.thingsboard.langchain4j</groupId> <groupId>org.thingsboard.langchain4j</groupId>
<artifactId>langchain4j-bom</artifactId> <artifactId>langchain4j-bom</artifactId>
@ -1929,7 +1953,7 @@
<url>https://repo1.maven.org/maven2/</url> <url>https://repo1.maven.org/maven2/</url>
</repository> </repository>
<repository> <repository>
<id>thingsboard-repo</id> <id>thingsboard-public-repo</id>
<url>https://repo.thingsboard.io/artifactory/libs-release-public</url> <url>https://repo.thingsboard.io/artifactory/libs-release-public</url>
</repository> </repository>
<repository> <repository>
@ -1955,4 +1979,10 @@
</repository> </repository>
</repositories> </repositories>
<pluginRepositories>
<pluginRepository>
<id>thingsboard-repo</id>
<url>https://repo.thingsboard.io/artifactory/libs-release-public</url>
</pluginRepository>
</pluginRepositories>
</project> </project>

22
rest-client/src/main/java/org/thingsboard/rest/client/RestClient.java

@ -22,7 +22,6 @@ import com.google.common.base.Strings;
import lombok.Getter; import lombok.Getter;
import lombok.SneakyThrows; import lombok.SneakyThrows;
import org.apache.commons.io.IOUtils; import org.apache.commons.io.IOUtils;
import org.apache.hc.core5.net.URIBuilder;
import org.apache.commons.lang3.concurrent.LazyInitializer; import org.apache.commons.lang3.concurrent.LazyInitializer;
import org.apache.hc.core5.net.URIBuilder; import org.apache.hc.core5.net.URIBuilder;
import org.springframework.core.ParameterizedTypeReference; import org.springframework.core.ParameterizedTypeReference;
@ -172,6 +171,7 @@ import org.thingsboard.server.common.data.query.AlarmCountQuery;
import org.thingsboard.server.common.data.query.AlarmData; import org.thingsboard.server.common.data.query.AlarmData;
import org.thingsboard.server.common.data.query.AlarmDataQuery; import org.thingsboard.server.common.data.query.AlarmDataQuery;
import org.thingsboard.server.common.data.query.AvailableEntityKeys; import org.thingsboard.server.common.data.query.AvailableEntityKeys;
import org.thingsboard.server.common.data.query.AvailableEntityKeysV2;
import org.thingsboard.server.common.data.query.EntityCountQuery; import org.thingsboard.server.common.data.query.EntityCountQuery;
import org.thingsboard.server.common.data.query.EntityData; import org.thingsboard.server.common.data.query.EntityData;
import org.thingsboard.server.common.data.query.EntityDataQuery; import org.thingsboard.server.common.data.query.EntityDataQuery;
@ -1898,6 +1898,10 @@ public class RestClient implements Closeable {
}).getBody(); }).getBody();
} }
/**
* @deprecated Use {@link #findAvailableEntityKeysV2(EntityDataQuery, boolean, boolean, Set, boolean)} instead.
*/
@Deprecated(forRemoval = true)
public AvailableEntityKeys findAvailableEntityKeysByQuery(EntityDataQuery query, boolean includeTimeseries, boolean includeAttributes, AttributeScope scope) { public AvailableEntityKeys findAvailableEntityKeysByQuery(EntityDataQuery query, boolean includeTimeseries, boolean includeAttributes, AttributeScope scope) {
var uri = UriComponentsBuilder.fromUriString(baseURL) var uri = UriComponentsBuilder.fromUriString(baseURL)
.path("/api/entitiesQuery/find/keys") .path("/api/entitiesQuery/find/keys")
@ -1909,6 +1913,22 @@ public class RestClient implements Closeable {
return restTemplate.exchange(uri, HttpMethod.POST, new HttpEntity<>(query), new ParameterizedTypeReference<AvailableEntityKeys>() {}).getBody(); return restTemplate.exchange(uri, HttpMethod.POST, new HttpEntity<>(query), new ParameterizedTypeReference<AvailableEntityKeys>() {}).getBody();
} }
@SneakyThrows(URISyntaxException.class)
public AvailableEntityKeysV2 findAvailableEntityKeysV2(
EntityDataQuery query, boolean includeTimeseries, boolean includeAttributes, Set<AttributeScope> scopes, boolean includeSamples
) {
var builder = new URIBuilder(baseURL).appendPath("/api/v2/entitiesQuery/find/keys")
.addParameter("includeTimeseries", String.valueOf(includeTimeseries))
.addParameter("includeAttributes", String.valueOf(includeAttributes))
.addParameter("includeSamples", String.valueOf(includeSamples));
if (scopes != null) {
for (AttributeScope scope : scopes) {
builder.addParameter("scopes", scope.name());
}
}
return restTemplate.exchange(builder.build(), HttpMethod.POST, new HttpEntity<>(query), new ParameterizedTypeReference<AvailableEntityKeysV2>() {}).getBody();
}
public PageData<AlarmData> findAlarmDataByQuery(AlarmDataQuery query) { public PageData<AlarmData> findAlarmDataByQuery(AlarmDataQuery query) {
return restTemplate.exchange( return restTemplate.exchange(
baseURL + "/api/alarmsQuery/find", baseURL + "/api/alarmsQuery/find",

34
rest-client/src/main/resources/logback.xml

@ -1,34 +0,0 @@
<?xml version="1.0" encoding="UTF-8" ?>
<!--
Copyright © 2016-2026 The Thingsboard Authors
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
-->
<!DOCTYPE configuration>
<configuration>
<appender name="STDOUT" class="ch.qos.logback.core.ConsoleAppender">
<encoder>
<pattern>%d{ISO8601} [%thread] %-5level %logger{36} - %msg%n</pattern>
</encoder>
</appender>
<logger name="org.thingsboard.server" level="INFO" />
<root level="INFO">
<appender-ref ref="STDOUT"/>
</root>
</configuration>

3
rule-engine/rule-engine-api/src/main/java/org/thingsboard/rule/engine/api/JobManager.java

@ -19,11 +19,14 @@ import com.google.common.util.concurrent.ListenableFuture;
import org.thingsboard.server.common.data.id.JobId; import org.thingsboard.server.common.data.id.JobId;
import org.thingsboard.server.common.data.id.TenantId; import org.thingsboard.server.common.data.id.TenantId;
import org.thingsboard.server.common.data.job.Job; import org.thingsboard.server.common.data.job.Job;
import org.thingsboard.server.common.msg.queue.TbCallback;
public interface JobManager { public interface JobManager {
ListenableFuture<Job> submitJob(Job job); // TODO: rate limits ListenableFuture<Job> submitJob(Job job); // TODO: rate limits
ListenableFuture<Job> submitJob(Job job, TbCallback finishCallback);
void cancelJob(TenantId tenantId, JobId jobId); void cancelJob(TenantId tenantId, JobId jobId);
void reprocessJob(TenantId tenantId, JobId jobId); void reprocessJob(TenantId tenantId, JobId jobId);

17
ui-ngx/package.json

@ -94,11 +94,11 @@
}, },
"devDependencies": { "devDependencies": {
"@angular-builders/custom-esbuild": "20.0.0", "@angular-builders/custom-esbuild": "20.0.0",
"@angular-devkit/build-angular": "20.3.15", "@angular-devkit/build-angular": "20.3.16",
"@angular-devkit/core": "20.3.15", "@angular-devkit/core": "20.3.16",
"@angular-devkit/schematics": "20.3.15", "@angular-devkit/schematics": "20.3.16",
"@angular/build": "20.3.15", "@angular/build": "20.3.16",
"@angular/cli": "20.3.15", "@angular/cli": "20.3.16",
"@angular/compiler-cli": "20.3.16", "@angular/compiler-cli": "20.3.16",
"@angular/language-service": "20.3.16", "@angular/language-service": "20.3.16",
"@types/ace-diff": "^2.1.4", "@types/ace-diff": "^2.1.4",
@ -121,7 +121,7 @@
"angular-eslint": "~20.7.0", "angular-eslint": "~20.7.0",
"autoprefixer": "^10.4.23", "autoprefixer": "^10.4.23",
"directory-tree": "^3.5.2", "directory-tree": "^3.5.2",
"eslint": "~9.39.2", "eslint": "9.39.3",
"eslint-plugin-import": "^2.32.0", "eslint-plugin-import": "^2.32.0",
"eslint-plugin-jsdoc": "^62.4.1", "eslint-plugin-jsdoc": "^62.4.1",
"eslint-plugin-prefer-arrow": "^1.2.3", "eslint-plugin-prefer-arrow": "^1.2.3",
@ -139,6 +139,9 @@
"ace-builds": "1.43.6", "ace-builds": "1.43.6",
"tinymce": "6.8.6", "tinymce": "6.8.6",
"@babel/core": "7.28.3", "@babel/core": "7.28.3",
"esbuild": "0.25.9" "esbuild": "0.25.9",
"rollup": "4.52.3",
"jquery.terminal/**/form-data": ">=4.0.4",
"js-beautify/**/minimatch": "^9.0.6"
} }
} }

2
ui-ngx/patches/@angular+build+20.3.15.patch → ui-ngx/patches/@angular+build+20.3.16.patch

@ -12,7 +12,7 @@ index 53168ea..f07c80a 100755
// remove important annotations, such as /* @__PURE__ */ and comments like /* vite-ignore */. // remove important annotations, such as /* @__PURE__ */ and comments like /* vite-ignore */.
removeComments: false, removeComments: false,
diff --git a/node_modules/@angular/build/src/tools/esbuild/angular/compiler-plugin.js b/node_modules/@angular/build/src/tools/esbuild/angular/compiler-plugin.js diff --git a/node_modules/@angular/build/src/tools/esbuild/angular/compiler-plugin.js b/node_modules/@angular/build/src/tools/esbuild/angular/compiler-plugin.js
index ee68408..ac15cbf 100755 index ee68408..2667b84 100755
--- a/node_modules/@angular/build/src/tools/esbuild/angular/compiler-plugin.js --- a/node_modules/@angular/build/src/tools/esbuild/angular/compiler-plugin.js
+++ b/node_modules/@angular/build/src/tools/esbuild/angular/compiler-plugin.js +++ b/node_modules/@angular/build/src/tools/esbuild/angular/compiler-plugin.js
@@ -90,7 +90,7 @@ function createCompilerPlugin(pluginOptions, compilationOrFactory, stylesheetBun @@ -90,7 +90,7 @@ function createCompilerPlugin(pluginOptions, compilationOrFactory, stylesheetBun

51
ui-ngx/patches/@iplab+ngx-color-picker+20.0.0.patch

@ -1,46 +1,13 @@
diff --git a/node_modules/@iplab/ngx-color-picker/fesm2022/iplab-ngx-color-picker.mjs b/node_modules/@iplab/ngx-color-picker/fesm2022/iplab-ngx-color-picker.mjs diff --git a/node_modules/@iplab/ngx-color-picker/fesm2022/iplab-ngx-color-picker.mjs b/node_modules/@iplab/ngx-color-picker/fesm2022/iplab-ngx-color-picker.mjs
index a372799..a3d709a 100644 index a372799..f64a6f8 100644
--- a/node_modules/@iplab/ngx-color-picker/fesm2022/iplab-ngx-color-picker.mjs --- a/node_modules/@iplab/ngx-color-picker/fesm2022/iplab-ngx-color-picker.mjs
+++ b/node_modules/@iplab/ngx-color-picker/fesm2022/iplab-ngx-color-picker.mjs +++ b/node_modules/@iplab/ngx-color-picker/fesm2022/iplab-ngx-color-picker.mjs
@@ -1129,11 +1129,11 @@ class RgbaComponent { @@ -516,7 +516,7 @@ class Color {
this.color.set(newColor); const s = (color.saturation / 100) * (l <= 1 ? l : 2 - l);
const value = (l + s) / 2;
const saturation = (2 * s) / (l + s) || 0;
- return new Hsva(hue, saturation, value, color.alpha);
+ return new Hsva(hue, saturation * 100, value * 100, color.alpha);
} }
static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "20.0.4", ngImport: i0, type: RgbaComponent, deps: [], target: i0.ɵɵFactoryTarget.Component }); } rgbaToHsva(color) {
- static { this.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "20.0.4", type: RgbaComponent, isStandalone: true, selector: "rgba-input-component", inputs: { color: { classPropertyName: "color", publicName: "color", isSignal: true, isRequired: true, transformFunction: null }, labelVisible: { classPropertyName: "labelVisible", publicName: "label", isSignal: true, isRequired: false, transformFunction: null }, isAlphaVisible: { classPropertyName: "isAlphaVisible", publicName: "alpha", isSignal: true, isRequired: false, transformFunction: null } }, outputs: { color: "colorChange" }, ngImport: i0, template: "<div class=\"column\">\r\n <input type=\"text\" pattern=\"[0-9]*\" min=\"0\" max=\"255\" [value]=\"value?.getRed().toString()\" (inputChange)=\"onInputChange($event, 'R')\" />\r\n @if (labelVisible()) {\r\n <span>R</span>\r\n }\r\n</div>\r\n<div class=\"column\">\r\n <input type=\"text\" pattern=\"[0-9]*\" min=\"0\" max=\"255\" [value]=\"value?.getGreen().toString()\" (inputChange)=\"onInputChange($event, 'G')\" />\r\n @if (labelVisible()) {\r\n <span>G</span>\r\n }\r\n</div>\r\n<div class=\"column\">\r\n <input type=\"text\" pattern=\"[0-9]*\" min=\"0\" max=\"255\" [value]=\"value?.getBlue().toString()\" (inputChange)=\"onInputChange($event, 'B')\" />\r\n @if (labelVisible()) {\r\n <span>B</span>\r\n }\r\n</div>\r\n@if (isAlphaVisible()) {\r\n <div class=\"column\">\r\n <input type=\"text\" pattern=\"[0-9]+([\\.,][0-9]{1,2})?\" min=\"0\" max=\"1\" [value]=\"value?.getAlpha(true).toString()\" (inputChange)=\"onInputChange($event, 'A')\" />\r\n @if (labelVisible()) {\r\n <span>A</span>\r\n }\r\n </div>\r\n}", styles: [":host,:host ::ng-deep *{padding:0;margin:0;-webkit-box-sizing:border-box;-moz-box-sizing:border-box;box-sizing:border-box}\n", ":host{display:table;width:100%;text-align:center;color:#b4b4b4;font-size:11px}.column{display:table-cell;padding:0 2px}input{width:100%;border:1px solid rgb(218,218,218);color:#272727;text-align:center;font-size:12px;-webkit-appearance:none;border-radius:0;margin:0 0 6px;height:26px;outline:none}\n", ""], dependencies: [{ kind: "directive", type: ColorPickerInputDirective, selector: "[inputChange]", inputs: ["min", "max"], outputs: ["inputChange"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush }); } const red = color.red / 255;
+ static { this.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "20.0.4", type: RgbaComponent, isStandalone: true, selector: "rgba-input-component", inputs: { color: { classPropertyName: "color", publicName: "color", isSignal: true, isRequired: true, transformFunction: null }, labelVisible: { classPropertyName: "labelVisible", publicName: "label", isSignal: true, isRequired: false, transformFunction: null }, isAlphaVisible: { classPropertyName: "isAlphaVisible", publicName: "alpha", isSignal: true, isRequired: false, transformFunction: null } }, outputs: { color: "colorChange" }, ngImport: i0, template: "<div class=\"column\">\r\n <input type=\"number\" pattern=\"[0-9]*\" min=\"0\" max=\"255\" [value]=\"value?.getRed().toString()\" (inputChange)=\"onInputChange($event, 'R')\" />\r\n @if (labelVisible()) {\r\n <span>R</span>\r\n }\r\n</div>\r\n<div class=\"column\">\r\n <input type=\"number\" pattern=\"[0-9]*\" min=\"0\" max=\"255\" [value]=\"value?.getGreen().toString()\" (inputChange)=\"onInputChange($event, 'G')\" />\r\n @if (labelVisible()) {\r\n <span>G</span>\r\n }\r\n</div>\r\n<div class=\"column\">\r\n <input type=\"number\" pattern=\"[0-9]*\" min=\"0\" max=\"255\" [value]=\"value?.getBlue().toString()\" (inputChange)=\"onInputChange($event, 'B')\" />\r\n @if (labelVisible()) {\r\n <span>B</span>\r\n }\r\n</div>\r\n@if (isAlphaVisible()) {\r\n <div class=\"column\">\r\n <input type=\"number\" pattern=\"[0-9]+([\\.,][0-9]{1,2})?\" min=\"0\" max=\"1\" [value]=\"value?.getAlpha(true).toString()\" (inputChange)=\"onInputChange($event, 'A')\" />\r\n @if (labelVisible()) {\r\n <span>A</span>\r\n }\r\n </div>\r\n}", styles: [":host,:host ::ng-deep *{padding:0;margin:0;-webkit-box-sizing:border-box;-moz-box-sizing:border-box;box-sizing:border-box}\n", ":host{display:table;width:100%;text-align:center;color:#b4b4b4;font-size:11px}.column{display:table-cell;padding:0 2px}input{width:100%;border:1px solid rgb(218,218,218);color:#272727;text-align:center;font-size:12px;-webkit-appearance:none;border-radius:0;margin:0 0 6px;height:26px;outline:none}\n", ""], dependencies: [{ kind: "directive", type: ColorPickerInputDirective, selector: "[inputChange]", inputs: ["min", "max"], outputs: ["inputChange"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush }); }
}
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.0.4", ngImport: i0, type: RgbaComponent, decorators: [{
type: Component,
- args: [{ selector: `rgba-input-component`, changeDetection: ChangeDetectionStrategy.OnPush, imports: [ColorPickerInputDirective], template: "<div class=\"column\">\r\n <input type=\"text\" pattern=\"[0-9]*\" min=\"0\" max=\"255\" [value]=\"value?.getRed().toString()\" (inputChange)=\"onInputChange($event, 'R')\" />\r\n @if (labelVisible()) {\r\n <span>R</span>\r\n }\r\n</div>\r\n<div class=\"column\">\r\n <input type=\"text\" pattern=\"[0-9]*\" min=\"0\" max=\"255\" [value]=\"value?.getGreen().toString()\" (inputChange)=\"onInputChange($event, 'G')\" />\r\n @if (labelVisible()) {\r\n <span>G</span>\r\n }\r\n</div>\r\n<div class=\"column\">\r\n <input type=\"text\" pattern=\"[0-9]*\" min=\"0\" max=\"255\" [value]=\"value?.getBlue().toString()\" (inputChange)=\"onInputChange($event, 'B')\" />\r\n @if (labelVisible()) {\r\n <span>B</span>\r\n }\r\n</div>\r\n@if (isAlphaVisible()) {\r\n <div class=\"column\">\r\n <input type=\"text\" pattern=\"[0-9]+([\\.,][0-9]{1,2})?\" min=\"0\" max=\"1\" [value]=\"value?.getAlpha(true).toString()\" (inputChange)=\"onInputChange($event, 'A')\" />\r\n @if (labelVisible()) {\r\n <span>A</span>\r\n }\r\n </div>\r\n}", styles: [":host,:host ::ng-deep *{padding:0;margin:0;-webkit-box-sizing:border-box;-moz-box-sizing:border-box;box-sizing:border-box}\n", ":host{display:table;width:100%;text-align:center;color:#b4b4b4;font-size:11px}.column{display:table-cell;padding:0 2px}input{width:100%;border:1px solid rgb(218,218,218);color:#272727;text-align:center;font-size:12px;-webkit-appearance:none;border-radius:0;margin:0 0 6px;height:26px;outline:none}\n"] }]
+ args: [{ selector: `rgba-input-component`, changeDetection: ChangeDetectionStrategy.OnPush, imports: [ColorPickerInputDirective], template: "<div class=\"column\">\r\n <input type=\"number\" pattern=\"[0-9]*\" min=\"0\" max=\"255\" [value]=\"value?.getRed().toString()\" (inputChange)=\"onInputChange($event, 'R')\" />\r\n @if (labelVisible()) {\r\n <span>R</span>\r\n }\r\n</div>\r\n<div class=\"column\">\r\n <input type=\"number\" pattern=\"[0-9]*\" min=\"0\" max=\"255\" [value]=\"value?.getGreen().toString()\" (inputChange)=\"onInputChange($event, 'G')\" />\r\n @if (labelVisible()) {\r\n <span>G</span>\r\n }\r\n</div>\r\n<div class=\"column\">\r\n <input type=\"number\" pattern=\"[0-9]*\" min=\"0\" max=\"255\" [value]=\"value?.getBlue().toString()\" (inputChange)=\"onInputChange($event, 'B')\" />\r\n @if (labelVisible()) {\r\n <span>B</span>\r\n }\r\n</div>\r\n@if (isAlphaVisible()) {\r\n <div class=\"column\">\r\n <input type=\"number\" pattern=\"[0-9]+([\\.,][0-9]{1,2})?\" min=\"0\" max=\"1\" [value]=\"value?.getAlpha(true).toString()\" (inputChange)=\"onInputChange($event, 'A')\" />\r\n @if (labelVisible()) {\r\n <span>A</span>\r\n }\r\n </div>\r\n}", styles: [":host,:host ::ng-deep *{padding:0;margin:0;-webkit-box-sizing:border-box;-moz-box-sizing:border-box;box-sizing:border-box}\n", ":host{display:table;width:100%;text-align:center;color:#b4b4b4;font-size:11px}.column{display:table-cell;padding:0 2px}input{width:100%;border:1px solid rgb(218,218,218);color:#272727;text-align:center;font-size:12px;-webkit-appearance:none;border-radius:0;margin:0 0 6px;height:26px;outline:none}\n"] }]
}] });
class HslaComponent {
@@ -1155,11 +1155,11 @@ class HslaComponent {
this.color.set(newColor);
}
static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "20.0.4", ngImport: i0, type: HslaComponent, deps: [], target: i0.ɵɵFactoryTarget.Component }); }
- static { this.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "20.0.4", type: HslaComponent, isStandalone: true, selector: "hsla-input-component", inputs: { color: { classPropertyName: "color", publicName: "color", isSignal: true, isRequired: true, transformFunction: null }, labelVisible: { classPropertyName: "labelVisible", publicName: "label", isSignal: true, isRequired: false, transformFunction: null }, isAlphaVisible: { classPropertyName: "isAlphaVisible", publicName: "alpha", isSignal: true, isRequired: false, transformFunction: null } }, outputs: { color: "colorChange" }, ngImport: i0, template: "<div class=\"column\">\r\n <input type=\"text\" pattern=\"[0-9]*\" min=\"0\" max=\"360\" [value]=\"value?.getHue().toString()\" (inputChange)=\"onInputChange($event, 'H')\" />\r\n @if (labelVisible()) {\r\n <span>H</span>\r\n }\r\n</div>\r\n<div class=\"column\">\r\n <input type=\"text\" pattern=\"[0-9]*\" min=\"0\" max=\"100\" [value]=\"value?.getSaturation() + '%'\" (inputChange)=\"onInputChange($event, 'S')\" />\r\n @if (labelVisible()) {\r\n <span>S</span>\r\n }\r\n</div>\r\n<div class=\"column\">\r\n <input type=\"text\" pattern=\"[0-9]*\" min=\"0\" max=\"100\" [value]=\"value?.getLightness() + '%'\" (inputChange)=\"onInputChange($event, 'L')\" />\r\n @if (labelVisible()) {\r\n <span>L</span>\r\n }\r\n</div>\r\n@if (isAlphaVisible()) {\r\n <div class=\"column\">\r\n <input type=\"text\" pattern=\"[0-9]+([\\.,][0-9]{1,2})?\" min=\"0\" max=\"1\" [value]=\"value?.getAlpha(true).toString()\" (inputChange)=\"onInputChange($event, 'A')\" />\r\n @if (labelVisible()) {\r\n <span>A</span>\r\n }\r\n </div>\r\n}", styles: [":host,:host ::ng-deep *{padding:0;margin:0;-webkit-box-sizing:border-box;-moz-box-sizing:border-box;box-sizing:border-box}\n", ":host{display:table;width:100%;text-align:center;color:#b4b4b4;font-size:11px}.column{display:table-cell;padding:0 2px}input{width:100%;border:1px solid rgb(218,218,218);color:#272727;text-align:center;font-size:12px;-webkit-appearance:none;border-radius:0;margin:0 0 6px;height:26px;outline:none}\n", ""], dependencies: [{ kind: "directive", type: ColorPickerInputDirective, selector: "[inputChange]", inputs: ["min", "max"], outputs: ["inputChange"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush }); }
+ static { this.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "20.0.4", type: HslaComponent, isStandalone: true, selector: "hsla-input-component", inputs: { color: { classPropertyName: "color", publicName: "color", isSignal: true, isRequired: true, transformFunction: null }, labelVisible: { classPropertyName: "labelVisible", publicName: "label", isSignal: true, isRequired: false, transformFunction: null }, isAlphaVisible: { classPropertyName: "isAlphaVisible", publicName: "alpha", isSignal: true, isRequired: false, transformFunction: null } }, outputs: { color: "colorChange" }, ngImport: i0, template: "<div class=\"column\">\r\n <input type=\"number\" pattern=\"[0-9]*\" min=\"0\" max=\"360\" [value]=\"value?.getHue().toString()\" (inputChange)=\"onInputChange($event, 'H')\" />\r\n @if (labelVisible()) {\r\n <span>H</span>\r\n }\r\n</div>\r\n<div class=\"column\">\r\n <input type=\"number\" pattern=\"[0-9]*\" min=\"0\" max=\"100\" [value]=\"value?.getSaturation() + '%'\" (inputChange)=\"onInputChange($event, 'S')\" />\r\n @if (labelVisible()) {\r\n <span>S</span>\r\n }\r\n</div>\r\n<div class=\"column\">\r\n <input type=\"number\" pattern=\"[0-9]*\" min=\"0\" max=\"100\" [value]=\"value?.getLightness() + '%'\" (inputChange)=\"onInputChange($event, 'L')\" />\r\n @if (labelVisible()) {\r\n <span>L</span>\r\n }\r\n</div>\r\n@if (isAlphaVisible()) {\r\n <div class=\"column\">\r\n <input type=\"number\" pattern=\"[0-9]+([\\.,][0-9]{1,2})?\" min=\"0\" max=\"1\" [value]=\"value?.getAlpha(true).toString()\" (inputChange)=\"onInputChange($event, 'A')\" />\r\n @if (labelVisible()) {\r\n <span>A</span>\r\n }\r\n </div>\r\n}", styles: [":host,:host ::ng-deep *{padding:0;margin:0;-webkit-box-sizing:border-box;-moz-box-sizing:border-box;box-sizing:border-box}\n", ":host{display:table;width:100%;text-align:center;color:#b4b4b4;font-size:11px}.column{display:table-cell;padding:0 2px}input{width:100%;border:1px solid rgb(218,218,218);color:#272727;text-align:center;font-size:12px;-webkit-appearance:none;border-radius:0;margin:0 0 6px;height:26px;outline:none}\n", ""], dependencies: [{ kind: "directive", type: ColorPickerInputDirective, selector: "[inputChange]", inputs: ["min", "max"], outputs: ["inputChange"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush }); }
}
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.0.4", ngImport: i0, type: HslaComponent, decorators: [{
type: Component,
- args: [{ selector: `hsla-input-component`, changeDetection: ChangeDetectionStrategy.OnPush, imports: [ColorPickerInputDirective], template: "<div class=\"column\">\r\n <input type=\"text\" pattern=\"[0-9]*\" min=\"0\" max=\"360\" [value]=\"value?.getHue().toString()\" (inputChange)=\"onInputChange($event, 'H')\" />\r\n @if (labelVisible()) {\r\n <span>H</span>\r\n }\r\n</div>\r\n<div class=\"column\">\r\n <input type=\"text\" pattern=\"[0-9]*\" min=\"0\" max=\"100\" [value]=\"value?.getSaturation() + '%'\" (inputChange)=\"onInputChange($event, 'S')\" />\r\n @if (labelVisible()) {\r\n <span>S</span>\r\n }\r\n</div>\r\n<div class=\"column\">\r\n <input type=\"text\" pattern=\"[0-9]*\" min=\"0\" max=\"100\" [value]=\"value?.getLightness() + '%'\" (inputChange)=\"onInputChange($event, 'L')\" />\r\n @if (labelVisible()) {\r\n <span>L</span>\r\n }\r\n</div>\r\n@if (isAlphaVisible()) {\r\n <div class=\"column\">\r\n <input type=\"text\" pattern=\"[0-9]+([\\.,][0-9]{1,2})?\" min=\"0\" max=\"1\" [value]=\"value?.getAlpha(true).toString()\" (inputChange)=\"onInputChange($event, 'A')\" />\r\n @if (labelVisible()) {\r\n <span>A</span>\r\n }\r\n </div>\r\n}", styles: [":host,:host ::ng-deep *{padding:0;margin:0;-webkit-box-sizing:border-box;-moz-box-sizing:border-box;box-sizing:border-box}\n", ":host{display:table;width:100%;text-align:center;color:#b4b4b4;font-size:11px}.column{display:table-cell;padding:0 2px}input{width:100%;border:1px solid rgb(218,218,218);color:#272727;text-align:center;font-size:12px;-webkit-appearance:none;border-radius:0;margin:0 0 6px;height:26px;outline:none}\n"] }]
+ args: [{ selector: `hsla-input-component`, changeDetection: ChangeDetectionStrategy.OnPush, imports: [ColorPickerInputDirective], template: "<div class=\"column\">\r\n <input type=\"number\" pattern=\"[0-9]*\" min=\"0\" max=\"360\" [value]=\"value?.getHue().toString()\" (inputChange)=\"onInputChange($event, 'H')\" />\r\n @if (labelVisible()) {\r\n <span>H</span>\r\n }\r\n</div>\r\n<div class=\"column\">\r\n <input type=\"number\" pattern=\"[0-9]*\" min=\"0\" max=\"100\" [value]=\"value?.getSaturation() + '%'\" (inputChange)=\"onInputChange($event, 'S')\" />\r\n @if (labelVisible()) {\r\n <span>S</span>\r\n }\r\n</div>\r\n<div class=\"column\">\r\n <input type=\"number\" pattern=\"[0-9]*\" min=\"0\" max=\"100\" [value]=\"value?.getLightness() + '%'\" (inputChange)=\"onInputChange($event, 'L')\" />\r\n @if (labelVisible()) {\r\n <span>L</span>\r\n }\r\n</div>\r\n@if (isAlphaVisible()) {\r\n <div class=\"column\">\r\n <input type=\"number\" pattern=\"[0-9]+([\\.,][0-9]{1,2})?\" min=\"0\" max=\"1\" [value]=\"value?.getAlpha(true).toString()\" (inputChange)=\"onInputChange($event, 'A')\" />\r\n @if (labelVisible()) {\r\n <span>A</span>\r\n }\r\n </div>\r\n}", styles: [":host,:host ::ng-deep *{padding:0;margin:0;-webkit-box-sizing:border-box;-moz-box-sizing:border-box;box-sizing:border-box}\n", ":host{display:table;width:100%;text-align:center;color:#b4b4b4;font-size:11px}.column{display:table-cell;padding:0 2px}input{width:100%;border:1px solid rgb(218,218,218);color:#272727;text-align:center;font-size:12px;-webkit-appearance:none;border-radius:0;margin:0 0 6px;height:26px;outline:none}\n"] }]
}] });
class HexComponent {
@@ -1190,11 +1190,11 @@ class HexComponent {
}
}
static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "20.0.4", ngImport: i0, type: HexComponent, deps: [], target: i0.ɵɵFactoryTarget.Component }); }
- static { this.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "20.0.4", type: HexComponent, isStandalone: true, selector: "hex-input-component", inputs: { color: { classPropertyName: "color", publicName: "color", isSignal: true, isRequired: true, transformFunction: null }, labelVisible: { classPropertyName: "labelVisible", publicName: "label", isSignal: true, isRequired: false, transformFunction: null }, prefixValue: { classPropertyName: "prefixValue", publicName: "prefix", isSignal: true, isRequired: false, transformFunction: null } }, outputs: { color: "colorChange" }, ngImport: i0, template: "<div class=\"column\">\r\n <input #elRef type=\"text\" [value]=\"value\" (keyup)=\"onInputChange($event, elRef.value)\" />\r\n @if (labelVisible()) {\r\n <span>HEX</span>\r\n }\r\n</div>", styles: [":host,:host ::ng-deep *{padding:0;margin:0;-webkit-box-sizing:border-box;-moz-box-sizing:border-box;box-sizing:border-box}\n", ":host{display:table;width:100%;text-align:center;color:#b4b4b4;font-size:11px}.column{display:table-cell;padding:0 2px}input{width:100%;border:1px solid rgb(218,218,218);color:#272727;text-align:center;font-size:12px;-webkit-appearance:none;border-radius:0;margin:0 0 6px;height:26px;outline:none}\n", ""], changeDetection: i0.ChangeDetectionStrategy.OnPush }); }
+ static { this.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "20.0.4", type: HexComponent, isStandalone: true, selector: "hex-input-component", inputs: { color: { classPropertyName: "color", publicName: "color", isSignal: true, isRequired: true, transformFunction: null }, labelVisible: { classPropertyName: "labelVisible", publicName: "label", isSignal: true, isRequired: false, transformFunction: null }, prefixValue: { classPropertyName: "prefixValue", publicName: "prefix", isSignal: true, isRequired: false, transformFunction: null } }, outputs: { color: "colorChange" }, ngImport: i0, template: "<div class=\"column\">\r\n <input #elRef type=\"number\" [value]=\"value\" (keyup)=\"onInputChange($event, elRef.value)\" />\r\n @if (labelVisible()) {\r\n <span>HEX</span>\r\n }\r\n</div>", styles: [":host,:host ::ng-deep *{padding:0;margin:0;-webkit-box-sizing:border-box;-moz-box-sizing:border-box;box-sizing:border-box}\n", ":host{display:table;width:100%;text-align:center;color:#b4b4b4;font-size:11px}.column{display:table-cell;padding:0 2px}input{width:100%;border:1px solid rgb(218,218,218);color:#272727;text-align:center;font-size:12px;-webkit-appearance:none;border-radius:0;margin:0 0 6px;height:26px;outline:none}\n", ""], changeDetection: i0.ChangeDetectionStrategy.OnPush }); }
}
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.0.4", ngImport: i0, type: HexComponent, decorators: [{
type: Component,
- args: [{ selector: `hex-input-component`, changeDetection: ChangeDetectionStrategy.OnPush, standalone: true, template: "<div class=\"column\">\r\n <input #elRef type=\"text\" [value]=\"value\" (keyup)=\"onInputChange($event, elRef.value)\" />\r\n @if (labelVisible()) {\r\n <span>HEX</span>\r\n }\r\n</div>", styles: [":host,:host ::ng-deep *{padding:0;margin:0;-webkit-box-sizing:border-box;-moz-box-sizing:border-box;box-sizing:border-box}\n", ":host{display:table;width:100%;text-align:center;color:#b4b4b4;font-size:11px}.column{display:table-cell;padding:0 2px}input{width:100%;border:1px solid rgb(218,218,218);color:#272727;text-align:center;font-size:12px;-webkit-appearance:none;border-radius:0;margin:0 0 6px;height:26px;outline:none}\n"] }]
+ args: [{ selector: `hex-input-component`, changeDetection: ChangeDetectionStrategy.OnPush, standalone: true, template: "<div class=\"column\">\r\n <input #elRef type=\"number\" [value]=\"value\" (keyup)=\"onInputChange($event, elRef.value)\" />\r\n @if (labelVisible()) {\r\n <span>HEX</span>\r\n }\r\n</div>", styles: [":host,:host ::ng-deep *{padding:0;margin:0;-webkit-box-sizing:border-box;-moz-box-sizing:border-box;box-sizing:border-box}\n", ":host{display:table;width:100%;text-align:center;color:#b4b4b4;font-size:11px}.column{display:table-cell;padding:0 2px}input{width:100%;border:1px solid rgb(218,218,218);color:#272727;text-align:center;font-size:12px;-webkit-appearance:none;border-radius:0;margin:0 0 6px;height:26px;outline:none}\n"] }]
}] });
const OpacityAnimation = trigger('opacityAnimation', [

12
ui-ngx/src/app/modules/home/components/widget/config/basic/chart/bar-chart-with-labels-basic-config.component.ts

@ -45,7 +45,10 @@ import {
barChartWithLabelsDefaultSettings, barChartWithLabelsDefaultSettings,
BarChartWithLabelsWidgetSettings BarChartWithLabelsWidgetSettings
} from '@home/components/widget/lib/chart/bar-chart-with-labels-widget.models'; } from '@home/components/widget/lib/chart/bar-chart-with-labels-widget.models';
import { TimeSeriesChartType } from '@home/components/widget/lib/chart/time-series-chart.models'; import {
TimeSeriesChartType,
updateLatestDataKeys
} from '@home/components/widget/lib/chart/time-series-chart.models';
import { getSourceTbUnitSymbol } from '@shared/models/unit.models'; import { getSourceTbUnitSymbol } from '@shared/models/unit.models';
@Component({ @Component({
@ -76,7 +79,7 @@ export class BarChartWithLabelsBasicConfigComponent extends BasicWidgetConfigCom
tooltipDatePreviewFn = this._tooltipDatePreviewFn.bind(this); tooltipDatePreviewFn = this._tooltipDatePreviewFn.bind(this);
predefinedValues = widgetTitleAutocompleteValues; predefinedValues = widgetTitleAutocompleteValues;
constructor(protected store: Store<AppState>, constructor(protected store: Store<AppState>,
protected widgetConfigComponent: WidgetConfigComponent, protected widgetConfigComponent: WidgetConfigComponent,
private $injector: Injector, private $injector: Injector,
@ -167,6 +170,11 @@ export class BarChartWithLabelsBasicConfigComponent extends BasicWidgetConfigCom
}); });
} }
protected onConfigChanged(widgetConfig: WidgetConfigComponentData) {
updateLatestDataKeys([widgetConfig.config.settings.yAxis], this.datasource, this.callbacks);
super.onConfigChanged(widgetConfig);
}
protected prepareOutputConfig(config: any): WidgetConfigComponentData { protected prepareOutputConfig(config: any): WidgetConfigComponentData {
setTimewindowConfig(this.widgetConfig.config, config.timewindowConfig); setTimewindowConfig(this.widgetConfig.config, config.timewindowConfig);
this.widgetConfig.config.datasources = config.datasources; this.widgetConfig.config.datasources = config.datasources;

7
ui-ngx/src/app/modules/home/components/widget/config/basic/chart/range-chart-basic-config.component.ts

@ -47,7 +47,7 @@ import {
} from '@home/components/widget/lib/chart/range-chart-widget.models'; } from '@home/components/widget/lib/chart/range-chart-widget.models';
import { import {
lineSeriesStepTypes, lineSeriesStepTypes,
lineSeriesStepTypeTranslations lineSeriesStepTypeTranslations, updateLatestDataKeys
} from '@home/components/widget/lib/chart/time-series-chart.models'; } from '@home/components/widget/lib/chart/time-series-chart.models';
import { import {
chartLabelPositions, chartLabelPositions,
@ -289,6 +289,11 @@ export class RangeChartBasicConfigComponent extends BasicWidgetConfigComponent {
return this.widgetConfig; return this.widgetConfig;
} }
protected onConfigChanged(widgetConfig: WidgetConfigComponentData) {
updateLatestDataKeys([widgetConfig.config.settings.yAxis], this.datasource, this.callbacks);
super.onConfigChanged(widgetConfig);
}
protected validatorTriggers(): string[] { protected validatorTriggers(): string[] {
return ['showTitle', 'showIcon', 'showRangeThresholds', 'fillArea', 'showLine', return ['showTitle', 'showIcon', 'showRangeThresholds', 'fillArea', 'showLine',
'step', 'showPointLabel', 'enablePointLabelBackground', 'showLegend', 'showTooltip', 'tooltipShowDate']; 'step', 'showPointLabel', 'enablePointLabelBackground', 'showLegend', 'showTooltip', 'tooltipShowDate'];

6
ui-ngx/src/app/modules/home/components/widget/lib/chart/bar-chart-with-labels-widget.component.ts

@ -134,6 +134,12 @@ export class BarChartWithLabelsWidgetComponent implements OnInit, OnDestroy, Aft
} }
} }
public onLatestDataUpdated() {
if (this.timeSeriesChart) {
this.timeSeriesChart.latestUpdated();
}
}
public onLegendKeyEnter(key: DataKey) { public onLegendKeyEnter(key: DataKey) {
this.timeSeriesChart.keyEnter(key); this.timeSeriesChart.keyEnter(key);
} }

6
ui-ngx/src/app/modules/home/components/widget/lib/chart/range-chart-widget.component.ts

@ -162,6 +162,12 @@ export class RangeChartWidgetComponent implements OnInit, OnDestroy, AfterViewIn
} }
} }
public onLatestDataUpdated() {
if (this.timeSeriesChart) {
this.timeSeriesChart.latestUpdated();
}
}
public toggleRangeItem(item: RangeItem) { public toggleRangeItem(item: RangeItem) {
item.enabled = !item.enabled; item.enabled = !item.enabled;
this.timeSeriesChart.toggleVisualMapRange(item.index); this.timeSeriesChart.toggleVisualMapRange(item.index);

100
ui-ngx/src/app/modules/home/components/widget/lib/chart/time-series-chart.models.ts

@ -99,6 +99,8 @@ import {
TimeSeriesChartTooltipWidgetSettings TimeSeriesChartTooltipWidgetSettings
} from '@home/components/widget/lib/chart/time-series-chart-tooltip.models'; } from '@home/components/widget/lib/chart/time-series-chart-tooltip.models';
import { TbUnit, TbUnitConverter } from '@shared/models/unit.models'; import { TbUnit, TbUnitConverter } from '@shared/models/unit.models';
import { DataKeyType } from '@shared/models/telemetry/telemetry.models';
import { DataKeysCallbacks } from '@home/components/widget/lib/settings/common/key/data-keys.component.models';
type TimeSeriesChartDataEntry = [number, any, number, number]; type TimeSeriesChartDataEntry = [number, any, number, number];
@ -1495,3 +1497,101 @@ const createSeriesLabelOption = (item: TimeSeriesChartDataItem, show: boolean,
} }
return labelOption; return labelOption;
}; };
export const checkLatestDataKeys = (yAxes: TimeSeriesChartYAxes, datasource: Datasource): TimeSeriesChartYAxes => {
const latestKeys = datasource?.latestDataKeys || [];
const result: TimeSeriesChartYAxes = {};
for (const [id, axis] of Object.entries(yAxes)) {
axis.min = normalizeAxisLimit(axis.min);
axis.max = normalizeAxisLimit(axis.max);
const minCfg = axis.min;
const maxCfg = axis.max;
const minValid = !!minCfg && (
minCfg.type !== ValueSourceType.latestKey ||
latestKeys.some(k => isYAxisKey(k, minCfg))
);
const maxValid = !!maxCfg && (
maxCfg.type !== ValueSourceType.latestKey ||
latestKeys.some(k => isYAxisKey(k, maxCfg))
);
if (minValid && maxValid) {
result[id] = axis;
}
}
return result;
}
export const updateLatestDataKeys = (yAxes: TimeSeriesChartYAxisSettings[], datasource: Datasource, dataKeyCallbacks: DataKeysCallbacks)=> {
if (datasource) {
let latestKeys = datasource.latestDataKeys;
if (!latestKeys) {
latestKeys = [];
datasource.latestDataKeys = latestKeys;
}
const existingYAxisKeys = latestKeys.filter(k => k.settings?.__yAxisMinKey === true || k.settings?.__yAxisMaxKey === true);
const foundYAxisKeys: DataKey[] = [];
for(const yAxis of yAxes) {
const min = yAxis.min as ValueSourceConfig;
const max = yAxis.max as ValueSourceConfig;
if (min && min.type === ValueSourceType.latestKey) {
const found = existingYAxisKeys.find(k => isYAxisKey(k, min));
if (!found) {
const newKey = dataKeyCallbacks.generateDataKey(min.latestKey, min.latestKeyType,
null, true, null);
newKey.settings.__yAxisMinKey = true;
latestKeys.push(newKey);
} else if (foundYAxisKeys.indexOf(found) === -1) {
foundYAxisKeys.push(found);
}
}
if (max && max.type === ValueSourceType.latestKey) {
const found = existingYAxisKeys.find(k => isYAxisKey(k, max));
if (!found) {
const newKey = dataKeyCallbacks.generateDataKey(max.latestKey, max.latestKeyType,
null, true, null);
newKey.settings.__yAxisMaxKey = true;
latestKeys.push(newKey);
} else if (foundYAxisKeys.indexOf(found) === -1) {
foundYAxisKeys.push(found);
}
}
}
const toRemove = existingYAxisKeys.filter(k => foundYAxisKeys.indexOf(k) === -1);
for (const key of toRemove) {
const index = latestKeys.indexOf(key);
if (index > -1) {
latestKeys.splice(index, 1);
}
}
}
}
export const isYAxisKey = (d: DataKey, limit: ValueSourceConfig): boolean => {
return (d.type === DataKeyType.function && d.label === limit.latestKey) ||
(d.type !== DataKeyType.function && d.name === limit.latestKey &&
d.type === limit.latestKeyType);
}
export const normalizeAxisLimit = (limit: string | number | ValueSourceConfig): ValueSourceConfig => {
if (!limit) {
return {
type: ValueSourceType.constant,
value: null,
entityAlias: null
};
} else if (typeof limit === 'number' || typeof limit === 'string') {
return {
type: ValueSourceType.constant,
value: Number(limit),
entityAlias: null
};
}
return limit;
}

23
ui-ngx/src/app/modules/home/components/widget/lib/chart/time-series-chart.ts

@ -23,7 +23,7 @@ import {
createTimeSeriesYAxis, createTimeSeriesYAxis,
defaultTimeSeriesChartYAxisSettings, defaultTimeSeriesChartYAxisSettings,
generateChartData, generateChartData,
LineSeriesStepType, LineSeriesStepType, normalizeAxisLimit,
parseThresholdData, parseThresholdData,
TimeSeriesChartAxis, TimeSeriesChartAxis,
TimeSeriesChartDataItem, TimeSeriesChartDataItem,
@ -581,8 +581,8 @@ export class TbTimeSeriesChart {
yAxisSettingsList.sort((a1, a2) => a1.order - a2.order); yAxisSettingsList.sort((a1, a2) => a1.order - a2.order);
const axisLimitDatasources: Datasource[] = []; const axisLimitDatasources: Datasource[] = [];
for (const yAxisSettings of yAxisSettingsList) { for (const yAxisSettings of yAxisSettingsList) {
yAxisSettings.min = this.normalizeAxisLimit(yAxisSettings.min); yAxisSettings.min = normalizeAxisLimit(yAxisSettings.min);
yAxisSettings.max = this.normalizeAxisLimit(yAxisSettings.max); yAxisSettings.max = normalizeAxisLimit(yAxisSettings.max);
const axisSettings = mergeDeep<TimeSeriesChartYAxisSettings>({} as TimeSeriesChartYAxisSettings, const axisSettings = mergeDeep<TimeSeriesChartYAxisSettings>({} as TimeSeriesChartYAxisSettings,
defaultTimeSeriesChartYAxisSettings, yAxisSettings); defaultTimeSeriesChartYAxisSettings, yAxisSettings);
const units = isNotEmptyTbUnits(axisSettings.units) ? axisSettings.units : this.ctx.units; const units = isNotEmptyTbUnits(axisSettings.units) ? axisSettings.units : this.ctx.units;
@ -1080,21 +1080,4 @@ export class TbTimeSeriesChart {
this.timeSeriesChart.setOption(this.timeSeriesChartOptions); this.timeSeriesChart.setOption(this.timeSeriesChartOptions);
} }
} }
private normalizeAxisLimit(limit: string | number | ValueSourceConfig): string | number | ValueSourceConfig {
if (!limit) {
return {
type: ValueSourceType.constant,
value: null,
entityAlias: null
};
} else if (typeof limit === 'number' || typeof limit === 'string') {
return {
type: ValueSourceType.constant,
value: Number(limit),
entityAlias: null
};
}
return limit;
}
} }

6
ui-ngx/src/app/modules/home/components/widget/lib/settings/chart/bar-chart-with-labels-widget-settings.component.ts

@ -31,6 +31,7 @@ import {
barChartWithLabelsDefaultSettings barChartWithLabelsDefaultSettings
} from '@home/components/widget/lib/chart/bar-chart-with-labels-widget.models'; } from '@home/components/widget/lib/chart/bar-chart-with-labels-widget.models';
import { getSourceTbUnitSymbol } from '@shared/models/unit.models'; import { getSourceTbUnitSymbol } from '@shared/models/unit.models';
import { updateLatestDataKeys } from '@home/components/widget/lib/chart/time-series-chart.models';
@Component({ @Component({
selector: 'tb-bar-chart-with-labels-widget-settings', selector: 'tb-bar-chart-with-labels-widget-settings',
@ -123,6 +124,11 @@ export class BarChartWithLabelsWidgetSettingsComponent extends WidgetSettingsCom
}); });
} }
protected onSettingsChanged(updated: WidgetSettings) {
updateLatestDataKeys([updated.yAxis], this.datasource, this.dataKeyCallbacks);
super.onSettingsChanged(updated);
}
protected validatorTriggers(): string[] { protected validatorTriggers(): string[] {
return ['showBarLabel', 'showBarValue', 'showBarBorder', 'showLegend', 'showTooltip', 'tooltipShowDate']; return ['showBarLabel', 'showBarValue', 'showBarBorder', 'showLegend', 'showTooltip', 'tooltipShowDate'];
} }

7
ui-ngx/src/app/modules/home/components/widget/lib/settings/chart/range-chart-widget-settings.component.ts

@ -30,7 +30,7 @@ import { rangeChartDefaultSettings } from '@home/components/widget/lib/chart/ran
import { DateFormatProcessor, DateFormatSettings } from '@shared/models/widget-settings.models'; import { DateFormatProcessor, DateFormatSettings } from '@shared/models/widget-settings.models';
import { import {
lineSeriesStepTypes, lineSeriesStepTypes,
lineSeriesStepTypeTranslations lineSeriesStepTypeTranslations, updateLatestDataKeys
} from '@home/components/widget/lib/chart/time-series-chart.models'; } from '@home/components/widget/lib/chart/time-series-chart.models';
import { import {
chartLabelPositions, chartLabelPositions,
@ -269,6 +269,11 @@ export class RangeChartWidgetSettingsComponent extends WidgetSettingsComponent {
} }
} }
protected onSettingsChanged(updated: WidgetSettings) {
updateLatestDataKeys([updated.yAxis], this.datasource, this.dataKeyCallbacks);
super.onSettingsChanged(updated);
}
private _pointLabelPreviewFn(): string { private _pointLabelPreviewFn(): string {
const units = getSourceTbUnitSymbol(this.widgetConfig.config.units); const units = getSourceTbUnitSymbol(this.widgetConfig.config.units);
const decimals: number = this.widgetConfig.config.decimals; const decimals: number = this.widgetConfig.config.decimals;

37
ui-ngx/src/app/modules/home/components/widget/lib/settings/common/axis-scale-row.component.ts

@ -97,7 +97,7 @@ export class AxisScaleRowComponent implements ControlValueAccessor, OnInit, Vali
this.limitForm = this.fb.group({ this.limitForm = this.fb.group({
type: [ValueSourceType.constant], type: [ValueSourceType.constant],
value: [null], value: [null],
entityAlias: [null] entityAlias: [null, [Validators.required]]
}); });
this.latestKeyFormControl = this.fb.control(null, [Validators.required]); this.latestKeyFormControl = this.fb.control(null, [Validators.required]);
this.entityKeyFormControl = this.fb.control(null, [Validators.required]); this.entityKeyFormControl = this.fb.control(null, [Validators.required]);
@ -169,23 +169,24 @@ export class AxisScaleRowComponent implements ControlValueAccessor, OnInit, Vali
} }
private updateValidators() { private updateValidators() {
const axisTypeControl = this.limitForm.get('type'); const type = this.limitForm.get('type')?.value;
if (axisTypeControl && this.entityKeyFormControl && this.latestKeyFormControl) { const entityAliasCtr = this.limitForm.get('entityAlias');
const type = axisTypeControl.value;
if (type === ValueSourceType.latestKey) { const isLatestKey = type === ValueSourceType.latestKey;
this.latestKeyFormControl.setValidators([Validators.required]); const isEntity = type === ValueSourceType.entity;
this.entityKeyFormControl.clearValidators();
} else if (type === ValueSourceType.entity) { isLatestKey ? this.latestKeyFormControl.enable({ emitEvent: false })
this.latestKeyFormControl.clearValidators(); : this.latestKeyFormControl.disable({ emitEvent: false });
this.limitForm.get('entityAlias').setValidators([Validators.required]);
this.entityKeyFormControl.setValidators([Validators.required]); isEntity ? this.entityKeyFormControl.enable({ emitEvent: false })
} else { : this.entityKeyFormControl.disable({ emitEvent: false });
this.latestKeyFormControl.clearValidators();
this.entityKeyFormControl.clearValidators(); isEntity ? entityAliasCtr.enable({ emitEvent: false })
} : entityAliasCtr.disable({ emitEvent: false });
this.latestKeyFormControl.updateValueAndValidity({ emitEvent: false });
this.entityKeyFormControl.updateValueAndValidity({ emitEvent: false }); this.latestKeyFormControl.updateValueAndValidity({ emitEvent: false });
} this.entityKeyFormControl.updateValueAndValidity({ emitEvent: false });
entityAliasCtr.updateValueAndValidity({ emitEvent: false });
} }
private updateModel() { private updateModel() {

6
ui-ngx/src/app/modules/home/components/widget/lib/settings/common/chart/time-series-chart-axis-settings.component.ts

@ -25,7 +25,7 @@ import {
Validators Validators
} from '@angular/forms'; } from '@angular/forms';
import { import {
AxisPosition, defaultXAxisTicksFormat, AxisPosition, defaultXAxisTicksFormat, normalizeAxisLimit,
timeSeriesAxisPositionTranslations, timeSeriesAxisPositionTranslations,
TimeSeriesChartAxisSettings, TimeSeriesChartXAxisSettings, TimeSeriesChartAxisSettings, TimeSeriesChartXAxisSettings,
TimeSeriesChartYAxisSettings TimeSeriesChartYAxisSettings
@ -138,8 +138,8 @@ export class TimeSeriesChartAxisSettingsComponent implements OnInit, ControlValu
this.axisSettingsFormGroup.addControl('ticksGenerator', this.fb.control(null, [])); this.axisSettingsFormGroup.addControl('ticksGenerator', this.fb.control(null, []));
this.axisSettingsFormGroup.addControl('interval', this.fb.control(null, [Validators.min(0)])); this.axisSettingsFormGroup.addControl('interval', this.fb.control(null, [Validators.min(0)]));
this.axisSettingsFormGroup.addControl('splitNumber', this.fb.control(null, [Validators.min(1)])); this.axisSettingsFormGroup.addControl('splitNumber', this.fb.control(null, [Validators.min(1)]));
this.axisSettingsFormGroup.addControl('min', this.fb.control(null, [])); this.axisSettingsFormGroup.addControl('min', this.fb.control(normalizeAxisLimit(null), []));
this.axisSettingsFormGroup.addControl('max', this.fb.control(null, [])); this.axisSettingsFormGroup.addControl('max', this.fb.control(normalizeAxisLimit(null), []));
} else if (this.axisType === 'xAxis') { } else if (this.axisType === 'xAxis') {
this.axisSettingsFormGroup.addControl('ticksFormat', this.fb.control(null, [])); this.axisSettingsFormGroup.addControl('ticksFormat', this.fb.control(null, []));
} }

108
ui-ngx/src/app/modules/home/components/widget/lib/settings/common/chart/time-series-chart-y-axes-panel.component.ts

@ -36,13 +36,15 @@ import {
Validator Validator
} from '@angular/forms'; } from '@angular/forms';
import { import {
checkLatestDataKeys,
defaultTimeSeriesChartYAxisSettings, defaultTimeSeriesChartYAxisSettings,
getNextTimeSeriesYAxisId, getNextTimeSeriesYAxisId,
TimeSeriesChartYAxes, TimeSeriesChartYAxes,
TimeSeriesChartYAxisId, TimeSeriesChartYAxisId,
TimeSeriesChartYAxisSettings, TimeSeriesChartYAxisSettings,
timeSeriesChartYAxisValid, timeSeriesChartYAxisValid,
timeSeriesChartYAxisValidator timeSeriesChartYAxisValidator,
updateLatestDataKeys
} from '@home/components/widget/lib/chart/time-series-chart.models'; } from '@home/components/widget/lib/chart/time-series-chart.models';
import { mergeDeep } from '@core/utils'; import { mergeDeep } from '@core/utils';
import { CdkDragDrop } from '@angular/cdk/drag-drop'; import { CdkDragDrop } from '@angular/cdk/drag-drop';
@ -50,7 +52,7 @@ import { coerceBoolean } from '@shared/decorators/coercion';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { IAliasController } from '@app/core/public-api'; import { IAliasController } from '@app/core/public-api';
import { DataKeysCallbacks } from '@home/components/widget/lib/settings/common/key/data-keys.component.models'; import { DataKeysCallbacks } from '@home/components/widget/lib/settings/common/key/data-keys.component.models';
import { DataKey, DataKeyType, Datasource, ValueSourceConfig, ValueSourceType } from '@app/shared/public-api'; import { Datasource } from '@app/shared/public-api';
@Component({ @Component({
selector: 'tb-time-series-chart-y-axes-panel', selector: 'tb-time-series-chart-y-axes-panel',
@ -127,7 +129,7 @@ export class TimeSeriesChartYAxesPanelComponent implements ControlValueAccessor,
for (const axis of axes) { for (const axis of axes) {
yAxes[axis.id] = axis; yAxes[axis.id] = axis;
} }
this.updateLatestDataKeys(Object.values(yAxes)); updateLatestDataKeys(Object.values(yAxes), this.datasource, this.dataKeyCallbacks);
this.propagateChange(yAxes); this.propagateChange(yAxes);
} }
); );
@ -150,7 +152,7 @@ export class TimeSeriesChartYAxesPanelComponent implements ControlValueAccessor,
} }
writeValue(value: TimeSeriesChartYAxes | undefined): void { writeValue(value: TimeSeriesChartYAxes | undefined): void {
const yAxes: TimeSeriesChartYAxes = this.checkLatestDataKeys(value || {}); const yAxes: TimeSeriesChartYAxes = checkLatestDataKeys(value || {}, this.datasource);
if (!yAxes.default) { if (!yAxes.default) {
yAxes.default = mergeDeep({} as TimeSeriesChartYAxisSettings, defaultTimeSeriesChartYAxisSettings, yAxes.default = mergeDeep({} as TimeSeriesChartYAxisSettings, defaultTimeSeriesChartYAxisSettings,
{id: 'default', order: 0} as TimeSeriesChartYAxisSettings); {id: 'default', order: 0} as TimeSeriesChartYAxisSettings);
@ -197,8 +199,6 @@ export class TimeSeriesChartYAxesPanelComponent implements ControlValueAccessor,
const axes: TimeSeriesChartYAxisSettings[] = this.yAxesFormGroup.get('axes').value; const axes: TimeSeriesChartYAxisSettings[] = this.yAxesFormGroup.get('axes').value;
axis.id = getNextTimeSeriesYAxisId(axes); axis.id = getNextTimeSeriesYAxisId(axes);
axis.order = axes.length; axis.order = axes.length;
axis.min = this.normalizeAxisLimit(axis.min);
axis.max = this.normalizeAxisLimit(axis.max);
const axesArray = this.yAxesFormGroup.get('axes') as UntypedFormArray; const axesArray = this.yAxesFormGroup.get('axes') as UntypedFormArray;
const axisControl = this.fb.control(axis, [timeSeriesChartYAxisValidator]); const axisControl = this.fb.control(axis, [timeSeriesChartYAxisValidator]);
axesArray.push(axisControl); axesArray.push(axisControl);
@ -212,100 +212,4 @@ export class TimeSeriesChartYAxesPanelComponent implements ControlValueAccessor,
return this.fb.array(axesControls); return this.fb.array(axesControls);
} }
private checkLatestDataKeys(yAxes: TimeSeriesChartYAxes): TimeSeriesChartYAxes {
const latestKeys = this.datasource?.latestDataKeys || [];
const result: TimeSeriesChartYAxes = {};
for (const [id, axis] of Object.entries(yAxes)) {
axis.min = this.normalizeAxisLimit(axis.min);
axis.max = this.normalizeAxisLimit(axis.max);
const minCfg = axis.min;
const maxCfg = axis.max;
const minValid = !!minCfg && (
minCfg.type !== ValueSourceType.latestKey ||
latestKeys.some(k => this.isYAxisKey(k, minCfg))
);
const maxValid = !!maxCfg && (
maxCfg.type !== ValueSourceType.latestKey ||
latestKeys.some(k => this.isYAxisKey(k, maxCfg))
);
if (minValid && maxValid) {
result[id] = axis;
}
}
return result;
}
private updateLatestDataKeys(yAxes: TimeSeriesChartYAxisSettings[]) {
if (this.datasource) {
let latestKeys = this.datasource.latestDataKeys;
if (!latestKeys) {
latestKeys = [];
this.datasource.latestDataKeys = latestKeys;
}
const existingYAxisKeys = latestKeys.filter(k => k.settings?.__yAxisMinKey === true || k.settings?.__yAxisMaxKey === true);
const foundYAxisKeys: DataKey[] = [];
for(const yAxis of yAxes) {
const min = yAxis.min as ValueSourceConfig;
const max = yAxis.max as ValueSourceConfig;
if (min.type === ValueSourceType.latestKey) {
const found = existingYAxisKeys.find(k => this.isYAxisKey(k, min));
if (!found) {
const newKey = this.dataKeyCallbacks.generateDataKey(min.latestKey, min.latestKeyType,
null, true, null);
newKey.settings.__yAxisMinKey = true;
latestKeys.push(newKey);
} else if (foundYAxisKeys.indexOf(found) === -1) {
foundYAxisKeys.push(found);
}
}
if (max.type === ValueSourceType.latestKey) {
const found = existingYAxisKeys.find(k => this.isYAxisKey(k, max));
if (!found) {
const newKey = this.dataKeyCallbacks.generateDataKey(max.latestKey, max.latestKeyType,
null, true, null);
newKey.settings.__yAxisMaxKey = true;
latestKeys.push(newKey);
} else if (foundYAxisKeys.indexOf(found) === -1) {
foundYAxisKeys.push(found);
}
}
}
const toRemove = existingYAxisKeys.filter(k => foundYAxisKeys.indexOf(k) === -1);
for (const key of toRemove) {
const index = latestKeys.indexOf(key);
if (index > -1) {
latestKeys.splice(index, 1);
}
}
}
}
private isYAxisKey(d: DataKey, limit: ValueSourceConfig): boolean {
return (d.type === DataKeyType.function && d.label === limit.latestKey) ||
(d.type !== DataKeyType.function && d.name === limit.latestKey &&
d.type === limit.latestKeyType);
}
private normalizeAxisLimit(limit: string | number | ValueSourceConfig): ValueSourceConfig {
if (!limit) {
return {
type: ValueSourceType.constant,
value: null,
entityAlias: null
};
} else if (typeof limit === 'number' || typeof limit === 'string') {
return {
type: ValueSourceType.constant,
value: Number(limit),
entityAlias: null
};
}
return limit;
}
} }

29
ui-ngx/src/app/modules/home/components/widget/lib/settings/common/chart/time-series-chart-y-axis-row.component.ts

@ -29,7 +29,7 @@ import {
} from '@angular/core'; } from '@angular/core';
import { ControlValueAccessor, NG_VALUE_ACCESSOR, UntypedFormBuilder, UntypedFormGroup } from '@angular/forms'; import { ControlValueAccessor, NG_VALUE_ACCESSOR, UntypedFormBuilder, UntypedFormGroup } from '@angular/forms';
import { import {
AxisPosition, AxisPosition, normalizeAxisLimit,
timeSeriesAxisPositionTranslations, timeSeriesAxisPositionTranslations,
TimeSeriesChartYAxisSettings TimeSeriesChartYAxisSettings
} from '@home/components/widget/lib/chart/time-series-chart.models'; } from '@home/components/widget/lib/chart/time-series-chart.models';
@ -136,8 +136,8 @@ export class TimeSeriesChartYAxisRowComponent implements ControlValueAccessor, O
writeValue(value: TimeSeriesChartYAxisSettings): void { writeValue(value: TimeSeriesChartYAxisSettings): void {
this.modelValue = value; this.modelValue = value;
const min = this.normalizeLimit(value.min); const min = normalizeAxisLimit(value.min);
const max = this.normalizeLimit(value.max); const max = normalizeAxisLimit(value.max);
this.axisFormGroup.patchValue({ this.axisFormGroup.patchValue({
label: value.label, label: value.label,
@ -252,27 +252,4 @@ export class TimeSeriesChartYAxisRowComponent implements ControlValueAccessor, O
entityKeyType: [null, []] entityKeyType: [null, []]
}); });
} }
private normalizeLimit(limit: any) {
const base = {
type: ValueSourceType.constant,
value: null,
latestKey: null,
latestKeyType: null,
entityAlias: null,
entityKey: null,
entityKeyType: null
};
if (limit == null) return base;
if (typeof limit === 'number' || typeof limit === 'string') {
return { ...base, type: ValueSourceType.constant, value: Number(limit) };
}
return {
...base,
...limit,
};
}
} }

7
ui-ngx/src/app/modules/home/components/widget/lib/settings/common/color-settings-panel.component.ts

@ -34,6 +34,7 @@ import { IAliasController } from '@core/api/widget-api.models';
import { coerceBoolean } from '@shared/decorators/coercion'; import { coerceBoolean } from '@shared/decorators/coercion';
import { DataKeysCallbacks } from '@home/components/widget/lib/settings/common/key/data-keys.component.models'; import { DataKeysCallbacks } from '@home/components/widget/lib/settings/common/key/data-keys.component.models';
import { Datasource } from '@shared/models/widget.models'; import { Datasource } from '@shared/models/widget.models';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
@Component({ @Component({
selector: 'tb-color-settings-panel', selector: 'tb-color-settings-panel',
@ -107,6 +108,12 @@ export class ColorSettingsPanelComponent extends PageComponent implements OnInit
colorFunction: [this.colorSettings?.colorFunction, []] colorFunction: [this.colorSettings?.colorFunction, []]
} }
); );
this.colorSettingsFormGroup.get('type').valueChanges.pipe(
takeUntilDestroyed(this.destroyRef)
).subscribe(() => {
this.updateValidators();
setTimeout(() => {this.popover?.updatePosition();}, 0);
});
this.updateValidators(); this.updateValidators();
} }

4
ui-ngx/src/app/modules/home/pages/asset/asset-tabs.component.html

@ -43,10 +43,6 @@
<mat-tab label="{{ 'alarm.alarms' | translate }}" #alarmsTab="matTab"> <mat-tab label="{{ 'alarm.alarms' | translate }}" #alarmsTab="matTab">
<tb-alarm-table [active]="alarmsTab.isActive" [entityId]="entity.id"></tb-alarm-table> <tb-alarm-table [active]="alarmsTab.isActive" [entityId]="entity.id"></tb-alarm-table>
</mat-tab> </mat-tab>
<mat-tab label="{{ 'asset.events' | translate }}" #eventsTab="matTab">
<tb-event-table [defaultEventType]="eventTypes.ERROR" [active]="eventsTab.isActive" [tenantId]="entity.tenantId.id"
[entityId]="entity.id"></tb-event-table>
</mat-tab>
<mat-tab label="{{ 'relation.relations' | translate }}" #relationsTab="matTab"> <mat-tab label="{{ 'relation.relations' | translate }}" #relationsTab="matTab">
<tb-relation-table [active]="relationsTab.isActive" [entityId]="entity.id"></tb-relation-table> <tb-relation-table [active]="relationsTab.isActive" [entityId]="entity.id"></tb-relation-table>
</mat-tab> </mat-tab>

7
ui-ngx/src/app/modules/home/pages/customer/customer-tabs.component.html

@ -39,13 +39,6 @@
<mat-tab label="{{ 'alarm.alarms' | translate }}" #alarmsTab="matTab"> <mat-tab label="{{ 'alarm.alarms' | translate }}" #alarmsTab="matTab">
<tb-alarm-table [active]="alarmsTab.isActive" [entityId]="entity.id"></tb-alarm-table> <tb-alarm-table [active]="alarmsTab.isActive" [entityId]="entity.id"></tb-alarm-table>
</mat-tab> </mat-tab>
<mat-tab label="{{ 'customer.events' | translate }}" #eventsTab="matTab">
<tb-event-table [defaultEventType]="eventTypes.ERROR"
[active]="eventsTab.isActive"
[tenantId]="entity.tenantId.id"
[entityId]="entity.id">
</tb-event-table>
</mat-tab>
<mat-tab label="{{ 'relation.relations' | translate }}" #relationsTab="matTab"> <mat-tab label="{{ 'relation.relations' | translate }}" #relationsTab="matTab">
<tb-relation-table [active]="relationsTab.isActive" [entityId]="entity.id"></tb-relation-table> <tb-relation-table [active]="relationsTab.isActive" [entityId]="entity.id"></tb-relation-table>
</mat-tab> </mat-tab>

5
ui-ngx/src/app/modules/home/pages/entity-view/entity-view-tabs.component.html

@ -36,11 +36,6 @@
label="{{ 'alarm.alarms' | translate }}" #alarmsTab="matTab"> label="{{ 'alarm.alarms' | translate }}" #alarmsTab="matTab">
<tb-alarm-table [active]="alarmsTab.isActive" [entityId]="entity.id"></tb-alarm-table> <tb-alarm-table [active]="alarmsTab.isActive" [entityId]="entity.id"></tb-alarm-table>
</mat-tab> </mat-tab>
<mat-tab *ngIf="entity"
label="{{ 'entity-view.events' | translate }}" #eventsTab="matTab">
<tb-event-table [defaultEventType]="eventTypes.ERROR" [active]="eventsTab.isActive" [tenantId]="entity.tenantId.id"
[entityId]="entity.id"></tb-event-table>
</mat-tab>
<mat-tab *ngIf="entity" <mat-tab *ngIf="entity"
label="{{ 'relation.relations' | translate }}" #relationsTab="matTab"> label="{{ 'relation.relations' | translate }}" #relationsTab="matTab">
<tb-relation-table [active]="relationsTab.isActive" [entityId]="entity.id"></tb-relation-table> <tb-relation-table [active]="relationsTab.isActive" [entityId]="entity.id"></tb-relation-table>

28
ui-ngx/src/app/shared/components/color-picker/color-input.base.scss

@ -0,0 +1,28 @@
/**
* Copyright © 2016-2026 The Thingsboard Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
:host {
.color-input-container {
display: flex;
gap: 4px;
align-items: center;
}
.color-input {
max-width: 72px;
margin-bottom: 4px;
}
}

2
ui-ngx/src/app/shared/components/color-picker/color-picker-panel.component.scss

@ -16,7 +16,7 @@
@import "../scss/constants"; @import "../scss/constants";
.tb-color-picker-panel { .tb-color-picker-panel {
width: 342px; width: 370px;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
max-height: calc(100vh - 24px); max-height: calc(100vh - 24px);

6
ui-ngx/src/app/shared/components/color-picker/color-picker.component.html

@ -37,10 +37,8 @@
<mat-option [value]="2">HSLA</mat-option> <mat-option [value]="2">HSLA</mat-option>
</mat-select> </mat-select>
<div class="color-input" [ngSwitch]="presentations[presentationControl.value]"> <div class="color-input" [ngSwitch]="presentations[presentationControl.value]">
<rgba-input-component *ngSwitchCase="'rgba'" label <tb-rgba-input *ngSwitchCase="'rgba'" labelVisible [(color)]="control.value"></tb-rgba-input>
[(color)]="control.value"></rgba-input-component> <tb-hsla-input *ngSwitchCase="'hsla'" labelVisible [(color)]="control.value"></tb-hsla-input>
<hsla-input-component *ngSwitchCase="'hsla'" label
[(color)]="control.value"></hsla-input-component>
<tb-hex-input *ngSwitchCase="'hex'" [(color)]="control.value"></tb-hex-input> <tb-hex-input *ngSwitchCase="'hex'" [(color)]="control.value"></tb-hex-input>
</div> </div>
</div> </div>

4
ui-ngx/src/app/shared/components/color-picker/color-picker.component.scss

@ -83,7 +83,7 @@
height: 56px; height: 56px;
display: flex; display: flex;
align-items: center; align-items: center;
gap: 20px; gap: 8px;
.presentation-select { .presentation-select {
font-size: 14px; font-size: 14px;
@ -104,7 +104,7 @@
display: flex; display: flex;
flex-direction: row; flex-direction: row;
flex-wrap: wrap; flex-wrap: wrap;
justify-content: space-between; justify-content: center;
gap: 8px; gap: 8px;
@media #{$mat-xs} { @media #{$mat-xs} {
flex-direction: column; flex-direction: column;

6
ui-ngx/src/app/shared/components/color-picker/hex-input.component.scss

@ -19,11 +19,11 @@
gap: 8px; gap: 8px;
} }
.hex-input { .hex-input {
max-width: 190px; max-width: 220px;
} }
.alpha-input { .alpha-input {
min-width: 60px; min-width: 72px;
max-width: 60px; max-width: 72px;
} }
::ng-deep { ::ng-deep {

54
ui-ngx/src/app/shared/components/color-picker/hsla-input.component.html

@ -0,0 +1,54 @@
<!--
Copyright © 2016-2026 The Thingsboard Authors
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
-->
<div class="color-input-container">
<div class="tb-form-row tb-flex no-gap no-border no-padding column align-center">
<mat-form-field class="color-input number" appearance="outline" subscriptSizing="dynamic">
<input matInput type="number" [min]="0" [max]="360" [value]="value.getHue()" (inputChange)="onInputChange($event, 'H')"/>
</mat-form-field>
@if (labelVisible) {
<span>H</span>
}
</div>
<div class="tb-form-row tb-flex no-gap no-border no-padding column align-center">
<mat-form-field class="color-input number" appearance="outline" subscriptSizing="dynamic">
<input matInput type="number" [min]="0" [max]="100" [value]="value.getSaturation()" (inputChange)="onInputChange($event, 'S')" />
<span matSuffix>{{suffixValue}}</span>
</mat-form-field>
@if (labelVisible) {
<span>S</span>
}
</div>
<div class="tb-form-row tb-flex no-gap no-border no-padding column align-center">
<mat-form-field class="color-input number" appearance="outline" subscriptSizing="dynamic">
<input matInput type="number" [min]="0" [max]="100" [value]="value.getLightness()" (inputChange)="onInputChange($event,'L')" />
<span matSuffix>{{suffixValue}}</span>
</mat-form-field>
@if (labelVisible) {
<span>L</span>
}
</div>
<div class="tb-form-row tb-flex no-gap no-border no-padding column align-center">
<mat-form-field class="color-input number" appearance="outline" subscriptSizing="dynamic">
<input matInput type="number" [min]="0" [max]="100" [value]="alphaValue" (inputChange)="onAlphaInputChange($event)" />
<span matSuffix>{{suffixValue}}</span>
</mat-form-field>
@if (labelVisible) {
<span>A</span>
}
</div>
</div>

72
ui-ngx/src/app/shared/components/color-picker/hsla-input.component.ts

@ -0,0 +1,72 @@
///
/// Copyright © 2016-2026 The Thingsboard Authors
///
/// Licensed under the Apache License, Version 2.0 (the "License");
/// you may not use this file except in compliance with the License.
/// You may obtain a copy of the License at
///
/// http://www.apache.org/licenses/LICENSE-2.0
///
/// Unless required by applicable law or agreed to in writing, software
/// distributed under the License is distributed on an "AS IS" BASIS,
/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
/// See the License for the specific language governing permissions and
/// limitations under the License.
///
import { Component, EventEmitter, Input, Output } from '@angular/core';
import { Color } from '@iplab/ngx-color-picker';
import { coerceBoolean } from '@shared/decorators/coercion';
type Channel = 'H' | 'S' | 'L';
@Component({
selector: 'tb-hsla-input',
templateUrl: './hsla-input.component.html',
styleUrl: './color-input.base.scss',
standalone: false
})
export class HslaInputComponent {
@Input()
public color: Color;
@Output()
public colorChange = new EventEmitter<Color>(false);
@Input()
@coerceBoolean()
public labelVisible = false;
@Input()
public suffixValue = '%';
public get value() {
return this.color.getHsla();
}
public get alphaValue(): number {
return this.color ? Math.round(this.color.getHsla().getAlpha() * 100) : 0;
}
public onAlphaInputChange(inputValue: number): void {
if (!this.color) return;
const hsla = this.color.getHsla();
const alpha = +inputValue / 100;
if (hsla.alpha !== alpha) {
const newColor = new Color().setHsla(hsla.getHue(), hsla.getSaturation(), hsla.getLightness(), alpha);
this.colorChange.emit(newColor);
}
}
public onInputChange(newValue: number, channel: Channel): void {
if (!this.color) return;
const hsla = this.value;
const hue = channel === 'H' ? +newValue : hsla.getHue();
const saturation = channel === 'S' ? +newValue : hsla.getSaturation();
const lightness = channel === 'L' ? +newValue : hsla.getLightness();
if (hue === hsla.getHue() && saturation === hsla.getSaturation() && lightness === hsla.getLightness()) return;
const newColor = new Color().setHsla(hue, saturation, lightness, hsla.getAlpha());
this.colorChange.emit(newColor);
}
}

46
ui-ngx/src/app/shared/components/color-picker/input-change.directive.ts

@ -0,0 +1,46 @@
///
/// Copyright © 2016-2026 The Thingsboard Authors
///
/// Licensed under the Apache License, Version 2.0 (the "License");
/// you may not use this file except in compliance with the License.
/// You may obtain a copy of the License at
///
/// http://www.apache.org/licenses/LICENSE-2.0
///
/// Unless required by applicable law or agreed to in writing, software
/// distributed under the License is distributed on an "AS IS" BASIS,
/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
/// See the License for the specific language governing permissions and
/// limitations under the License.
///
import { Directive, EventEmitter, HostBinding, HostListener, Input, numberAttribute, Output } from '@angular/core';
@Directive({
selector: '[inputChange]',
standalone: false
})
export class InputChangeDirective {
@Input({transform: numberAttribute})
@HostBinding('attr.min')
min = 0;
@Input({transform: numberAttribute})
@HostBinding('attr.max')
max = 255;
@Output()
public inputChange = new EventEmitter<number>();
@HostListener('input', ['$event'])
public inputChanges(event: any): void {
const element = event.target as HTMLInputElement || event.srcElement as HTMLInputElement;
const value = element.value;
const numeric = parseFloat(value);
if (!isNaN(numeric) && numeric >= this.min && numeric <= this.max) {
this.inputChange.emit(numeric);
}
}
}

Some files were not shown because too many files changed in this diff

Loading…
Cancel
Save