Browse Source

Merge branch 'master' into master

pull/14910/head
Jake 5 months ago
committed by GitHub
parent
commit
59fc54885d
No known key found for this signature in database GPG Key ID: B5690EEEBB952194
  1. 4
      application/src/main/java/org/thingsboard/server/controller/SystemInfoController.java
  2. 2
      application/src/main/java/org/thingsboard/server/service/cf/ctx/state/alarm/AlarmCalculatedFieldState.java
  3. 2
      application/src/main/java/org/thingsboard/server/service/install/DefaultSystemDataLoaderService.java
  4. 4
      application/src/main/java/org/thingsboard/server/service/query/DefaultEntityQueryService.java
  5. 2
      application/src/main/java/org/thingsboard/server/service/subscription/TbAlarmCountSubCtx.java
  6. 2
      application/src/main/java/org/thingsboard/server/service/subscription/TbAlarmDataSubCtx.java
  7. 4
      application/src/main/resources/thingsboard.yml
  8. 2
      application/src/test/java/org/thingsboard/server/cf/AlarmRulesTest.java
  9. 4
      application/src/test/java/org/thingsboard/server/client/DomainApiClientTest.java
  10. 4
      application/src/test/java/org/thingsboard/server/client/Oauth2ApiClientTest.java
  11. 497
      application/src/test/java/org/thingsboard/server/controller/DeviceProfileControllerTest.java
  12. 527
      application/src/test/java/org/thingsboard/server/controller/EntityQueryControllerTest.java
  13. 8
      application/src/test/java/org/thingsboard/server/edge/UserEdgeTest.java
  14. 1
      common/data/src/main/java/org/thingsboard/server/common/data/SystemParams.java
  15. 1
      common/data/src/main/java/org/thingsboard/server/common/data/alarm/rule/condition/expression/AlarmConditionFilter.java
  16. 1
      common/data/src/main/java/org/thingsboard/server/common/data/alarm/rule/condition/expression/SimpleAlarmConditionExpression.java
  17. 2
      common/data/src/main/java/org/thingsboard/server/common/data/alarm/rule/condition/expression/predicate/ComplexFilterPredicate.java
  18. 12
      common/data/src/main/java/org/thingsboard/server/common/data/query/AbstractDataQuery.java
  19. 4
      common/data/src/main/java/org/thingsboard/server/common/data/query/AlarmCountQuery.java
  20. 7
      common/data/src/main/java/org/thingsboard/server/common/data/query/AlarmDataQuery.java
  21. 5
      common/data/src/main/java/org/thingsboard/server/common/data/query/ComplexFilterPredicate.java
  22. 2
      common/data/src/main/java/org/thingsboard/server/common/data/query/ComplexOperation.java
  23. 13
      common/data/src/main/java/org/thingsboard/server/common/data/query/EntityCountQuery.java
  24. 6
      common/data/src/main/java/org/thingsboard/server/common/data/query/EntityDataQuery.java
  25. 5
      common/edqs/src/main/java/org/thingsboard/server/edqs/data/dp/StringDataPoint.java
  26. 5
      common/edqs/src/main/java/org/thingsboard/server/edqs/query/EdqsCountQuery.java
  27. 5
      common/edqs/src/main/java/org/thingsboard/server/edqs/query/EdqsDataQuery.java
  28. 2
      common/edqs/src/main/java/org/thingsboard/server/edqs/query/EdqsQuery.java
  29. 84
      common/edqs/src/main/java/org/thingsboard/server/edqs/util/RepositoryUtils.java
  30. 16
      dao/src/main/java/org/thingsboard/server/dao/entity/BaseEntityService.java
  31. 103
      dao/src/main/java/org/thingsboard/server/dao/sql/query/DefaultEntityQueryRepository.java
  32. 161
      dao/src/main/java/org/thingsboard/server/dao/sql/query/EntityKeyMapping.java
  33. 47
      dao/src/test/java/org/thingsboard/server/dao/TbTimescaleDBContainerProvider.java
  34. 1
      dao/src/test/resources/META-INF/services/org.testcontainers.containers.JdbcDatabaseContainerProvider
  35. 2
      dao/src/test/resources/nosql-test.properties
  36. 2
      dao/src/test/resources/sql-test.properties
  37. 2
      dao/src/test/resources/timescale-test.properties
  38. 57
      edqs/src/test/java/org/thingsboard/server/edqs/repo/DeviceTypeFilterTest.java
  39. 163
      edqs/src/test/java/org/thingsboard/server/edqs/repo/RepositoryUtilsTest.java
  40. 9
      monitoring/pom.xml
  41. 10
      monitoring/src/main/java/org/thingsboard/monitoring/config/transport/TransportInfo.java
  42. 3
      monitoring/src/main/java/org/thingsboard/monitoring/data/MonitoredServiceKey.java
  43. 34
      monitoring/src/main/java/org/thingsboard/monitoring/data/notification/AffectedService.java
  44. 9
      monitoring/src/main/java/org/thingsboard/monitoring/data/notification/HighLatencyNotification.java
  45. 5
      monitoring/src/main/java/org/thingsboard/monitoring/data/notification/InfoNotification.java
  46. 10
      monitoring/src/main/java/org/thingsboard/monitoring/data/notification/Notification.java
  47. 62
      monitoring/src/main/java/org/thingsboard/monitoring/data/notification/ServiceFailureNotification.java
  48. 7
      monitoring/src/main/java/org/thingsboard/monitoring/data/notification/ServiceRecoveryNotification.java
  49. 14
      monitoring/src/main/java/org/thingsboard/monitoring/data/notification/ShortNameProvider.java
  50. 2
      monitoring/src/main/java/org/thingsboard/monitoring/notification/NotificationService.java
  51. 4
      monitoring/src/main/java/org/thingsboard/monitoring/notification/channels/NotificationChannel.java
  52. 125
      monitoring/src/main/java/org/thingsboard/monitoring/notification/channels/impl/SlackApiClient.java
  53. 45
      monitoring/src/main/java/org/thingsboard/monitoring/notification/channels/impl/SlackIncidentTransport.java
  54. 69
      monitoring/src/main/java/org/thingsboard/monitoring/notification/channels/impl/SlackNotificationChannel.java
  55. 292
      monitoring/src/main/java/org/thingsboard/monitoring/notification/incident/IncidentManager.java
  56. 26
      monitoring/src/main/java/org/thingsboard/monitoring/notification/incident/IncidentTransport.java
  57. 3
      monitoring/src/main/java/org/thingsboard/monitoring/service/BaseHealthChecker.java
  58. 54
      monitoring/src/main/java/org/thingsboard/monitoring/service/BaseMonitoringService.java
  59. 5
      monitoring/src/main/java/org/thingsboard/monitoring/service/MonitoringReporter.java
  60. 13
      monitoring/src/main/resources/tb-monitoring.yml
  61. 97
      monitoring/src/test/java/org/thingsboard/monitoring/data/notification/ServiceFailureNotificationTest.java
  62. 183
      monitoring/src/test/java/org/thingsboard/monitoring/notification/incident/IncidentManagerTest.java
  63. 6
      pom.xml
  64. 3
      ui-ngx/src/app/core/api/alarm-data-subscription.ts
  65. 1
      ui-ngx/src/app/core/api/alarm-data.service.ts
  66. 10
      ui-ngx/src/app/core/api/alias-controller.ts
  67. 10
      ui-ngx/src/app/core/api/entity-data-subscription.ts
  68. 1
      ui-ngx/src/app/core/api/entity-data.service.ts
  69. 1
      ui-ngx/src/app/core/auth/auth.models.ts
  70. 3
      ui-ngx/src/app/core/auth/auth.reducer.ts
  71. 23
      ui-ngx/src/app/modules/home/components/filter/complex-filter-predicate-dialog.component.html
  72. 77
      ui-ngx/src/app/modules/home/components/filter/filter-dialog.component.html
  73. 87
      ui-ngx/src/app/modules/home/components/filter/filter-dialog.component.ts
  74. 112
      ui-ngx/src/app/modules/home/components/filter/filter-predicate-list.component.html
  75. 42
      ui-ngx/src/app/modules/home/components/filter/filter-predicate-list.component.scss
  76. 121
      ui-ngx/src/app/modules/home/components/filter/filter-predicate-value.component.html
  77. 101
      ui-ngx/src/app/modules/home/components/filter/filter-predicate.component.html
  78. 142
      ui-ngx/src/app/modules/home/components/filter/filter-predicate.component.ts
  79. 65
      ui-ngx/src/app/modules/home/components/filter/filter-text.component.ts
  80. 111
      ui-ngx/src/app/modules/home/components/filter/filters-dialog.component.html
  81. 42
      ui-ngx/src/app/modules/home/components/filter/filters-dialog.component.scss
  82. 11
      ui-ngx/src/app/modules/home/components/filter/filters-dialog.component.ts
  83. 205
      ui-ngx/src/app/modules/home/components/filter/key-filter-dialog.component.html
  84. 148
      ui-ngx/src/app/modules/home/components/filter/key-filter-list.component.html
  85. 51
      ui-ngx/src/app/modules/home/components/filter/key-filter-list.component.scss
  86. 126
      ui-ngx/src/app/modules/home/components/filter/key-filter-list.component.ts
  87. 81
      ui-ngx/src/app/modules/home/components/filter/user-filter-dialog.component.html
  88. 6
      ui-ngx/src/app/modules/home/components/filter/user-filter-dialog.component.scss
  89. 68
      ui-ngx/src/app/modules/home/components/filter/user-filter-dialog.component.ts
  90. 2
      ui-ngx/src/app/shared/components/time/datetime.component.ts
  91. 18
      ui-ngx/src/app/shared/models/query/query.models.ts
  92. 2
      ui-ngx/src/app/shared/models/widget.models.ts
  93. 1
      ui-ngx/src/assets/locale/locale.constant-en_US.json

4
application/src/main/java/org/thingsboard/server/controller/SystemInfoController.java

@ -76,6 +76,9 @@ public class SystemInfoController extends BaseController {
@Value("${debug.settings.default_duration:15}")
private int defaultDebugDurationMinutes;
@Value("${sql.query.key-filters-or-conditions.enabled:true}")
private boolean keyFiltersOrConditionsEnabled;
@Autowired(required = false)
private BuildProperties buildProperties;
@ -173,6 +176,7 @@ public class SystemInfoController extends BaseController {
systemParams.setMobileQrEnabled(Optional.ofNullable(qrCodeSettingService.findQrCodeSettings(TenantId.SYS_TENANT_ID))
.map(QrCodeSettings::getQrCodeConfig).map(QRCodeConfig::isShowOnHomePage)
.orElse(false));
systemParams.setAllowKeyFiltersOrConditions(keyFiltersOrConditionsEnabled);
return systemParams;
}

2
application/src/main/java/org/thingsboard/server/service/cf/ctx/state/alarm/AlarmCalculatedFieldState.java

@ -40,7 +40,7 @@ import org.thingsboard.server.common.data.alarm.rule.condition.AlarmConditionTyp
import org.thingsboard.server.common.data.alarm.rule.condition.AlarmConditionValue;
import org.thingsboard.server.common.data.alarm.rule.condition.expression.AlarmConditionExpression;
import org.thingsboard.server.common.data.alarm.rule.condition.expression.AlarmConditionFilter;
import org.thingsboard.server.common.data.alarm.rule.condition.expression.ComplexOperation;
import org.thingsboard.server.common.data.query.ComplexOperation;
import org.thingsboard.server.common.data.alarm.rule.condition.expression.SimpleAlarmConditionExpression;
import org.thingsboard.server.common.data.alarm.rule.condition.expression.TbelAlarmConditionExpression;
import org.thingsboard.server.common.data.alarm.rule.condition.expression.predicate.BooleanFilterPredicate;

2
application/src/main/java/org/thingsboard/server/service/install/DefaultSystemDataLoaderService.java

@ -53,7 +53,7 @@ import org.thingsboard.server.common.data.alarm.rule.AlarmRule;
import org.thingsboard.server.common.data.alarm.rule.condition.AlarmConditionValue;
import org.thingsboard.server.common.data.alarm.rule.condition.SimpleAlarmCondition;
import org.thingsboard.server.common.data.alarm.rule.condition.expression.AlarmConditionFilter;
import org.thingsboard.server.common.data.alarm.rule.condition.expression.ComplexOperation;
import org.thingsboard.server.common.data.query.ComplexOperation;
import org.thingsboard.server.common.data.alarm.rule.condition.expression.SimpleAlarmConditionExpression;
import org.thingsboard.server.common.data.alarm.rule.condition.expression.predicate.BooleanFilterPredicate;
import org.thingsboard.server.common.data.alarm.rule.condition.expression.predicate.NumericFilterPredicate;

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

@ -221,7 +221,7 @@ public class DefaultEntityQueryService implements EntityQueryService {
private EntityDataQuery buildEntityDataQuery(AlarmCountQuery query) {
EntityDataPageLink edpl = new EntityDataPageLink(maxEntitiesPerAlarmSubscription, 0, null,
new EntityDataSortOrder(new EntityKey(EntityKeyType.ENTITY_FIELD, EntityKeyMapping.CREATED_TIME)));
return new EntityDataQuery(query.getEntityFilter(), edpl, null, null, query.getKeyFilters());
return new EntityDataQuery(query.getEntityFilter(), edpl, null, null, query.getKeyFilters(), query.getKeyFiltersOperationOrDefault());
}
private EntityDataQuery buildEntityDataQuery(AlarmDataQuery query) {
@ -233,7 +233,7 @@ public class DefaultEntityQueryService implements EntityQueryService {
entitiesSortOrder = sortOrder;
}
EntityDataPageLink edpl = new EntityDataPageLink(maxEntitiesPerAlarmSubscription, 0, null, entitiesSortOrder);
return new EntityDataQuery(query.getEntityFilter(), edpl, query.getEntityFields(), query.getLatestValues(), query.getKeyFilters());
return new EntityDataQuery(query.getEntityFilter(), edpl, query.getEntityFields(), query.getLatestValues(), query.getKeyFilters(), query.getKeyFiltersOperationOrDefault());
}
@Override

2
application/src/main/java/org/thingsboard/server/service/subscription/TbAlarmCountSubCtx.java

@ -130,7 +130,7 @@ public class TbAlarmCountSubCtx extends TbAbstractEntityQuerySubCtx<AlarmCountQu
private EntityDataQuery buildEntityDataQuery() {
EntityDataPageLink edpl = new EntityDataPageLink(maxEntitiesPerAlarmSubscription, 0, null,
new EntityDataSortOrder(new EntityKey(EntityKeyType.ENTITY_FIELD, ModelConstants.CREATED_TIME_PROPERTY)));
return new EntityDataQuery(query.getEntityFilter(), edpl, null, null, query.getKeyFilters());
return new EntityDataQuery(query.getEntityFilter(), edpl, null, null, query.getKeyFilters(), query.getKeyFiltersOperationOrDefault());
}
private void resetInvocationCounter() {

2
application/src/main/java/org/thingsboard/server/service/subscription/TbAlarmDataSubCtx.java

@ -363,7 +363,7 @@ public class TbAlarmDataSubCtx extends TbAbstractDataSubCtx<AlarmDataQuery> {
entitiesSortOrder = sortOrder;
}
EntityDataPageLink edpl = new EntityDataPageLink(maxEntitiesPerAlarmSubscription, 0, null, entitiesSortOrder);
return new EntityDataQuery(query.getEntityFilter(), edpl, query.getEntityFields(), query.getLatestValues(), query.getKeyFilters());
return new EntityDataQuery(query.getEntityFilter(), edpl, query.getEntityFields(), query.getLatestValues(), query.getKeyFilters(), query.getKeyFiltersOperationOrDefault());
}
}

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

@ -469,6 +469,10 @@ sql:
log_tenant_stats: "${SQL_LOG_TENANT_STATS:true}"
# Interval in milliseconds for printing the latest statistic information about the tenant
log_tenant_stats_interval_ms: "${SQL_LOG_TENANT_STATS_INTERVAL_MS:60000}"
query:
key-filters-or-conditions:
# Enable/disable OR conditions between key filters in entity data queries
enabled: "${SQL_QUERY_KEY_FILTERS_OR_CONDITIONS_ENABLED:true}"
postgres:
# Specify partitioning size for timestamp key-value storage. Example: DAYS, MONTHS, YEARS, INDEFINITE.
ts_key_value_partitioning: "${SQL_POSTGRES_TS_KV_PARTITIONING:MONTHS}"

2
application/src/test/java/org/thingsboard/server/cf/AlarmRulesTest.java

@ -38,7 +38,7 @@ import org.thingsboard.server.common.data.alarm.rule.condition.RepeatingAlarmCon
import org.thingsboard.server.common.data.alarm.rule.condition.SimpleAlarmCondition;
import org.thingsboard.server.common.data.alarm.rule.condition.expression.AlarmConditionExpression;
import org.thingsboard.server.common.data.alarm.rule.condition.expression.AlarmConditionFilter;
import org.thingsboard.server.common.data.alarm.rule.condition.expression.ComplexOperation;
import org.thingsboard.server.common.data.query.ComplexOperation;
import org.thingsboard.server.common.data.alarm.rule.condition.expression.SimpleAlarmConditionExpression;
import org.thingsboard.server.common.data.alarm.rule.condition.expression.TbelAlarmConditionExpression;
import org.thingsboard.server.common.data.alarm.rule.condition.expression.predicate.NoDataFilterPredicate;

4
application/src/test/java/org/thingsboard/server/client/DomainApiClientTest.java

@ -69,7 +69,7 @@ public class DomainApiClientTest extends AbstractApiClientTest {
}
// list tenant domains with text search
PageDataDomainInfo filteredDomains = client.getTenantDomainInfos(100, 0,
PageDataDomainInfo filteredDomains = client.getDomainInfos(100, 0,
"domain.", null, null);
assertNotNull(filteredDomains);
assertEquals(5, filteredDomains.getData().size());
@ -97,7 +97,7 @@ public class DomainApiClientTest extends AbstractApiClientTest {
client.getDomainInfoById(domainToDeleteId)
);
PageDataDomainInfo domainsAfterDelete = client.getTenantDomainInfos(100, 0,
PageDataDomainInfo domainsAfterDelete = client.getDomainInfos(100, 0,
"domain.", null, null);
assertEquals(4, domainsAfterDelete.getData().size());
}

4
application/src/test/java/org/thingsboard/server/client/Oauth2ApiClientTest.java

@ -90,7 +90,7 @@ public class Oauth2ApiClientTest extends AbstractApiClientTest {
}
// list tenant OAuth2 client infos
PageDataOAuth2ClientInfo clientInfos = client.findTenantOAuth2ClientInfos(100, 0,
PageDataOAuth2ClientInfo clientInfos = client.findOAuth2ClientInfos(100, 0,
TEST_PREFIX + "OAuth2_" + timestamp, null, null);
assertNotNull(clientInfos);
assertEquals(5, clientInfos.getData().size());
@ -130,7 +130,7 @@ public class Oauth2ApiClientTest extends AbstractApiClientTest {
client.getOAuth2ClientById(clientToDeleteId)
);
PageDataOAuth2ClientInfo clientsAfterDelete = client.findTenantOAuth2ClientInfos(100, 0,
PageDataOAuth2ClientInfo clientsAfterDelete = client.findOAuth2ClientInfos(100, 0,
TEST_PREFIX + "OAuth2_" + timestamp, null, null);
assertEquals(4, clientsAfterDelete.getData().size());
}

497
application/src/test/java/org/thingsboard/server/controller/DeviceProfileControllerTest.java

@ -16,6 +16,7 @@
package org.thingsboard.server.controller;
import com.fasterxml.jackson.core.type.TypeReference;
import org.awaitility.Awaitility;
import org.junit.After;
import org.junit.Assert;
import org.junit.Before;
@ -52,8 +53,8 @@ import org.thingsboard.server.common.data.page.PageLink;
import org.thingsboard.server.common.data.rule.RuleChain;
import org.thingsboard.server.common.data.security.Authority;
import org.thingsboard.server.dao.device.DeviceProfileDao;
import org.thingsboard.server.exception.DataValidationException;
import org.thingsboard.server.dao.service.DaoSqlTest;
import org.thingsboard.server.exception.DataValidationException;
import java.util.ArrayList;
import java.util.Arrays;
@ -62,6 +63,7 @@ import java.util.Comparator;
import java.util.List;
import java.util.Map;
import java.util.UUID;
import java.util.concurrent.TimeUnit;
import java.util.function.Function;
import java.util.stream.Collectors;
@ -93,6 +95,7 @@ public class DeviceProfileControllerTest extends AbstractControllerTest {
public DeviceProfileDao deviceProfileDao(DeviceProfileDao deviceProfileDao) {
return Mockito.mock(DeviceProfileDao.class, AdditionalAnswers.delegatesTo(deviceProfileDao));
}
}
@Before
@ -640,364 +643,364 @@ public class DeviceProfileControllerTest extends AbstractControllerTest {
@Test
public void testSaveProtoDeviceProfileWithInvalidProtoFile() throws Exception {
testSaveDeviceProfileWithInvalidProtoSchema("syntax = \"proto3\";\n" +
"\n" +
"package schemavalidation;\n" +
"\n" +
"message SchemaValidationTest {\n" +
" required int32 parameter = 1;\n" +
"}", "[Transport Configuration] failed to parse attributes proto schema due to: Syntax error in :6:4: 'required' label forbidden in proto3 field declarations");
"\n" +
"package schemavalidation;\n" +
"\n" +
"message SchemaValidationTest {\n" +
" required int32 parameter = 1;\n" +
"}", "[Transport Configuration] failed to parse attributes proto schema due to: Syntax error in :6:4: 'required' label forbidden in proto3 field declarations");
}
@Test
public void testSaveProtoDeviceProfileWithInvalidProtoSyntax() throws Exception {
testSaveDeviceProfileWithInvalidProtoSchema("syntax = \"proto2\";\n" +
"\n" +
"package schemavalidation;\n" +
"\n" +
"message SchemaValidationTest {\n" +
" required int32 parameter = 1;\n" +
"}", "[Transport Configuration] invalid schema syntax: proto2 for attributes proto schema provided! Only proto3 allowed!");
"\n" +
"package schemavalidation;\n" +
"\n" +
"message SchemaValidationTest {\n" +
" required int32 parameter = 1;\n" +
"}", "[Transport Configuration] invalid schema syntax: proto2 for attributes proto schema provided! Only proto3 allowed!");
}
@Test
public void testSaveProtoDeviceProfileOptionsNotSupported() throws Exception {
testSaveDeviceProfileWithInvalidProtoSchema("syntax = \"proto3\";\n" +
"\n" +
"option java_package = \"com.test.schemavalidation\";\n" +
"option java_multiple_files = true;\n" +
"\n" +
"package schemavalidation;\n" +
"\n" +
"message SchemaValidationTest {\n" +
" optional int32 parameter = 1;\n" +
"}", "[Transport Configuration] invalid attributes proto schema provided! Schema options don't support!");
"\n" +
"option java_package = \"com.test.schemavalidation\";\n" +
"option java_multiple_files = true;\n" +
"\n" +
"package schemavalidation;\n" +
"\n" +
"message SchemaValidationTest {\n" +
" optional int32 parameter = 1;\n" +
"}", "[Transport Configuration] invalid attributes proto schema provided! Schema options don't support!");
}
@Test
public void testSaveProtoDeviceProfilePublicImportsNotSupported() throws Exception {
testSaveDeviceProfileWithInvalidProtoSchema("syntax = \"proto3\";\n" +
"\n" +
"import public \"oldschema.proto\";\n" +
"\n" +
"package schemavalidation;\n" +
"\n" +
"message SchemaValidationTest {\n" +
" optional int32 parameter = 1;\n" +
"}", "[Transport Configuration] invalid attributes proto schema provided! Schema public imports don't support!");
"\n" +
"import public \"oldschema.proto\";\n" +
"\n" +
"package schemavalidation;\n" +
"\n" +
"message SchemaValidationTest {\n" +
" optional int32 parameter = 1;\n" +
"}", "[Transport Configuration] invalid attributes proto schema provided! Schema public imports don't support!");
}
@Test
public void testSaveProtoDeviceProfileImportsNotSupported() throws Exception {
testSaveDeviceProfileWithInvalidProtoSchema("syntax = \"proto3\";\n" +
"\n" +
"import \"oldschema.proto\";\n" +
"\n" +
"package schemavalidation;\n" +
"\n" +
"message SchemaValidationTest {\n" +
" optional int32 parameter = 1;\n" +
"}", "[Transport Configuration] invalid attributes proto schema provided! Schema imports don't support!");
"\n" +
"import \"oldschema.proto\";\n" +
"\n" +
"package schemavalidation;\n" +
"\n" +
"message SchemaValidationTest {\n" +
" optional int32 parameter = 1;\n" +
"}", "[Transport Configuration] invalid attributes proto schema provided! Schema imports don't support!");
}
@Test
public void testSaveProtoDeviceProfileExtendDeclarationsNotSupported() throws Exception {
testSaveDeviceProfileWithInvalidProtoSchema("syntax = \"proto3\";\n" +
"\n" +
"package schemavalidation;\n" +
"\n" +
"extend google.protobuf.MethodOptions {\n" +
" MyMessage my_method_option = 50007;\n" +
"}", "[Transport Configuration] invalid attributes proto schema provided! Schema extend declarations don't support!");
"\n" +
"package schemavalidation;\n" +
"\n" +
"extend google.protobuf.MethodOptions {\n" +
" MyMessage my_method_option = 50007;\n" +
"}", "[Transport Configuration] invalid attributes proto schema provided! Schema extend declarations don't support!");
}
@Test
public void testSaveProtoDeviceProfileEnumOptionsNotSupported() throws Exception {
testSaveDeviceProfileWithInvalidProtoSchema("syntax = \"proto3\";\n" +
"\n" +
"package schemavalidation;\n" +
"\n" +
"enum testEnum {\n" +
" option allow_alias = true;\n" +
" DEFAULT = 0;\n" +
" STARTED = 1;\n" +
" RUNNING = 2;\n" +
"}\n" +
"\n" +
"message testMessage {\n" +
" optional int32 parameter = 1;\n" +
"}", "[Transport Configuration] invalid attributes proto schema provided! Enum definitions options are not supported!");
"\n" +
"package schemavalidation;\n" +
"\n" +
"enum testEnum {\n" +
" option allow_alias = true;\n" +
" DEFAULT = 0;\n" +
" STARTED = 1;\n" +
" RUNNING = 2;\n" +
"}\n" +
"\n" +
"message testMessage {\n" +
" optional int32 parameter = 1;\n" +
"}", "[Transport Configuration] invalid attributes proto schema provided! Enum definitions options are not supported!");
}
@Test
public void testSaveProtoDeviceProfileNoOneMessageTypeExists() throws Exception {
testSaveDeviceProfileWithInvalidProtoSchema("syntax = \"proto3\";\n" +
"\n" +
"package schemavalidation;\n" +
"\n" +
"enum testEnum {\n" +
" DEFAULT = 0;\n" +
" STARTED = 1;\n" +
" RUNNING = 2;\n" +
"}", "[Transport Configuration] invalid attributes proto schema provided! At least one Message definition should exists!");
"\n" +
"package schemavalidation;\n" +
"\n" +
"enum testEnum {\n" +
" DEFAULT = 0;\n" +
" STARTED = 1;\n" +
" RUNNING = 2;\n" +
"}", "[Transport Configuration] invalid attributes proto schema provided! At least one Message definition should exists!");
}
@Test
public void testSaveProtoDeviceProfileMessageTypeOptionsNotSupported() throws Exception {
testSaveDeviceProfileWithInvalidProtoSchema("syntax = \"proto3\";\n" +
"\n" +
"package schemavalidation;\n" +
"\n" +
"message testMessage {\n" +
" option allow_alias = true;\n" +
" optional int32 parameter = 1;\n" +
"}", "[Transport Configuration] invalid attributes proto schema provided! Message definition options don't support!");
"\n" +
"package schemavalidation;\n" +
"\n" +
"message testMessage {\n" +
" option allow_alias = true;\n" +
" optional int32 parameter = 1;\n" +
"}", "[Transport Configuration] invalid attributes proto schema provided! Message definition options don't support!");
}
@Test
public void testSaveProtoDeviceProfileMessageTypeExtensionsNotSupported() throws Exception {
testSaveDeviceProfileWithInvalidProtoSchema("syntax = \"proto3\";\n" +
"\n" +
"package schemavalidation;\n" +
"\n" +
"message TestMessage {\n" +
" extensions 100 to 199;\n" +
"}", "[Transport Configuration] invalid attributes proto schema provided! Message definition extensions don't support!");
"\n" +
"package schemavalidation;\n" +
"\n" +
"message TestMessage {\n" +
" extensions 100 to 199;\n" +
"}", "[Transport Configuration] invalid attributes proto schema provided! Message definition extensions don't support!");
}
@Test
public void testSaveProtoDeviceProfileMessageTypeReservedElementsNotSupported() throws Exception {
testSaveDeviceProfileWithInvalidProtoSchema("syntax = \"proto3\";\n" +
"\n" +
"package schemavalidation;\n" +
"\n" +
"message Foo {\n" +
" reserved 2, 15, 9 to 11;\n" +
" reserved \"foo\", \"bar\";\n" +
"}", "[Transport Configuration] invalid attributes proto schema provided! Message definition reserved elements don't support!");
"\n" +
"package schemavalidation;\n" +
"\n" +
"message Foo {\n" +
" reserved 2, 15, 9 to 11;\n" +
" reserved \"foo\", \"bar\";\n" +
"}", "[Transport Configuration] invalid attributes proto schema provided! Message definition reserved elements don't support!");
}
@Test
public void testSaveProtoDeviceProfileMessageTypeGroupsElementsNotSupported() throws Exception {
testSaveDeviceProfileWithInvalidProtoSchema("syntax = \"proto3\";\n" +
"\n" +
"package schemavalidation;\n" +
"\n" +
"message TestMessage {\n" +
" repeated group Result = 1 {\n" +
" optional string url = 2;\n" +
" optional string title = 3;\n" +
" repeated string snippets = 4;\n" +
" }\n" +
"}", "[Transport Configuration] invalid attributes proto schema provided! Message definition groups don't support!");
"\n" +
"package schemavalidation;\n" +
"\n" +
"message TestMessage {\n" +
" repeated group Result = 1 {\n" +
" optional string url = 2;\n" +
" optional string title = 3;\n" +
" repeated string snippets = 4;\n" +
" }\n" +
"}", "[Transport Configuration] invalid attributes proto schema provided! Message definition groups don't support!");
}
@Test
public void testSaveProtoDeviceProfileOneOfsGroupsElementsNotSupported() throws Exception {
testSaveDeviceProfileWithInvalidProtoSchema("syntax = \"proto3\";\n" +
"\n" +
"package schemavalidation;\n" +
"\n" +
"message SampleMessage {\n" +
" oneof test_oneof {\n" +
" string name = 1;\n" +
" group Result = 2 {\n" +
" \tstring url = 3;\n" +
" \tstring title = 4;\n" +
" \trepeated string snippets = 5;\n" +
" }\n" +
" }" +
"}", "[Transport Configuration] invalid attributes proto schema provided! OneOf definition groups don't support!");
"\n" +
"package schemavalidation;\n" +
"\n" +
"message SampleMessage {\n" +
" oneof test_oneof {\n" +
" string name = 1;\n" +
" group Result = 2 {\n" +
" \tstring url = 3;\n" +
" \tstring title = 4;\n" +
" \trepeated string snippets = 5;\n" +
" }\n" +
" }" +
"}", "[Transport Configuration] invalid attributes proto schema provided! OneOf definition groups don't support!");
}
@Test
public void testSaveProtoDeviceProfileWithInvalidTelemetrySchemaTsField() throws Exception {
testSaveDeviceProfileWithInvalidProtoSchema("syntax =\"proto3\";\n" +
"\n" +
"package schemavalidation;\n" +
"\n" +
"message PostTelemetry {\n" +
" int64 ts = 1;\n" +
" Values values = 2;\n" +
" \n" +
" message Values {\n" +
" string key1 = 3;\n" +
" bool key2 = 4;\n" +
" double key3 = 5;\n" +
" int32 key4 = 6;\n" +
" JsonObject key5 = 7;\n" +
" }\n" +
" \n" +
" message JsonObject {\n" +
" optional int32 someNumber = 8;\n" +
" repeated int32 someArray = 9;\n" +
" NestedJsonObject someNestedObject = 10;\n" +
" message NestedJsonObject {\n" +
" optional string key = 11;\n" +
" }\n" +
" }\n" +
"}", "[Transport Configuration] invalid telemetry proto schema provided! Field 'ts' has invalid label. Field 'ts' should have optional keyword!");
"\n" +
"package schemavalidation;\n" +
"\n" +
"message PostTelemetry {\n" +
" int64 ts = 1;\n" +
" Values values = 2;\n" +
" \n" +
" message Values {\n" +
" string key1 = 3;\n" +
" bool key2 = 4;\n" +
" double key3 = 5;\n" +
" int32 key4 = 6;\n" +
" JsonObject key5 = 7;\n" +
" }\n" +
" \n" +
" message JsonObject {\n" +
" optional int32 someNumber = 8;\n" +
" repeated int32 someArray = 9;\n" +
" NestedJsonObject someNestedObject = 10;\n" +
" message NestedJsonObject {\n" +
" optional string key = 11;\n" +
" }\n" +
" }\n" +
"}", "[Transport Configuration] invalid telemetry proto schema provided! Field 'ts' has invalid label. Field 'ts' should have optional keyword!");
}
@Test
public void testSaveProtoDeviceProfileWithInvalidTelemetrySchemaTsDateType() throws Exception {
testSaveDeviceProfileWithInvalidProtoSchema("syntax =\"proto3\";\n" +
"\n" +
"package schemavalidation;\n" +
"\n" +
"message PostTelemetry {\n" +
" optional int32 ts = 1;\n" +
" Values values = 2;\n" +
" \n" +
" message Values {\n" +
" string key1 = 3;\n" +
" bool key2 = 4;\n" +
" double key3 = 5;\n" +
" int32 key4 = 6;\n" +
" JsonObject key5 = 7;\n" +
" }\n" +
" \n" +
" message JsonObject {\n" +
" optional int32 someNumber = 8;\n" +
" }\n" +
"}", "[Transport Configuration] invalid telemetry proto schema provided! Field 'ts' has invalid data type. Only int64 type is supported!");
"\n" +
"package schemavalidation;\n" +
"\n" +
"message PostTelemetry {\n" +
" optional int32 ts = 1;\n" +
" Values values = 2;\n" +
" \n" +
" message Values {\n" +
" string key1 = 3;\n" +
" bool key2 = 4;\n" +
" double key3 = 5;\n" +
" int32 key4 = 6;\n" +
" JsonObject key5 = 7;\n" +
" }\n" +
" \n" +
" message JsonObject {\n" +
" optional int32 someNumber = 8;\n" +
" }\n" +
"}", "[Transport Configuration] invalid telemetry proto schema provided! Field 'ts' has invalid data type. Only int64 type is supported!");
}
@Test
public void testSaveProtoDeviceProfileWithInvalidTelemetrySchemaValuesDateType() throws Exception {
testSaveDeviceProfileWithInvalidProtoSchema("syntax =\"proto3\";\n" +
"\n" +
"package schemavalidation;\n" +
"\n" +
"message PostTelemetry {\n" +
" optional int64 ts = 1;\n" +
" string values = 2;\n" +
" \n" +
"}", "[Transport Configuration] invalid telemetry proto schema provided! Field 'values' has invalid data type. Only message type is supported!");
"\n" +
"package schemavalidation;\n" +
"\n" +
"message PostTelemetry {\n" +
" optional int64 ts = 1;\n" +
" string values = 2;\n" +
" \n" +
"}", "[Transport Configuration] invalid telemetry proto schema provided! Field 'values' has invalid data type. Only message type is supported!");
}
@Test
public void testSaveProtoDeviceProfileWithInvalidRpcRequestSchemaMethodDateType() throws Exception {
testSaveDeviceProfileWithInvalidRpcRequestProtoSchema("syntax =\"proto3\";\n" +
"\n" +
"package schemavalidation;\n" +
"\n" +
"message RpcRequestMsg {\n" +
" optional int32 method = 1;\n" +
" optional int32 requestId = 2;\n" +
" optional string params = 3;\n" +
" \n" +
"}", "[Transport Configuration] invalid rpc request proto schema provided! Field 'method' has invalid data type. Only string type is supported!");
"\n" +
"package schemavalidation;\n" +
"\n" +
"message RpcRequestMsg {\n" +
" optional int32 method = 1;\n" +
" optional int32 requestId = 2;\n" +
" optional string params = 3;\n" +
" \n" +
"}", "[Transport Configuration] invalid rpc request proto schema provided! Field 'method' has invalid data type. Only string type is supported!");
}
@Test
public void testSaveProtoDeviceProfileWithInvalidRpcRequestSchemaRequestIdDateType() throws Exception {
testSaveDeviceProfileWithInvalidRpcRequestProtoSchema("syntax =\"proto3\";\n" +
"\n" +
"package schemavalidation;\n" +
"\n" +
"message RpcRequestMsg {\n" +
" optional string method = 1;\n" +
" optional int64 requestId = 2;\n" +
" optional string params = 3;\n" +
" \n" +
"}", "[Transport Configuration] invalid rpc request proto schema provided! Field 'requestId' has invalid data type. Only int32 type is supported!");
"\n" +
"package schemavalidation;\n" +
"\n" +
"message RpcRequestMsg {\n" +
" optional string method = 1;\n" +
" optional int64 requestId = 2;\n" +
" optional string params = 3;\n" +
" \n" +
"}", "[Transport Configuration] invalid rpc request proto schema provided! Field 'requestId' has invalid data type. Only int32 type is supported!");
}
@Test
public void testSaveProtoDeviceProfileWithInvalidRpcRequestSchemaMethodLabel() throws Exception {
testSaveDeviceProfileWithInvalidRpcRequestProtoSchema("syntax =\"proto3\";\n" +
"\n" +
"package schemavalidation;\n" +
"\n" +
"message RpcRequestMsg {\n" +
" repeated string method = 1;\n" +
" optional int32 requestId = 2;\n" +
" optional string params = 3;\n" +
" \n" +
"}", "[Transport Configuration] invalid rpc request proto schema provided! Field 'method' has invalid label!");
"\n" +
"package schemavalidation;\n" +
"\n" +
"message RpcRequestMsg {\n" +
" repeated string method = 1;\n" +
" optional int32 requestId = 2;\n" +
" optional string params = 3;\n" +
" \n" +
"}", "[Transport Configuration] invalid rpc request proto schema provided! Field 'method' has invalid label!");
}
@Test
public void testSaveProtoDeviceProfileWithInvalidRpcRequestSchemaRequestIdLabel() throws Exception {
testSaveDeviceProfileWithInvalidRpcRequestProtoSchema("syntax =\"proto3\";\n" +
"\n" +
"package schemavalidation;\n" +
"\n" +
"message RpcRequestMsg {\n" +
" optional string method = 1;\n" +
" repeated int32 requestId = 2;\n" +
" optional string params = 3;\n" +
" \n" +
"}", "[Transport Configuration] invalid rpc request proto schema provided! Field 'requestId' has invalid label!");
"\n" +
"package schemavalidation;\n" +
"\n" +
"message RpcRequestMsg {\n" +
" optional string method = 1;\n" +
" repeated int32 requestId = 2;\n" +
" optional string params = 3;\n" +
" \n" +
"}", "[Transport Configuration] invalid rpc request proto schema provided! Field 'requestId' has invalid label!");
}
@Test
public void testSaveProtoDeviceProfileWithInvalidRpcRequestSchemaParamsLabel() throws Exception {
testSaveDeviceProfileWithInvalidRpcRequestProtoSchema("syntax =\"proto3\";\n" +
"\n" +
"package schemavalidation;\n" +
"\n" +
"message RpcRequestMsg {\n" +
" optional string method = 1;\n" +
" optional int32 requestId = 2;\n" +
" repeated string params = 3;\n" +
" \n" +
"}", "[Transport Configuration] invalid rpc request proto schema provided! Field 'params' has invalid label!");
"\n" +
"package schemavalidation;\n" +
"\n" +
"message RpcRequestMsg {\n" +
" optional string method = 1;\n" +
" optional int32 requestId = 2;\n" +
" repeated string params = 3;\n" +
" \n" +
"}", "[Transport Configuration] invalid rpc request proto schema provided! Field 'params' has invalid label!");
}
@Test
public void testSaveProtoDeviceProfileWithInvalidRpcRequestSchemaFieldsCount() throws Exception {
testSaveDeviceProfileWithInvalidRpcRequestProtoSchema("syntax =\"proto3\";\n" +
"\n" +
"package schemavalidation;\n" +
"\n" +
"message RpcRequestMsg {\n" +
" optional int32 requestId = 2;\n" +
" optional string params = 3;\n" +
" \n" +
"}", "[Transport Configuration] invalid rpc request proto schema provided! RpcRequestMsg message should always contains 3 fields: method, requestId and params!");
"\n" +
"package schemavalidation;\n" +
"\n" +
"message RpcRequestMsg {\n" +
" optional int32 requestId = 2;\n" +
" optional string params = 3;\n" +
" \n" +
"}", "[Transport Configuration] invalid rpc request proto schema provided! RpcRequestMsg message should always contains 3 fields: method, requestId and params!");
}
@Test
public void testSaveProtoDeviceProfileWithInvalidRpcRequestSchemaFieldMethodIsNoSet() throws Exception {
testSaveDeviceProfileWithInvalidRpcRequestProtoSchema("syntax =\"proto3\";\n" +
"\n" +
"package schemavalidation;\n" +
"\n" +
"message RpcRequestMsg {\n" +
" optional string methodName = 1;\n" +
" optional int32 requestId = 2;\n" +
" optional string params = 3;\n" +
" \n" +
"}", "[Transport Configuration] invalid rpc request proto schema provided! Failed to get field descriptor for field: method!");
"\n" +
"package schemavalidation;\n" +
"\n" +
"message RpcRequestMsg {\n" +
" optional string methodName = 1;\n" +
" optional int32 requestId = 2;\n" +
" optional string params = 3;\n" +
" \n" +
"}", "[Transport Configuration] invalid rpc request proto schema provided! Failed to get field descriptor for field: method!");
}
@Test
public void testSaveProtoDeviceProfileWithInvalidRpcRequestSchemaFieldRequestIdIsNotSet() throws Exception {
testSaveDeviceProfileWithInvalidRpcRequestProtoSchema("syntax =\"proto3\";\n" +
"\n" +
"package schemavalidation;\n" +
"\n" +
"message RpcRequestMsg {\n" +
" optional string method = 1;\n" +
" optional int32 requestIdentifier = 2;\n" +
" optional string params = 3;\n" +
" \n" +
"}", "[Transport Configuration] invalid rpc request proto schema provided! Failed to get field descriptor for field: requestId!");
"\n" +
"package schemavalidation;\n" +
"\n" +
"message RpcRequestMsg {\n" +
" optional string method = 1;\n" +
" optional int32 requestIdentifier = 2;\n" +
" optional string params = 3;\n" +
" \n" +
"}", "[Transport Configuration] invalid rpc request proto schema provided! Failed to get field descriptor for field: requestId!");
}
@Test
public void testSaveProtoDeviceProfileWithInvalidRpcRequestSchemaFieldParamsIsNotSet() throws Exception {
testSaveDeviceProfileWithInvalidRpcRequestProtoSchema("syntax =\"proto3\";\n" +
"\n" +
"package schemavalidation;\n" +
"\n" +
"message RpcRequestMsg {\n" +
" optional string method = 1;\n" +
" optional int32 requestId = 2;\n" +
" optional string parameters = 3;\n" +
" \n" +
"}", "[Transport Configuration] invalid rpc request proto schema provided! Failed to get field descriptor for field: params!");
"\n" +
"package schemavalidation;\n" +
"\n" +
"message RpcRequestMsg {\n" +
" optional string method = 1;\n" +
" optional int32 requestId = 2;\n" +
" optional string parameters = 3;\n" +
" \n" +
"}", "[Transport Configuration] invalid rpc request proto schema provided! Failed to get field descriptor for field: params!");
}
@Test
@ -1068,11 +1071,17 @@ public class DeviceProfileControllerTest extends AbstractControllerTest {
MqttDeviceProfileTransportConfiguration mqttDeviceProfileTransportConfiguration = this.createMqttDeviceProfileTransportConfiguration(protoTransportPayloadConfiguration, false);
DeviceProfile deviceProfile = this.createDeviceProfile("Device Profile", mqttDeviceProfileTransportConfiguration);
Mockito.reset(tbClusterService, auditLogService);
doPost("/api/deviceProfile", deviceProfile)
.andExpect(status().isBadRequest())
.andExpect(statusReason(containsString(errorMsg)));
// The request may hit a transient TenantNotFoundException right after the @Before tenant creation
// if the tenant profile cache is not yet warmed up for the newly created tenant. Retry until the
// request returns the expected 400 Bad Request for the invalid schema. Mockito.reset is inside the
// retry loop so the subsequent verify* assertions see only the invocations from the last attempt.
Awaitility.await().atMost(10, TimeUnit.SECONDS).pollInterval(500, TimeUnit.MILLISECONDS)
.ignoreExceptions().untilAsserted(() -> {
Mockito.reset(tbClusterService, auditLogService);
doPost("/api/deviceProfile", deviceProfile)
.andExpect(status().isBadRequest())
.andExpect(statusReason(containsString(errorMsg)));
});
testNotifyEntityEqualsOneTimeServiceNeverError(deviceProfile, savedTenant.getId(),
tenantAdmin.getId(), tenantAdmin.getEmail(), ActionType.ADDED, new DataValidationException(errorMsg));

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

@ -28,6 +28,7 @@ import org.junit.Before;
import org.junit.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.test.context.TestPropertySource;
import org.springframework.test.util.ReflectionTestUtils;
import org.springframework.test.context.bean.override.mockito.MockitoBean;
import org.springframework.test.web.servlet.ResultActions;
import org.testcontainers.shaded.org.apache.commons.lang3.RandomStringUtils;
@ -62,6 +63,8 @@ import org.thingsboard.server.common.data.query.AlarmDataQuery;
import org.thingsboard.server.common.data.query.AliasEntityId;
import org.thingsboard.server.common.data.query.AvailableEntityKeysV2;
import org.thingsboard.server.common.data.query.AvailableEntityKeysV2.KeyInfo;
import org.thingsboard.server.common.data.query.ComplexFilterPredicate;
import org.thingsboard.server.common.data.query.ComplexOperation;
import org.thingsboard.server.common.data.query.DeviceTypeFilter;
import org.thingsboard.server.common.data.query.DynamicValue;
import org.thingsboard.server.common.data.query.DynamicValueSourceType;
@ -89,6 +92,7 @@ import org.thingsboard.server.common.data.relation.RelationEntityTypeFilter;
import org.thingsboard.server.common.data.security.Authority;
import org.thingsboard.server.common.data.tenant.profile.DefaultTenantProfileConfiguration;
import org.thingsboard.server.common.data.tenant.profile.TenantProfileData;
import org.thingsboard.server.dao.entity.BaseEntityService;
import org.thingsboard.server.dao.queue.QueueStatsService;
import org.thingsboard.server.dao.service.DaoSqlTest;
import org.thingsboard.server.edqs.util.EdqsRocksDb;
@ -123,6 +127,8 @@ public class EntityQueryControllerTest extends AbstractControllerTest {
@Autowired
private QueueStatsService queueStatsService;
@Autowired
private BaseEntityService baseEntityService;
@MockitoBean
private EdqsRocksDb edqsRocksDb;
@ -1343,7 +1349,7 @@ public class EntityQueryControllerTest extends AbstractControllerTest {
//assign dashboard
doPost("/api/customer/" + savedCustomer.getId().getId().toString()
+ "/dashboard/" + savedDashboard.getId().getId().toString(), Dashboard.class);
+ "/dashboard/" + savedDashboard.getId().getId().toString(), Dashboard.class);
// check entity data query by customer
User customerUser = new User();
@ -1729,4 +1735,523 @@ public class EntityQueryControllerTest extends AbstractControllerTest {
return new EntityDataQuery(filter, new EntityDataPageLink(1, 0, null, null), Collections.emptyList(), null, null);
}
// --- OR conditions tests ---
private Device createDeviceWithSharedAttributes(String name, String type, String sharedAttributesPayload) throws Exception {
Device device = new Device();
device.setName(name);
device.setType(type);
device = doPost("/api/device", device, Device.class);
if (sharedAttributesPayload != null) {
doPost("/api/plugins/telemetry/" + device.getId() + "/" + DataConstants.SHARED_SCOPE,
sharedAttributesPayload, String.class, status().isOk());
}
return device;
}
private Device createDeviceWithTimeseries(String name, String type, String timeseriesPayload) throws Exception {
Device device = new Device();
device.setName(name);
device.setType(type);
device = doPost("/api/device", device, Device.class);
JsonNode payload = JacksonUtil.toJsonNode(timeseriesPayload);
doPost("/api/plugins/telemetry/" + EntityType.DEVICE.name() + "/" + device.getUuidId() + "/timeseries/SERVER_SCOPE", payload)
.andExpect(status().isOk());
return device;
}
private Alarm createAlarm(DeviceId originator, String type, AlarmSeverity severity) throws Exception {
Alarm alarm = new Alarm();
alarm.setOriginator(originator);
alarm.setType(type);
alarm.setSeverity(severity);
return doPost("/api/alarm", alarm, Alarm.class);
}
private static DeviceTypeFilter deviceTypeFilter(String type) {
DeviceTypeFilter filter = new DeviceTypeFilter();
filter.setDeviceTypes(List.of(type));
filter.setDeviceNameFilter("");
return filter;
}
private static KeyFilter numericKeyFilter(EntityKeyType keyType, String key,
NumericFilterPredicate.NumericOperation operation, double value) {
KeyFilter keyFilter = new KeyFilter();
keyFilter.setKey(new EntityKey(keyType, key));
keyFilter.setValueType(EntityKeyValueType.NUMERIC);
NumericFilterPredicate predicate = new NumericFilterPredicate();
predicate.setValue(FilterPredicateValue.fromDouble(value));
predicate.setOperation(operation);
keyFilter.setPredicate(predicate);
return keyFilter;
}
private static KeyFilter numericAttributeKeyFilter(String key,
NumericFilterPredicate.NumericOperation operation, double value) {
return numericKeyFilter(EntityKeyType.ATTRIBUTE, key, operation, value);
}
private static KeyFilter stringKeyFilter(EntityKeyType keyType, String key,
StringFilterPredicate.StringOperation operation, String value) {
KeyFilter keyFilter = new KeyFilter();
keyFilter.setKey(new EntityKey(keyType, key));
keyFilter.setValueType(EntityKeyValueType.STRING);
StringFilterPredicate predicate = new StringFilterPredicate();
predicate.setValue(FilterPredicateValue.fromString(value));
predicate.setOperation(operation);
keyFilter.setPredicate(predicate);
return keyFilter;
}
private static KeyFilter stringAttributeKeyFilter(String key,
StringFilterPredicate.StringOperation operation, String value) {
return stringKeyFilter(EntityKeyType.ATTRIBUTE, key, operation, value);
}
private static EntityDataPageLink pageLinkSortedByName(int pageSize, int page, String textSearch) {
EntityDataSortOrder sortOrder = new EntityDataSortOrder(
new EntityKey(EntityKeyType.ENTITY_FIELD, "name"), EntityDataSortOrder.Direction.ASC);
return new EntityDataPageLink(pageSize, page, textSearch, sortOrder);
}
private static List<EntityKey> nameEntityField() {
return Collections.singletonList(new EntityKey(EntityKeyType.ENTITY_FIELD, "name"));
}
private static List<String> extractNames(PageData<EntityData> result) {
return result.getData().stream()
.map(e -> e.getLatest().get(EntityKeyType.ENTITY_FIELD).get("name").getValue())
.collect(Collectors.toList());
}
@Test
public void testCountEntitiesWithOrKeyFiltersOperation() throws Exception {
String type = "orTestType";
createDeviceWithSharedAttributes("OrTestDeviceA", type, "{\"temperature\":60}");
createDeviceWithSharedAttributes("OrTestDeviceB", type, "{\"temperature\":5}");
createDeviceWithSharedAttributes("OrTestDeviceC", type, "{\"temperature\":30}");
List<KeyFilter> keyFilters = List.of(
numericAttributeKeyFilter("temperature", NumericFilterPredicate.NumericOperation.GREATER, 50),
numericAttributeKeyFilter("temperature", NumericFilterPredicate.NumericOperation.LESS, 10));
// OR: deviceA (60>50) and deviceB (5<10) match => count=2
EntityCountQuery orQuery = new EntityCountQuery(deviceTypeFilter(type), keyFilters, ComplexOperation.OR);
await().atMost(TIMEOUT, TimeUnit.SECONDS).untilAsserted(() -> countByQueryAndCheck(orQuery, 2));
// AND: no device has temperature both >50 AND <10 => count=0
EntityCountQuery andQuery = new EntityCountQuery(deviceTypeFilter(type), keyFilters, ComplexOperation.AND);
countByQueryAndCheck(andQuery, 0);
}
@Test
public void testFindEntityDataWithOrKeyFiltersOperation() throws Exception {
String type = "orDataType";
createDeviceWithSharedAttributes("OrDataDeviceX", type, "{\"status\":\"active\"}");
createDeviceWithSharedAttributes("OrDataDeviceY", type, "{\"humidity\":80}");
createDeviceWithSharedAttributes("OrDataDeviceZ", type, null); // no matching attribute
List<KeyFilter> keyFilters = List.of(
stringAttributeKeyFilter("status", StringFilterPredicate.StringOperation.EQUAL, "active"),
numericAttributeKeyFilter("humidity", NumericFilterPredicate.NumericOperation.GREATER, 70));
// OR: deviceX matches status=active, deviceY matches humidity>70
EntityDataQuery orQuery = new EntityDataQuery(deviceTypeFilter(type), pageLinkSortedByName(10, 0, null),
nameEntityField(), null, keyFilters, ComplexOperation.OR);
await().atMost(TIMEOUT, TimeUnit.SECONDS).untilAsserted(() -> findByQueryAndCheck(orQuery, 2));
PageData<EntityData> result = findByQueryAndCheck(orQuery, 2);
assertThat(extractNames(result)).containsExactlyInAnyOrder("OrDataDeviceX", "OrDataDeviceY");
}
@Test
public void testFindEntityDataWithOrDoesNotLeakFilterOnlyEntityFields() throws Exception {
// Regression test: under OR, an entity-field filter (e.g. label) that isn't declared in
// entityFields must not leak its value into EntityData.latest[ENTITY_FIELD].
String type = "orLeakGuardType";
Device d1 = createDeviceWithSharedAttributes("OrLeakDeviceA", type, "{\"status\":\"active\"}");
d1.setLabel("leak-label-A");
doPost("/api/device", d1, Device.class);
Device d2 = createDeviceWithSharedAttributes("OrLeakDeviceB", type, null);
d2.setLabel("leak-label-B");
doPost("/api/device", d2, Device.class);
List<KeyFilter> keyFilters = List.of(
stringAttributeKeyFilter("status", StringFilterPredicate.StringOperation.EQUAL, "active"),
buildStringKeyFilter(EntityKeyType.ENTITY_FIELD, "label", StringFilterPredicate.StringOperation.EQUAL, "leak-label-B"));
EntityDataQuery orQuery = new EntityDataQuery(deviceTypeFilter(type), pageLinkSortedByName(10, 0, null),
nameEntityField(), null, keyFilters, ComplexOperation.OR);
await().atMost(TIMEOUT, TimeUnit.SECONDS).untilAsserted(() -> findByQueryAndCheck(orQuery, 2));
PageData<EntityData> result = findByQueryAndCheck(orQuery, 2);
assertThat(extractNames(result)).containsExactlyInAnyOrder("OrLeakDeviceA", "OrLeakDeviceB");
for (EntityData entity : result.getData()) {
assertThat(entity.getLatest().get(EntityKeyType.ENTITY_FIELD)).containsOnlyKeys("name");
}
}
@Test
public void testFindEntityDataWithOrSameKeyFilters() throws Exception {
String type = "orSameKeyType";
createDeviceWithSharedAttributes("OrSameKeyDeviceA", type, "{\"temperature\":60}");
createDeviceWithSharedAttributes("OrSameKeyDeviceB", type, "{\"temperature\":5}");
// Same key "temperature" with two different predicates exercises the OR-ungrouping path
List<KeyFilter> keyFilters = List.of(
numericAttributeKeyFilter("temperature", NumericFilterPredicate.NumericOperation.GREATER, 50),
numericAttributeKeyFilter("temperature", NumericFilterPredicate.NumericOperation.LESS, 10));
EntityDataQuery orQuery = new EntityDataQuery(deviceTypeFilter(type), pageLinkSortedByName(10, 0, null),
nameEntityField(), null, keyFilters, ComplexOperation.OR);
await().atMost(TIMEOUT, TimeUnit.SECONDS).untilAsserted(() -> findByQueryAndCheck(orQuery, 2));
PageData<EntityData> result = findByQueryAndCheck(orQuery, 2);
assertThat(extractNames(result)).containsExactlyInAnyOrder("OrSameKeyDeviceA", "OrSameKeyDeviceB");
}
@Test
public void testCountEntitiesWithoutKeyFiltersOperation() throws Exception {
String type = "backCompatType";
createDeviceWithSharedAttributes("BackCompatDeviceA", type, "{\"temperature\":60}");
createDeviceWithSharedAttributes("BackCompatDeviceB", type, "{\"temperature\":5}");
List<KeyFilter> keyFilters = List.of(
numericAttributeKeyFilter("temperature", NumericFilterPredicate.NumericOperation.GREATER, 50),
numericAttributeKeyFilter("temperature", NumericFilterPredicate.NumericOperation.LESS, 10));
// Await attribute propagation via an OR query that should find 2 when propagated
EntityCountQuery orCheckQuery = new EntityCountQuery(deviceTypeFilter(type), keyFilters, ComplexOperation.OR);
await().atMost(TIMEOUT, TimeUnit.SECONDS).untilAsserted(() -> countByQueryAndCheck(orCheckQuery, 2));
// Query without keyFiltersOperation (null) -- should behave as AND
EntityCountQuery nullOpQuery = new EntityCountQuery(deviceTypeFilter(type), keyFilters);
Long nullResult = countByQueryAndCheck(nullOpQuery, 0);
// Query with explicit AND -- should produce the same result
EntityCountQuery andOpQuery = new EntityCountQuery(deviceTypeFilter(type), keyFilters, ComplexOperation.AND);
Long andResult = countByQueryAndCheck(andOpQuery, 0);
Assert.assertEquals(nullResult, andResult);
}
@Test
public void testAlarmDataQueryWithOrKeyFiltersOperation() throws Exception {
loginTenantAdmin();
String type = "orAlarmType";
Device deviceHot = createDeviceWithSharedAttributes("OrAlarmDeviceHot", type, "{\"temperature\":60}");
Device deviceCold = createDeviceWithSharedAttributes("OrAlarmDeviceCold", type, "{\"temperature\":5}");
Device deviceMid = createDeviceWithSharedAttributes("OrAlarmDeviceMid", type, "{\"temperature\":30}");
createAlarm(deviceHot.getId(), "highTemp", AlarmSeverity.CRITICAL);
createAlarm(deviceCold.getId(), "lowTemp", AlarmSeverity.WARNING);
createAlarm(deviceMid.getId(), "normalTemp", AlarmSeverity.WARNING);
List<KeyFilter> keyFilters = List.of(
numericAttributeKeyFilter("temperature", NumericFilterPredicate.NumericOperation.GREATER, 50),
numericAttributeKeyFilter("temperature", NumericFilterPredicate.NumericOperation.LESS, 10));
AlarmDataPageLink pageLink = new AlarmDataPageLink();
pageLink.setPage(0);
pageLink.setPageSize(100);
pageLink.setSortOrder(new EntityDataSortOrder(new EntityKey(EntityKeyType.ALARM_FIELD, "createdTime")));
List<EntityKey> alarmFields = List.of(new EntityKey(EntityKeyType.ALARM_FIELD, "type"));
// OR query: should return alarms for deviceHot (60>50) and deviceCold (5<10) = 2 alarms
AlarmDataQuery orAlarmQuery = new AlarmDataQuery(deviceTypeFilter(type), pageLink, null, null, keyFilters, alarmFields, ComplexOperation.OR);
await().atMost(TIMEOUT, TimeUnit.SECONDS).untilAsserted(() -> findAlarmsByQueryAndCheck(orAlarmQuery, 2));
PageData<AlarmData> alarmResult = findAlarmsByQueryAndCheck(orAlarmQuery, 2);
List<String> alarmTypes = alarmResult.getData().stream().map(AlarmData::getType).collect(Collectors.toList());
assertThat(alarmTypes).containsExactlyInAnyOrder("highTemp", "lowTemp");
// AND query: no device has temp both >50 AND <10 => 0 alarms
AlarmDataQuery andAlarmQuery = new AlarmDataQuery(deviceTypeFilter(type), pageLink, null, null, keyFilters, alarmFields, ComplexOperation.AND);
findAlarmsByQueryAndCheck(andAlarmQuery, 0);
}
@Test
public void testCountAlarmsByQueryWithOrKeyFiltersOperation() throws Exception {
loginTenantAdmin();
String type = "orAlarmCntType";
Device deviceHot = createDeviceWithSharedAttributes("OrAlarmCntDeviceHot", type, "{\"temperature\":60}");
Device deviceCold = createDeviceWithSharedAttributes("OrAlarmCntDeviceCold", type, "{\"temperature\":5}");
Device deviceMid = createDeviceWithSharedAttributes("OrAlarmCntDeviceMid", type, "{\"temperature\":30}");
// 2 alarms for deviceHot, 1 for deviceCold, 1 for deviceMid
createAlarm(deviceHot.getId(), "highTemp1", AlarmSeverity.CRITICAL);
createAlarm(deviceHot.getId(), "highTemp2", AlarmSeverity.CRITICAL);
createAlarm(deviceCold.getId(), "lowTemp", AlarmSeverity.WARNING);
createAlarm(deviceMid.getId(), "normalTemp", AlarmSeverity.WARNING);
List<KeyFilter> keyFilters = List.of(
numericAttributeKeyFilter("temperature", NumericFilterPredicate.NumericOperation.GREATER, 50),
numericAttributeKeyFilter("temperature", NumericFilterPredicate.NumericOperation.LESS, 10));
// OR: deviceHot (2 alarms) + deviceCold (1 alarm) match => 3 alarms total
AlarmCountQuery orQuery = new AlarmCountQuery(deviceTypeFilter(type), keyFilters, ComplexOperation.OR);
await().atMost(TIMEOUT, TimeUnit.SECONDS).untilAsserted(() -> countAlarmsByQueryAndCheck(orQuery, 3));
// AND: no device matches both filters => 0
AlarmCountQuery andQuery = new AlarmCountQuery(deviceTypeFilter(type), keyFilters, ComplexOperation.AND);
countAlarmsByQueryAndCheck(andQuery, 0);
}
@Test
public void testCountEntitiesWithOrMixedEntityFieldAndAttribute() throws Exception {
// Exercises the entity field predicate relocation to middle-layer WHERE under OR
String type = "orMixedType";
createDeviceWithSharedAttributes("OrMixedAlpha", type, "{\"temperature\":10}");
createDeviceWithSharedAttributes("OrMixedBeta", type, "{\"temperature\":60}");
createDeviceWithSharedAttributes("OrMixedGamma", type, "{\"temperature\":10}");
List<KeyFilter> keyFilters = List.of(
stringKeyFilter(EntityKeyType.ENTITY_FIELD, "name", StringFilterPredicate.StringOperation.CONTAINS, "Alpha"),
numericAttributeKeyFilter("temperature", NumericFilterPredicate.NumericOperation.GREATER, 50));
// OR: Alpha matches name contains "Alpha", Beta matches temp>50 => count=2
EntityCountQuery orQuery = new EntityCountQuery(deviceTypeFilter(type), keyFilters, ComplexOperation.OR);
await().atMost(TIMEOUT, TimeUnit.SECONDS).untilAsserted(() -> countByQueryAndCheck(orQuery, 2));
// AND: only Alpha has name "Alpha" AND temp is 10 (not >50) => count=0
EntityCountQuery andQuery = new EntityCountQuery(deviceTypeFilter(type), keyFilters, ComplexOperation.AND);
countByQueryAndCheck(andQuery, 0);
}
@Test
public void testCountEntitiesWithOrStringAttributes() throws Exception {
String type = "orStrType";
createDeviceWithSharedAttributes("OrStrDeviceA", type, "{\"color\":\"red\"}");
createDeviceWithSharedAttributes("OrStrDeviceB", type, "{\"color\":\"blue\"}");
createDeviceWithSharedAttributes("OrStrDeviceC", type, "{\"color\":\"green\"}");
List<KeyFilter> keyFilters = List.of(
stringAttributeKeyFilter("color", StringFilterPredicate.StringOperation.EQUAL, "red"),
stringAttributeKeyFilter("color", StringFilterPredicate.StringOperation.EQUAL, "blue"));
// OR: red and blue match => count=2
EntityCountQuery orQuery = new EntityCountQuery(deviceTypeFilter(type), keyFilters, ComplexOperation.OR);
await().atMost(TIMEOUT, TimeUnit.SECONDS).untilAsserted(() -> countByQueryAndCheck(orQuery, 2));
// AND: no device is both red AND blue => count=0
EntityCountQuery andQuery = new EntityCountQuery(deviceTypeFilter(type), keyFilters, ComplexOperation.AND);
countByQueryAndCheck(andQuery, 0);
}
@Test
public void testCountEntitiesWithOrSingleFilter() throws Exception {
String type = "orSingleType";
createDeviceWithSharedAttributes("OrSingleDeviceA", type, "{\"temperature\":60}");
createDeviceWithSharedAttributes("OrSingleDeviceB", type, "{\"temperature\":30}");
List<KeyFilter> keyFilters = List.of(
numericAttributeKeyFilter("temperature", NumericFilterPredicate.NumericOperation.GREATER, 50));
// Single filter with OR should behave identically to AND
EntityCountQuery orQuery = new EntityCountQuery(deviceTypeFilter(type), keyFilters, ComplexOperation.OR);
await().atMost(TIMEOUT, TimeUnit.SECONDS).untilAsserted(() -> countByQueryAndCheck(orQuery, 1));
Long orResult = countByQueryAndCheck(orQuery, 1);
EntityCountQuery andQuery = new EntityCountQuery(deviceTypeFilter(type), keyFilters, ComplexOperation.AND);
Long andResult = countByQueryAndCheck(andQuery, 1);
Assert.assertEquals(orResult, andResult);
}
@Test
public void testCountEntitiesWithOrThreeFilters() throws Exception {
String type = "or3fType";
createDeviceWithSharedAttributes("Or3fDeviceA", type, "{\"temperature\":60,\"humidity\":50,\"pressure\":1000}");
createDeviceWithSharedAttributes("Or3fDeviceB", type, "{\"temperature\":20,\"humidity\":90,\"pressure\":1000}");
createDeviceWithSharedAttributes("Or3fDeviceC", type, "{\"temperature\":20,\"humidity\":50,\"pressure\":1050}");
createDeviceWithSharedAttributes("Or3fDeviceD", type, "{\"temperature\":20,\"humidity\":50,\"pressure\":1000}");
List<KeyFilter> keyFilters = List.of(
numericAttributeKeyFilter("temperature", NumericFilterPredicate.NumericOperation.GREATER, 50),
numericAttributeKeyFilter("humidity", NumericFilterPredicate.NumericOperation.GREATER, 80),
numericAttributeKeyFilter("pressure", NumericFilterPredicate.NumericOperation.GREATER, 1040));
// OR: A matches temp>50, B matches hum>80, C matches press>1040, D matches none => 3
EntityCountQuery orQuery = new EntityCountQuery(deviceTypeFilter(type), keyFilters, ComplexOperation.OR);
await().atMost(TIMEOUT, TimeUnit.SECONDS).untilAsserted(() -> countByQueryAndCheck(orQuery, 3));
// AND: no device matches all three => 0
EntityCountQuery andQuery = new EntityCountQuery(deviceTypeFilter(type), keyFilters, ComplexOperation.AND);
countByQueryAndCheck(andQuery, 0);
}
@Test
public void testFindEntityDataWithOrPagination() throws Exception {
// 5 devices, 4 match OR filters; explicit temperatures keep the setup easy to read.
String type = "orPageType";
int[] temperatures = {61, 62, 2, 1, 25}; // devices 1,2: temp>50; 3,4: temp<10; 5: no match
for (int i = 0; i < temperatures.length; i++) {
createDeviceWithSharedAttributes(
String.format("OrPageDevice%02d", i + 1), type,
"{\"temperature\":" + temperatures[i] + "}");
}
List<KeyFilter> keyFilters = List.of(
numericAttributeKeyFilter("temperature", NumericFilterPredicate.NumericOperation.GREATER, 50),
numericAttributeKeyFilter("temperature", NumericFilterPredicate.NumericOperation.LESS, 10));
// Page 1: pageSize=2, totalElements=4, data.size()=2
EntityDataQuery orQuery1 = new EntityDataQuery(deviceTypeFilter(type), pageLinkSortedByName(2, 0, null),
nameEntityField(), null, keyFilters, ComplexOperation.OR);
await().atMost(TIMEOUT, TimeUnit.SECONDS).untilAsserted(() -> findByQueryAndCheck(orQuery1, 4));
PageData<EntityData> page1 = findByQueryAndCheck(orQuery1, 4);
Assert.assertEquals(2, page1.getData().size());
Assert.assertTrue(page1.hasNext());
// Page 2: remaining 2 of 4
EntityDataQuery orQuery2 = new EntityDataQuery(deviceTypeFilter(type), pageLinkSortedByName(2, 1, null),
nameEntityField(), null, keyFilters, ComplexOperation.OR);
PageData<EntityData> page2 = findByQueryAndCheck(orQuery2, 4);
Assert.assertEquals(2, page2.getData().size());
Assert.assertFalse(page2.hasNext());
// All names across both pages should be from the 4 matching devices (01..04)
List<String> allNames = new ArrayList<>(extractNames(page1));
allNames.addAll(extractNames(page2));
assertThat(allNames).hasSize(4).doesNotContain("OrPageDevice05");
}
@Test
public void testCountEntitiesWithOrZeroMatches() throws Exception {
String type = "orZeroType";
createDeviceWithSharedAttributes("OrZeroDeviceA", type, "{\"temperature\":30}");
createDeviceWithSharedAttributes("OrZeroDeviceB", type, "{\"temperature\":40}");
// Await attribute propagation via a filter that actually matches both devices
EntityCountQuery propagationCheck = new EntityCountQuery(deviceTypeFilter(type), List.of(
numericAttributeKeyFilter("temperature", NumericFilterPredicate.NumericOperation.GREATER, 20)),
ComplexOperation.OR);
await().atMost(TIMEOUT, TimeUnit.SECONDS).untilAsserted(() -> countByQueryAndCheck(propagationCheck, 2));
List<KeyFilter> keyFilters = List.of(
numericAttributeKeyFilter("temperature", NumericFilterPredicate.NumericOperation.GREATER, 50),
numericAttributeKeyFilter("temperature", NumericFilterPredicate.NumericOperation.LESS, 10));
// OR with no matches: neither filter matches any device => count=0
EntityCountQuery orQuery = new EntityCountQuery(deviceTypeFilter(type), keyFilters, ComplexOperation.OR);
countByQueryAndCheck(orQuery, 0);
}
@Test
public void testOrKeyFiltersOperationRejectedWhenDisabled() throws Exception {
loginTenantAdmin();
ReflectionTestUtils.setField(baseEntityService, "keyFiltersOrConditionsEnabled", false);
try {
DeviceTypeFilter filter = deviceTypeFilter("default");
// POST a query with OR operation -- should be rejected with 400
EntityCountQuery orQuery = new EntityCountQuery(filter, Collections.emptyList(), ComplexOperation.OR);
String errorMessage = getErrorMessage(
doPost("/api/entitiesQuery/count", orQuery).andExpect(status().isBadRequest())
);
assertThat(errorMessage).contains("OR conditions between key filters are disabled");
// POST a query without keyFiltersOperation (null/AND) -- should still succeed
EntityCountQuery andQuery = new EntityCountQuery(filter, Collections.emptyList());
doPost("/api/entitiesQuery/count", andQuery).andExpect(status().isOk());
// POST a query with explicit AND -- should also succeed
EntityCountQuery explicitAndQuery = new EntityCountQuery(filter, Collections.emptyList(), ComplexOperation.AND);
doPost("/api/entitiesQuery/count", explicitAndQuery).andExpect(status().isOk());
} finally {
ReflectionTestUtils.setField(baseEntityService, "keyFiltersOrConditionsEnabled", true);
}
}
@Test
public void testFindEntityDataWithOrAndTextSearch() throws Exception {
// 3 devices: 2 match OR filters, but only 1 also matches textSearch at a time
String type = "orTextType";
createDeviceWithSharedAttributes("OrTextAlpha", type, "{\"temperature\":60}");
createDeviceWithSharedAttributes("OrTextBeta", type, "{\"temperature\":5}");
createDeviceWithSharedAttributes("OrTextGamma", type, "{\"temperature\":30}");
List<KeyFilter> keyFilters = List.of(
numericAttributeKeyFilter("temperature", NumericFilterPredicate.NumericOperation.GREATER, 50),
numericAttributeKeyFilter("temperature", NumericFilterPredicate.NumericOperation.LESS, 10));
// OR without textSearch: Alpha and Beta match => 2
EntityDataQuery orQueryNoText = new EntityDataQuery(deviceTypeFilter(type), pageLinkSortedByName(10, 0, null),
nameEntityField(), null, keyFilters, ComplexOperation.OR);
await().atMost(TIMEOUT, TimeUnit.SECONDS).untilAsserted(() -> findByQueryAndCheck(orQueryNoText, 2));
// OR with textSearch="Alpha": only Alpha matches both OR filter AND text search
EntityDataQuery orQueryWithText = new EntityDataQuery(deviceTypeFilter(type), pageLinkSortedByName(10, 0, "Alpha"),
nameEntityField(), null, keyFilters, ComplexOperation.OR);
assertThat(extractNames(findByQueryAndCheck(orQueryWithText, 1))).containsExactly("OrTextAlpha");
// OR with textSearch="Beta": only Beta matches both OR filter AND text search
EntityDataQuery orQueryBeta = new EntityDataQuery(deviceTypeFilter(type), pageLinkSortedByName(10, 0, "Beta"),
nameEntityField(), null, keyFilters, ComplexOperation.OR);
assertThat(extractNames(findByQueryAndCheck(orQueryBeta, 1))).containsExactly("OrTextBeta");
// OR with textSearch="Gamma": Gamma doesn't match any OR filter => 0
EntityDataQuery orQueryGamma = new EntityDataQuery(deviceTypeFilter(type), pageLinkSortedByName(10, 0, "Gamma"),
nameEntityField(), null, keyFilters, ComplexOperation.OR);
findByQueryAndCheck(orQueryGamma, 0);
}
@Test
public void testCountEntitiesWithOrTimeSeriesKeyFilters() throws Exception {
String type = "orTsType";
createDeviceWithTimeseries("OrTsDeviceA", type, "{\"temperature\":60}");
createDeviceWithTimeseries("OrTsDeviceB", type, "{\"temperature\":5}");
createDeviceWithTimeseries("OrTsDeviceC", type, "{\"temperature\":30}");
List<KeyFilter> keyFilters = List.of(
numericKeyFilter(EntityKeyType.TIME_SERIES, "temperature", NumericFilterPredicate.NumericOperation.GREATER, 50),
numericKeyFilter(EntityKeyType.TIME_SERIES, "temperature", NumericFilterPredicate.NumericOperation.LESS, 10));
// OR: deviceA (60>50) and deviceB (5<10) match => count=2
EntityCountQuery orQuery = new EntityCountQuery(deviceTypeFilter(type), keyFilters, ComplexOperation.OR);
await().atMost(TIMEOUT, TimeUnit.SECONDS).untilAsserted(() -> countByQueryAndCheck(orQuery, 2));
// AND: no device has ts temperature both >50 AND <10 => count=0
EntityCountQuery andQuery = new EntityCountQuery(deviceTypeFilter(type), keyFilters, ComplexOperation.AND);
countByQueryAndCheck(andQuery, 0);
}
@Test
public void testCountEntitiesWithOrComplexFilterPredicate() throws Exception {
// Key-level ComplexFilterPredicate combined with query-level OR
String type = "orCplxType";
createDeviceWithSharedAttributes("OrCplxDeviceA", type, "{\"temperature\":65,\"humidity\":50}");
createDeviceWithSharedAttributes("OrCplxDeviceB", type, "{\"temperature\":25,\"humidity\":90}");
createDeviceWithSharedAttributes("OrCplxDeviceC", type, "{\"temperature\":25,\"humidity\":50}");
// Key filter 1: temperature > 50 AND temperature < 70 (complex predicate within key filter) — matches A only
NumericFilterPredicate gt50 = new NumericFilterPredicate();
gt50.setValue(FilterPredicateValue.fromDouble(50));
gt50.setOperation(NumericFilterPredicate.NumericOperation.GREATER);
NumericFilterPredicate lt70 = new NumericFilterPredicate();
lt70.setValue(FilterPredicateValue.fromDouble(70));
lt70.setOperation(NumericFilterPredicate.NumericOperation.LESS);
ComplexFilterPredicate complexTempPred = new ComplexFilterPredicate();
complexTempPred.setOperation(ComplexOperation.AND);
complexTempPred.setPredicates(List.of(gt50, lt70));
KeyFilter tempComplexFilter = new KeyFilter();
tempComplexFilter.setKey(new EntityKey(EntityKeyType.ATTRIBUTE, "temperature"));
tempComplexFilter.setValueType(EntityKeyValueType.NUMERIC);
tempComplexFilter.setPredicate(complexTempPred);
// Key filter 2: humidity > 80 (simple predicate) — matches B only
List<KeyFilter> keyFilters = List.of(
tempComplexFilter,
numericAttributeKeyFilter("humidity", NumericFilterPredicate.NumericOperation.GREATER, 80));
// Query-level OR: A matches key filter 1, B matches key filter 2 => 2
EntityCountQuery orQuery = new EntityCountQuery(deviceTypeFilter(type), keyFilters, ComplexOperation.OR);
await().atMost(TIMEOUT, TimeUnit.SECONDS).untilAsserted(() -> countByQueryAndCheck(orQuery, 2));
// Query-level AND: no device matches both key filters => 0
EntityCountQuery andQuery = new EntityCountQuery(deviceTypeFilter(type), keyFilters, ComplexOperation.AND);
countByQueryAndCheck(andQuery, 0);
}
}

8
application/src/test/java/org/thingsboard/server/edge/UserEdgeTest.java

@ -20,7 +20,6 @@ import org.junit.Assert;
import org.junit.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.test.web.servlet.ResultMatcher;
import org.testcontainers.shaded.org.awaitility.Awaitility;
import org.thingsboard.common.util.JacksonUtil;
import org.thingsboard.server.common.data.Customer;
@ -224,7 +223,12 @@ public class UserEdgeTest extends AbstractEdgeTest {
User savedUser = createUser(user, password);
Assert.assertTrue(edgeImitator.waitForMessages());
Assert.assertEquals(1, edgeImitator.findAllMessagesByType(UserUpdateMsg.class).size());
Assert.assertEquals(2, edgeImitator.findAllMessagesByType(UserCredentialsUpdateMsg.class).size());
// The initial USER ADDED edge event may bundle a UserCredentialsUpdateMsg when
// user activation completes before the event is processed, in addition to the 2
// messages from the CREDENTIALS_UPDATED events fired during activation. Accept 2 or 3.
int credMsgCount = edgeImitator.findAllMessagesByType(UserCredentialsUpdateMsg.class).size();
Assert.assertTrue("Expected 2 or 3 UserCredentialsUpdateMsg (ADDED/activation race), got " + credMsgCount,
credMsgCount == 2 || credMsgCount == 3);
UserUpdateMsg userUpdateMsg = getLatestUserUpdateMsg();
User userMsg = JacksonUtil.fromString(userUpdateMsg.getEntity(), User.class, true);

1
common/data/src/main/java/org/thingsboard/server/common/data/SystemParams.java

@ -45,4 +45,5 @@ public class SystemParams {
long minAllowedAggregationIntervalInSecForCF;
long intermediateAggregationIntervalInSecForCF;
TrendzSettings trendzSettings;
boolean allowKeyFiltersOrConditions;
}

1
common/data/src/main/java/org/thingsboard/server/common/data/alarm/rule/condition/expression/AlarmConditionFilter.java

@ -24,6 +24,7 @@ import lombok.Data;
import org.thingsboard.server.common.data.alarm.rule.condition.expression.predicate.ComplexFilterPredicate;
import org.thingsboard.server.common.data.alarm.rule.condition.expression.predicate.FilterPredicateType;
import org.thingsboard.server.common.data.alarm.rule.condition.expression.predicate.KeyFilterPredicate;
import org.thingsboard.server.common.data.query.ComplexOperation;
import org.thingsboard.server.common.data.query.EntityKeyValueType;
import io.swagger.v3.oas.annotations.media.Schema;

1
common/data/src/main/java/org/thingsboard/server/common/data/alarm/rule/condition/expression/SimpleAlarmConditionExpression.java

@ -22,6 +22,7 @@ import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import org.thingsboard.server.common.data.alarm.rule.condition.expression.predicate.FilterPredicateType;
import org.thingsboard.server.common.data.query.ComplexOperation;
import java.util.List;

2
common/data/src/main/java/org/thingsboard/server/common/data/alarm/rule/condition/expression/predicate/ComplexFilterPredicate.java

@ -18,7 +18,7 @@ package org.thingsboard.server.common.data.alarm.rule.condition.expression.predi
import io.swagger.v3.oas.annotations.media.ArraySchema;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import org.thingsboard.server.common.data.alarm.rule.condition.expression.ComplexOperation;
import org.thingsboard.server.common.data.query.ComplexOperation;
import java.util.List;

12
common/data/src/main/java/org/thingsboard/server/common/data/query/AbstractDataQuery.java

@ -49,4 +49,16 @@ public abstract class AbstractDataQuery<T extends EntityDataPageLink> extends En
this.latestValues = latestValues;
}
public AbstractDataQuery(EntityFilter entityFilter,
T pageLink,
List<EntityKey> entityFields,
List<EntityKey> latestValues,
List<KeyFilter> keyFilters,
ComplexOperation keyFiltersOperation) {
super(entityFilter, keyFilters, keyFiltersOperation);
this.pageLink = pageLink;
this.entityFields = entityFields;
this.latestValues = latestValues;
}
}

4
common/data/src/main/java/org/thingsboard/server/common/data/query/AlarmCountQuery.java

@ -51,4 +51,8 @@ public class AlarmCountQuery extends EntityCountQuery {
super(entityFilter, keyFilters);
}
public AlarmCountQuery(EntityFilter entityFilter, List<KeyFilter> keyFilters, ComplexOperation keyFiltersOperation) {
super(entityFilter, keyFilters, keyFiltersOperation);
}
}

7
common/data/src/main/java/org/thingsboard/server/common/data/query/AlarmDataQuery.java

@ -39,8 +39,13 @@ public class AlarmDataQuery extends AbstractDataQuery<AlarmDataPageLink> {
this.alarmFields = alarmFields;
}
public AlarmDataQuery(EntityFilter entityFilter, AlarmDataPageLink pageLink, List<EntityKey> entityFields, List<EntityKey> latestValues, List<KeyFilter> keyFilters, List<EntityKey> alarmFields, ComplexOperation keyFiltersOperation) {
super(entityFilter, pageLink, entityFields, latestValues, keyFilters, keyFiltersOperation);
this.alarmFields = alarmFields;
}
@JsonIgnore
public AlarmDataQuery next() {
return new AlarmDataQuery(getEntityFilter(), getPageLink().nextPageLink(), entityFields, latestValues, keyFilters, alarmFields);
return new AlarmDataQuery(getEntityFilter(), getPageLink().nextPageLink(), entityFields, latestValues, keyFilters, alarmFields, getKeyFiltersOperation());
}
}

5
common/data/src/main/java/org/thingsboard/server/common/data/query/ComplexFilterPredicate.java

@ -34,9 +34,4 @@ public class ComplexFilterPredicate implements KeyFilterPredicate {
return FilterPredicateType.COMPLEX;
}
@Schema
public enum ComplexOperation {
AND,
OR
}
}

2
common/data/src/main/java/org/thingsboard/server/common/data/alarm/rule/condition/expression/ComplexOperation.java → common/data/src/main/java/org/thingsboard/server/common/data/query/ComplexOperation.java

@ -13,7 +13,7 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.thingsboard.server.common.data.alarm.rule.condition.expression;
package org.thingsboard.server.common.data.query;
import io.swagger.v3.oas.annotations.media.Schema;

13
common/data/src/main/java/org/thingsboard/server/common/data/query/EntityCountQuery.java

@ -34,6 +34,9 @@ public class EntityCountQuery {
@Getter
protected List<KeyFilter> keyFilters;
@Getter
private ComplexOperation keyFiltersOperation;
public EntityCountQuery() {
}
@ -45,4 +48,14 @@ public class EntityCountQuery {
this.entityFilter = entityFilter;
this.keyFilters = keyFilters;
}
public EntityCountQuery(EntityFilter entityFilter, List<KeyFilter> keyFilters, ComplexOperation keyFiltersOperation) {
this.entityFilter = entityFilter;
this.keyFilters = keyFilters;
this.keyFiltersOperation = keyFiltersOperation;
}
public ComplexOperation getKeyFiltersOperationOrDefault() {
return keyFiltersOperation != null ? keyFiltersOperation : ComplexOperation.AND;
}
}

6
common/data/src/main/java/org/thingsboard/server/common/data/query/EntityDataQuery.java

@ -34,9 +34,13 @@ public class EntityDataQuery extends AbstractDataQuery<EntityDataPageLink> {
super(entityFilter, pageLink, entityFields, latestValues, keyFilters);
}
public EntityDataQuery(EntityFilter entityFilter, EntityDataPageLink pageLink, List<EntityKey> entityFields, List<EntityKey> latestValues, List<KeyFilter> keyFilters, ComplexOperation keyFiltersOperation) {
super(entityFilter, pageLink, entityFields, latestValues, keyFilters, keyFiltersOperation);
}
@JsonIgnore
public EntityDataQuery next() {
return new EntityDataQuery(getEntityFilter(), getPageLink().nextPageLink(), entityFields, latestValues, keyFilters);
return new EntityDataQuery(getEntityFilter(), getPageLink().nextPageLink(), entityFields, latestValues, keyFilters, getKeyFiltersOperation());
}
}

5
common/edqs/src/main/java/org/thingsboard/server/edqs/data/dp/StringDataPoint.java

@ -33,6 +33,11 @@ public class StringDataPoint extends AbstractDataPoint {
this.value = deduplicate ? TbStringPool.intern(value) : value;
}
@Override
public boolean getBool() {
return Boolean.parseBoolean(value);
}
@Override
public double getDouble() {
return Double.parseDouble(value);

5
common/edqs/src/main/java/org/thingsboard/server/edqs/query/EdqsCountQuery.java

@ -16,6 +16,7 @@
package org.thingsboard.server.edqs.query;
import lombok.Builder;
import org.thingsboard.server.common.data.query.ComplexOperation;
import org.thingsboard.server.common.data.query.EntityFilter;
import java.util.List;
@ -23,8 +24,8 @@ import java.util.List;
public class EdqsCountQuery extends EdqsQuery {
@Builder
EdqsCountQuery(EntityFilter entityFilter, boolean hasKeyFilters, List<EdqsFilter> keyFilters) {
super(entityFilter, hasKeyFilters, keyFilters);
EdqsCountQuery(EntityFilter entityFilter, boolean hasKeyFilters, List<EdqsFilter> keyFilters, ComplexOperation keyFiltersOperation) {
super(entityFilter, hasKeyFilters, keyFilters, keyFiltersOperation);
}
}

5
common/edqs/src/main/java/org/thingsboard/server/edqs/query/EdqsDataQuery.java

@ -19,6 +19,7 @@ import lombok.Builder;
import lombok.EqualsAndHashCode;
import lombok.Getter;
import org.thingsboard.server.common.data.StringUtils;
import org.thingsboard.server.common.data.query.ComplexOperation;
import org.thingsboard.server.common.data.query.EntityDataSortOrder;
import org.thingsboard.server.common.data.query.EntityFilter;
import org.thingsboard.server.common.data.query.EntityKeyType;
@ -41,10 +42,10 @@ public class EdqsDataQuery extends EdqsQuery {
private final List<DataKey> latestValues;
@Builder
public EdqsDataQuery(EntityFilter entityFilter, List<EdqsFilter> keyFilters,
public EdqsDataQuery(EntityFilter entityFilter, List<EdqsFilter> keyFilters, ComplexOperation keyFiltersOperation,
int pageSize, int page, String textSearch, DataKey sortKey, EntityDataSortOrder.Direction sortDirection,
List<DataKey> entityFields, List<DataKey> latestValues) {
super(entityFilter, CollectionsUtil.isNotEmpty(keyFilters), keyFilters);
super(entityFilter, CollectionsUtil.isNotEmpty(keyFilters), keyFilters, keyFiltersOperation);
this.pageSize = pageSize;
this.page = page;
this.hasTextSearch = StringUtils.isNotBlank(textSearch);

2
common/edqs/src/main/java/org/thingsboard/server/edqs/query/EdqsQuery.java

@ -16,6 +16,7 @@
package org.thingsboard.server.edqs.query;
import lombok.Data;
import org.thingsboard.server.common.data.query.ComplexOperation;
import org.thingsboard.server.common.data.query.EntityFilter;
import java.util.List;
@ -26,5 +27,6 @@ public abstract class EdqsQuery {
private final EntityFilter entityFilter;
private final boolean hasKeyFilters;
private final List<EdqsFilter> keyFilters;
private final ComplexOperation keyFiltersOperation;
}

84
common/edqs/src/main/java/org/thingsboard/server/edqs/util/RepositoryUtils.java

@ -22,6 +22,7 @@ import org.thingsboard.server.common.data.edqs.DataPoint;
import org.thingsboard.server.common.data.permission.QueryContext;
import org.thingsboard.server.common.data.query.BooleanFilterPredicate;
import org.thingsboard.server.common.data.query.ComplexFilterPredicate;
import org.thingsboard.server.common.data.query.ComplexOperation;
import org.thingsboard.server.common.data.query.EntityCountQuery;
import org.thingsboard.server.common.data.query.EntityDataQuery;
import org.thingsboard.server.common.data.query.EntityDataSortOrder;
@ -60,8 +61,8 @@ import java.util.stream.Stream;
import static org.apache.commons.lang3.StringUtils.containsIgnoreCase;
import static org.thingsboard.server.common.data.StringUtils.equalsAny;
import static org.thingsboard.server.common.data.StringUtils.splitByCommaWithoutQuotes;
import static org.thingsboard.server.common.data.query.ComplexFilterPredicate.ComplexOperation.AND;
import static org.thingsboard.server.common.data.query.ComplexFilterPredicate.ComplexOperation.OR;
import static org.thingsboard.server.common.data.query.ComplexOperation.AND;
import static org.thingsboard.server.common.data.query.ComplexOperation.OR;
@Slf4j
public class RepositoryUtils {
@ -139,6 +140,7 @@ public class RepositoryUtils {
}
query.entityFilter(oldQuery.getEntityFilter());
query.keyFilters(toKeyFilters(oldQuery.getKeyFilters()));
query.keyFiltersOperation(oldQuery.getKeyFiltersOperationOrDefault());
query.entityFields(toNewKeys(oldQuery.getEntityFields()));
query.latestValues(toNewKeys(oldQuery.getLatestValues()));
return query.build();
@ -149,6 +151,7 @@ public class RepositoryUtils {
.entityFilter(oldQuery.getEntityFilter())
.hasKeyFilters(CollectionsUtil.isNotEmpty(oldQuery.getKeyFilters()))
.keyFilters(toKeyFilters(oldQuery.getKeyFilters()))
.keyFiltersOperation(oldQuery.getKeyFiltersOperationOrDefault())
.build();
}
@ -196,38 +199,57 @@ public class RepositoryUtils {
}
public static boolean checkKeyFilters(EntityData entity, List<EdqsFilter> keyFilters) {
for (EdqsFilter keyFilter : keyFilters) {
EntityKeyValueType valueType = keyFilter.valueType();
if (valueType == null) {
valueType = switch (keyFilter.predicate().getType()) {
case STRING -> EntityKeyValueType.STRING;
case NUMERIC -> EntityKeyValueType.NUMERIC;
case BOOLEAN -> EntityKeyValueType.BOOLEAN;
default -> throw new IllegalStateException();
};
}
DataKey dataKey = keyFilter.key();
DataPoint dp = entity.getDataPoint(dataKey, null);
boolean checkResult = switch (valueType) {
case STRING -> {
String str = dp != null ? dp.valueToString() : null;
yield (dataKey.type() == EntityKeyType.ENTITY_FIELD) ? (str == null || checkKeyFilter(str, keyFilter.predicate())) :
(str != null && checkKeyFilter(str, keyFilter.predicate()));
}
case BOOLEAN -> {
Boolean booleanValue = dp != null ? dp.getBool() : null;
yield booleanValue != null && checkKeyFilter(booleanValue, keyFilter.predicate());
return checkKeyFilters(entity, keyFilters, ComplexOperation.AND);
}
public static boolean checkKeyFilters(EntityData entity, List<EdqsFilter> keyFilters, ComplexOperation operation) {
ComplexOperation op = operation != null ? operation : ComplexOperation.AND;
if (op == ComplexOperation.OR) {
for (EdqsFilter keyFilter : keyFilters) {
if (evaluateSingleFilter(entity, keyFilter)) {
return true;
}
case DATE_TIME, NUMERIC -> {
Double doubleValue = dp != null ? dp.getDouble() : null;
yield doubleValue != null && checkKeyFilter(doubleValue, keyFilter.predicate());
}
// Vacuously true when OR is called with an empty filter list. Unreachable via checkFilters
// (which guards on isHasKeyFilters()), but kept defensive for any future direct caller.
return keyFilters.isEmpty();
} else {
for (EdqsFilter keyFilter : keyFilters) {
if (!evaluateSingleFilter(entity, keyFilter)) {
return false;
}
};
if (!checkResult) {
return false;
}
return true;
}
return true;
}
private static boolean evaluateSingleFilter(EntityData entity, EdqsFilter keyFilter) {
EntityKeyValueType valueType = keyFilter.valueType();
if (valueType == null) {
valueType = switch (keyFilter.predicate().getType()) {
case STRING -> EntityKeyValueType.STRING;
case NUMERIC -> EntityKeyValueType.NUMERIC;
case BOOLEAN -> EntityKeyValueType.BOOLEAN;
default -> throw new IllegalStateException();
};
}
DataKey dataKey = keyFilter.key();
DataPoint dp = entity.getDataPoint(dataKey, null);
return switch (valueType) {
case STRING -> {
String str = dp != null ? dp.valueToString() : null;
yield (dataKey.type() == EntityKeyType.ENTITY_FIELD) ? (str == null || checkKeyFilter(str, keyFilter.predicate())) :
(str != null && checkKeyFilter(str, keyFilter.predicate()));
}
case BOOLEAN -> {
Boolean booleanValue = dp != null ? dp.getBool() : null;
yield booleanValue != null && checkKeyFilter(booleanValue, keyFilter.predicate());
}
case DATE_TIME, NUMERIC -> {
Double doubleValue = dp != null ? dp.getDouble() : null;
yield doubleValue != null && checkKeyFilter(doubleValue, keyFilter.predicate());
}
};
}
public static boolean checkKeyFilter(String value, KeyFilterPredicate keyFilterPredicate) {
@ -376,7 +398,7 @@ public class RepositoryUtils {
if (entity == null || entity.getFields() == null) {
return false; // Entity was already removed or not arrived yet;
}
if (query.isHasKeyFilters() && !checkKeyFilters(entity, query.getKeyFilters())) {
if (query.isHasKeyFilters() && !checkKeyFilters(entity, query.getKeyFilters(), query.getKeyFiltersOperation())) {
return false;
}
if (query instanceof EdqsDataQuery dataQuery) {

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

@ -21,6 +21,7 @@ import com.google.common.util.concurrent.ListenableFuture;
import com.google.common.util.concurrent.MoreExecutors;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Lazy;
import org.springframework.stereotype.Service;
import org.springframework.util.CollectionUtils;
@ -40,6 +41,7 @@ import org.thingsboard.server.common.data.id.HasId;
import org.thingsboard.server.common.data.id.NameLabelAndCustomerDetails;
import org.thingsboard.server.common.data.id.TenantId;
import org.thingsboard.server.common.data.page.PageData;
import org.thingsboard.server.common.data.query.ComplexOperation;
import org.thingsboard.server.common.data.query.EntityCountQuery;
import org.thingsboard.server.common.data.query.EntityData;
import org.thingsboard.server.common.data.query.EntityDataPageLink;
@ -91,6 +93,9 @@ public class BaseEntityService extends AbstractEntityService implements EntitySe
private static final Set<EntityFilterType> EXCLUDED_TYPES_FROM_OPTIMIZATION = Set.of(
EntityFilterType.ENTITY_LIST, EntityFilterType.SINGLE_ENTITY, EntityFilterType.RELATIONS_QUERY);
@Value("${sql.query.key-filters-or-conditions.enabled:true}")
private boolean keyFiltersOrConditionsEnabled;
@Autowired
private EntityQueryDao entityQueryDao;
@ -328,7 +333,7 @@ public class BaseEntityService extends AbstractEntityService implements EntitySe
return new NameLabelAndCustomerDetails(getName(entity), getLabel(entity), getCustomerId(entity));
}
private static void validateEntityCountQuery(EntityCountQuery query) {
private void validateEntityCountQuery(EntityCountQuery query) {
if (query == null) {
throw new IncorrectParameterException("Query must be specified.");
} else if (query.getEntityFilter() == null) {
@ -342,9 +347,14 @@ public class BaseEntityService extends AbstractEntityService implements EntitySe
} else if (query.getEntityFilter().getType().equals(ENTITY_NAME)) {
validateEntityNameQuery((EntityNameFilter) query.getEntityFilter());
}
// Intentionally using the nullable getKeyFiltersOperation() (not getKeyFiltersOperationOrDefault()):
// a null value encodes "classic AND" and must pass this guard even when the OR feature flag is off.
if (!keyFiltersOrConditionsEnabled && query.getKeyFiltersOperation() == ComplexOperation.OR) {
throw new IncorrectParameterException("OR conditions between key filters are disabled by the system administrator.");
}
}
private static void validateEntityDataQuery(EntityDataQuery query) {
private void validateEntityDataQuery(EntityDataQuery query) {
validateEntityCountQuery(query);
validateEntityDataPageLink(query.getPageLink());
}
@ -409,7 +419,7 @@ public class BaseEntityService extends AbstractEntityService implements EntitySe
.collect(Collectors.toList());
}
}
EntityDataQuery entityQuery = new EntityDataQuery(query.getEntityFilter(), query.getPageLink(), entityFields, latestValues, query.getKeyFilters());
EntityDataQuery entityQuery = new EntityDataQuery(query.getEntityFilter(), query.getPageLink(), entityFields, latestValues, query.getKeyFilters(), query.getKeyFiltersOperation());
return this.entityQueryDao.findEntityDataByQuery(tenantId, customerId, entityQuery);
}

103
dao/src/main/java/org/thingsboard/server/dao/sql/query/DefaultEntityQueryRepository.java

@ -30,6 +30,7 @@ import org.thingsboard.server.common.data.page.PageData;
import org.thingsboard.server.common.data.permission.QueryContext;
import org.thingsboard.server.common.data.query.ApiUsageStateFilter;
import org.thingsboard.server.common.data.query.AssetSearchQueryFilter;
import org.thingsboard.server.common.data.query.ComplexOperation;
import org.thingsboard.server.common.data.query.AssetTypeFilter;
import org.thingsboard.server.common.data.query.DeviceSearchQueryFilter;
import org.thingsboard.server.common.data.query.DeviceTypeFilter;
@ -54,6 +55,7 @@ import org.thingsboard.server.common.data.query.SingleEntityFilter;
import org.thingsboard.server.common.data.relation.EntitySearchDirection;
import org.thingsboard.server.common.data.relation.RelationEntityTypeFilter;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
import java.util.HashMap;
@ -351,25 +353,56 @@ public class DefaultEntityQueryRepository implements EntityQueryRepository {
}
});
} else {
List<EntityKeyMapping> mappings = EntityKeyMapping.prepareEntityCountKeyMapping(query);
ComplexOperation operation = query.getKeyFiltersOperationOrDefault();
boolean isOr = operation == ComplexOperation.OR;
List<EntityKeyMapping> selectionMapping = mappings.stream().filter(EntityKeyMapping::isSelection)
.collect(Collectors.toList());
List<EntityKeyMapping> entityFieldsSelectionMapping = selectionMapping.stream().filter(mapping -> !mapping.isLatest())
.collect(Collectors.toList());
List<EntityKeyMapping> mappings = EntityKeyMapping.prepareEntityCountKeyMapping(query);
List<EntityKeyMapping> selectionMapping = new ArrayList<>(mappings.stream().filter(EntityKeyMapping::isSelection)
.collect(Collectors.toList()));
List<EntityKeyMapping> filterMapping = mappings.stream().filter(EntityKeyMapping::hasFilter)
.collect(Collectors.toList());
List<EntityKeyMapping> entityFieldsFiltersMapping = filterMapping.stream().filter(mapping -> !mapping.isLatest() && mapping.getEntityKeyColumn() != null)
.collect(Collectors.toList());
// Under OR: entity field filter columns must be in inner SELECT for outer WHERE reference.
// Mirror the ignore=true fix from findEntityDataByQuery so the inner subquery still emits the
// extra column but downstream response shape (benign for count) stays symmetric with the data path.
if (isOr) {
for (EntityKeyMapping m : entityFieldsFiltersMapping) {
if (!selectionMapping.contains(m)) {
m.setIgnore(true);
selectionMapping.add(m);
}
}
}
List<EntityKeyMapping> entityFieldsSelectionMapping = selectionMapping.stream().filter(mapping -> !mapping.isLatest())
.collect(Collectors.toList());
List<EntityKeyMapping> allLatestMappings = mappings.stream().filter(EntityKeyMapping::isLatest)
.collect(Collectors.toList());
// Under OR: entity field filters move to outer WHERE (not inner WHERE)
List<EntityKeyMapping> innerEntityFieldsFilters = isOr ? Collections.emptyList() : entityFieldsFiltersMapping;
String entityWhereClause = DefaultEntityQueryRepository.this.buildEntityWhere(ctx, query.getEntityFilter(), innerEntityFieldsFilters);
String latestJoinsCnt = EntityKeyMapping.buildLatestJoins(ctx, query.getEntityFilter(), entityType, allLatestMappings, true, isOr);
// Under OR: combine ALL filter mappings (entity fields + aliases) with OR joiner
// Place at the middle layer (after JOINs) where alias table references are visible.
// Under AND: place at the outer result layer (existing behavior).
String aliasWhereQuery;
if (isOr) {
String combinedFilterQuery = EntityKeyMapping.buildQuery(ctx, filterMapping, query.getEntityFilter().getType(), ComplexOperation.OR);
if (!combinedFilterQuery.isEmpty()) {
latestJoinsCnt += " where (" + combinedFilterQuery + ")";
}
aliasWhereQuery = "";
} else {
aliasWhereQuery = DefaultEntityQueryRepository.this.buildAliasWhereQuery(ctx, query.getEntityFilter(), selectionMapping, "");
}
String entityWhereClause = DefaultEntityQueryRepository.this.buildEntityWhere(ctx, query.getEntityFilter(), entityFieldsFiltersMapping);
String aliasWhereQuery = DefaultEntityQueryRepository.this.buildAliasWhereQuery(ctx, query.getEntityFilter(), selectionMapping, "");
String latestJoinsCnt = EntityKeyMapping.buildLatestJoins(ctx, query.getEntityFilter(), entityType, allLatestMappings, true);
String entityFieldsSelection = EntityKeyMapping.buildSelections(entityFieldsSelectionMapping, query.getEntityFilter().getType(), entityType);
String entityTypeStr;
if (query.getEntityFilter().getType().equals(EntityFilterType.RELATIONS_QUERY)) {
@ -419,13 +452,13 @@ public class DefaultEntityQueryRepository implements EntityQueryRepository {
EntityType entityType = resolveEntityType(query.getEntityFilter());
SqlQueryContext ctx = new SqlQueryContext(new QueryContext(tenantId, customerId, entityType, ignorePermissionCheck));
EntityDataPageLink pageLink = query.getPageLink();
ComplexOperation operation = query.getKeyFiltersOperationOrDefault();
boolean isOr = operation == ComplexOperation.OR;
List<EntityKeyMapping> mappings = EntityKeyMapping.prepareKeyMapping(entityType, query);
List<EntityKeyMapping> selectionMapping = mappings.stream().filter(EntityKeyMapping::isSelection)
.collect(Collectors.toList());
List<EntityKeyMapping> entityFieldsSelectionMapping = selectionMapping.stream().filter(mapping -> !mapping.isLatest())
.collect(Collectors.toList());
List<EntityKeyMapping> selectionMapping = new ArrayList<>(mappings.stream().filter(EntityKeyMapping::isSelection)
.collect(Collectors.toList()));
List<EntityKeyMapping> latestSelectionMapping = selectionMapping.stream().filter(EntityKeyMapping::isLatest)
.collect(Collectors.toList());
@ -434,14 +467,52 @@ public class DefaultEntityQueryRepository implements EntityQueryRepository {
List<EntityKeyMapping> entityFieldsFiltersMapping = filterMapping.stream().filter(mapping -> !mapping.isLatest() && mapping.getEntityKeyColumn() != null)
.collect(Collectors.toList());
// Under OR: entity field filter columns must be in inner SELECT for outer WHERE reference.
// Mark force-added filter-only mappings as ignored so EntityDataAdapter does not expose
// them in EntityData.latest — keeps the response shape identical to AND.
if (isOr) {
for (EntityKeyMapping m : entityFieldsFiltersMapping) {
if (!selectionMapping.contains(m)) {
m.setIgnore(true);
selectionMapping.add(m);
}
}
}
List<EntityKeyMapping> entityFieldsSelectionMapping = selectionMapping.stream().filter(mapping -> !mapping.isLatest())
.collect(Collectors.toList());
List<EntityKeyMapping> allLatestMappings = mappings.stream().filter(EntityKeyMapping::isLatest)
.collect(Collectors.toList());
// Under OR: entity field filters move to outer WHERE (not inner WHERE)
List<EntityKeyMapping> innerEntityFieldsFilters = isOr ? Collections.emptyList() : entityFieldsFiltersMapping;
String entityWhereClause = DefaultEntityQueryRepository.this.buildEntityWhere(ctx, query.getEntityFilter(), innerEntityFieldsFilters);
String latestJoinsCnt = EntityKeyMapping.buildLatestJoins(ctx, query.getEntityFilter(), entityType, allLatestMappings, true, isOr);
String latestJoinsData = EntityKeyMapping.buildLatestJoins(ctx, query.getEntityFilter(), entityType, allLatestMappings, false, isOr);
// Under OR: combine ALL filter mappings (entity fields + aliases) with OR joiner
// Place at the middle layer (after JOINs) where alias table references are visible.
// Under AND: place at the outer result layer (existing behavior).
String aliasWhereQuery;
if (isOr) {
String combinedFilterQuery = EntityKeyMapping.buildQuery(ctx, filterMapping, query.getEntityFilter().getType(), ComplexOperation.OR);
String middleWhere = "";
if (!combinedFilterQuery.isEmpty()) {
middleWhere = " where (" + combinedFilterQuery + ")";
}
String searchTextQuery = buildTextSearchQuery(ctx, selectionMapping, pageLink.getTextSearch());
if (!searchTextQuery.isEmpty()) {
middleWhere += (middleWhere.isEmpty() ? " where " : " and ") + "(" + searchTextQuery + ") ";
}
latestJoinsCnt += middleWhere;
latestJoinsData += middleWhere;
aliasWhereQuery = "";
} else {
aliasWhereQuery = DefaultEntityQueryRepository.this.buildAliasWhereQuery(ctx, query.getEntityFilter(), selectionMapping, pageLink.getTextSearch());
}
String entityWhereClause = DefaultEntityQueryRepository.this.buildEntityWhere(ctx, query.getEntityFilter(), entityFieldsFiltersMapping);
String latestJoinsCnt = EntityKeyMapping.buildLatestJoins(ctx, query.getEntityFilter(), entityType, allLatestMappings, true);
String latestJoinsData = EntityKeyMapping.buildLatestJoins(ctx, query.getEntityFilter(), entityType, allLatestMappings, false);
String aliasWhereQuery = DefaultEntityQueryRepository.this.buildAliasWhereQuery(ctx, query.getEntityFilter(), selectionMapping, pageLink.getTextSearch());
String entityFieldsSelection = EntityKeyMapping.buildSelections(entityFieldsSelectionMapping, query.getEntityFilter().getType(), entityType);
String entityTypeStr;
if (query.getEntityFilter().getType().equals(EntityFilterType.RELATIONS_QUERY)) {

161
dao/src/main/java/org/thingsboard/server/dao/sql/query/EntityKeyMapping.java

@ -21,6 +21,7 @@ import org.thingsboard.server.common.data.EntityType;
import org.thingsboard.server.common.data.StringUtils;
import org.thingsboard.server.common.data.query.BooleanFilterPredicate;
import org.thingsboard.server.common.data.query.ComplexFilterPredicate;
import org.thingsboard.server.common.data.query.ComplexOperation;
import org.thingsboard.server.common.data.query.EntityCountQuery;
import org.thingsboard.server.common.data.query.EntityDataQuery;
import org.thingsboard.server.common.data.query.EntityDataSortOrder;
@ -283,16 +284,39 @@ public class EntityKeyMapping {
}
public Stream<String> toQueries(SqlQueryContext ctx, EntityFilterType filterType) {
if (hasFilter()) {
String keyAlias = (entityKey.getType().equals(EntityKeyType.ENTITY_FIELD) && getEntityKeyColumn() != null) ? "e" : alias;
return keyFilters.stream().map(keyFilter ->
this.buildKeyQuery(ctx, keyAlias, keyFilter, filterType));
} else {
return toQueries(ctx, filterType, false);
}
public Stream<String> toQueries(SqlQueryContext ctx, EntityFilterType filterType, boolean outerContext) {
if (!hasFilter()) {
return Stream.empty();
}
String keyAlias;
boolean useAliasDirectly = false;
if (entityKey.getType().equals(EntityKeyType.ENTITY_FIELD) && getEntityKeyColumn() != null) {
if (outerContext) {
// In the middle layer (OR relocation), entity field columns are exposed
// by their alias name from the inner subquery SELECT (e.g., "alias2" from
// "cast(e.name as varchar) as alias2"), so buildSimplePredicateQuery must
// use the alias directly as the field instead of appending entityKeyColumn.
keyAlias = alias;
useAliasDirectly = true;
} else {
keyAlias = "e";
}
} else {
keyAlias = alias;
}
final boolean aliasAsField = useAliasDirectly;
return keyFilters.stream().map(keyFilter ->
this.buildKeyQuery(ctx, keyAlias, keyFilter, filterType, aliasAsField));
}
public String toLatestJoin(SqlQueryContext ctx, EntityFilter entityFilter, EntityType entityType) {
return toLatestJoin(ctx, entityFilter, entityType, false);
}
public String toLatestJoin(SqlQueryContext ctx, EntityFilter entityFilter, EntityType entityType, boolean forceLeftJoin) {
String entityTypeStr;
if (entityFilter.getType().equals(EntityFilterType.RELATIONS_QUERY)) {
entityTypeStr = "entities.entity_type";
@ -300,20 +324,23 @@ public class EntityKeyMapping {
entityTypeStr = "'" + entityType.name() + "'";
}
ctx.addStringParameter(getKeyId(), entityKey.getKey());
String filterQuery = toQueries(ctx, entityFilter.getType())
// Under OR (forceLeftJoin=true) the filter predicate is re-emitted by buildQuery at the middle
// layer with disjunction semantics. Inlining it in the ON clause here would duplicate the
// predicate with a different bound parameter name and double-filter the LEFT JOIN.
String filterQuery = forceLeftJoin ? "" : toQueries(ctx, entityFilter.getType())
.filter(StringUtils::isNotEmpty)
.collect(Collectors.joining(" and "));
if (StringUtils.isNotEmpty(filterQuery)) {
filterQuery = " AND (" + filterQuery + ")";
}
if (entityKey.getType().equals(EntityKeyType.TIME_SERIES)) {
String join = (hasFilter() && hasFilterValues(ctx)) ? "inner join" : "left join";
String join = (!forceLeftJoin && hasFilter() && hasFilterValues(ctx)) ? "inner join" : "left join";
return String.format("%s ts_kv_latest %s ON %s.entity_id=entities.id AND %s.key = (select key_id from key_dictionary where key = :%s_key_id) %s",
join, alias, alias, alias, alias, filterQuery);
} else {
String query;
if (!entityKey.getType().equals(EntityKeyType.ATTRIBUTE)) {
String join = (hasFilter() && hasFilterValues(ctx)) ? "inner join" : "left join";
String join = (!forceLeftJoin && hasFilter() && hasFilterValues(ctx)) ? "inner join" : "left join";
query = String.format("%s attribute_kv %s ON %s.entity_id=entities.id AND %s.attribute_key=(select key_id from key_dictionary where key = :%s_key_id) ",
join, alias, alias, alias, alias);
int scope;
@ -326,7 +353,7 @@ public class EntityKeyMapping {
}
query = String.format("%s AND %s.attribute_type=%s %s", query, alias, scope, filterQuery);
} else {
String join = (hasFilter() && hasFilterValues(ctx)) ? "join LATERAL" : "left join LATERAL";
String join = (!forceLeftJoin && hasFilter() && hasFilterValues(ctx)) ? "join LATERAL" : "left join LATERAL";
query = String.format("%s (select * from attribute_kv %s WHERE %s.entity_id=entities.id AND %s.attribute_key=(select key_id from key_dictionary where key = :%s_key_id) %s " +
"ORDER BY %s.last_update_ts DESC limit 1) as %s ON true",
join, alias, alias, alias, alias, filterQuery, alias, alias);
@ -351,27 +378,47 @@ public class EntityKeyMapping {
}
public static String buildLatestJoins(SqlQueryContext ctx, EntityFilter entityFilter, EntityType entityType, List<EntityKeyMapping> latestMappings, boolean countQuery) {
return buildLatestJoins(ctx, entityFilter, entityType, latestMappings, countQuery, false);
}
public static String buildLatestJoins(SqlQueryContext ctx, EntityFilter entityFilter, EntityType entityType,
List<EntityKeyMapping> latestMappings, boolean countQuery, boolean forceLeftJoin) {
return latestMappings.stream()
.filter(mapping -> !countQuery || mapping.hasFilter())
.map(mapping -> mapping.toLatestJoin(ctx, entityFilter, entityType))
.map(mapping -> mapping.toLatestJoin(ctx, entityFilter, entityType, forceLeftJoin))
.collect(Collectors.joining(" "));
}
public static String buildQuery(SqlQueryContext ctx, List<EntityKeyMapping> mappings, EntityFilterType filterType) {
return buildQuery(ctx, mappings, filterType, ComplexOperation.AND);
}
public static String buildQuery(SqlQueryContext ctx, List<EntityKeyMapping> mappings,
EntityFilterType filterType, ComplexOperation operation) {
String joiner = (operation == ComplexOperation.OR) ? " OR " : " AND ";
// Vacuously-true predicates (e.g. a ComplexFilterPredicate with zero nested predicates producing
// an empty string) are dropped here. Under AND this is safe — TRUE is the identity for AND — but
// under OR it silently narrows the disjunction. Callers must not emit meaningful empty predicates.
return mappings.stream()
.flatMap(mapping -> mapping.toQueries(ctx, filterType))
.flatMap(mapping -> mapping.toQueries(ctx, filterType, operation == ComplexOperation.OR))
.filter(StringUtils::isNotEmpty)
.collect(Collectors.joining(" AND "));
.collect(Collectors.joining(joiner));
}
public static List<EntityKeyMapping> prepareKeyMapping(EntityType entityType, EntityDataQuery query) {
EntityFilterType entityFilterType = query.getEntityFilter().getType();
ComplexOperation operation = query.getKeyFiltersOperationOrDefault();
List<EntityKey> entityFields = query.getEntityFields() != null ? query.getEntityFields() : Collections.emptyList();
List<EntityKey> latestValues = query.getLatestValues() != null ? query.getLatestValues() : Collections.emptyList();
Map<EntityKey, List<KeyFilter>> filters =
query.getKeyFilters() != null ?
query.getKeyFilters().stream().collect(Collectors.groupingBy(KeyFilter::getKey)) : Collections.emptyMap();
Map<EntityKey, List<KeyFilter>> filters;
if (operation == ComplexOperation.OR) {
// Under OR, don't group same-key filters; handle individually below
filters = Collections.emptyMap();
} else {
filters = query.getKeyFilters() != null ?
query.getKeyFilters().stream().collect(Collectors.groupingBy(KeyFilter::getKey)) : Collections.emptyMap();
}
EntityDataSortOrder sortOrder = query.getPageLink().getSortOrder();
EntityKey sortOrderKey = sortOrder != null ? sortOrder.getKey() : null;
int index = 2;
@ -428,14 +475,37 @@ public class EntityKeyMapping {
for (EntityKeyMapping mapping : mappings) {
mapping.setIndex(index);
mapping.setAlias(String.format("alias%s", index));
mapping.setKeyFilters(filters.remove(mapping.entityKey));
if (operation != ComplexOperation.OR) {
// Under AND: assign grouped filters to matching selection mappings
mapping.setKeyFilters(filters.remove(mapping.entityKey));
}
if (mapping.getEntityKey().getType().equals(EntityKeyType.ENTITY_FIELD)) {
index++;
} else {
index += 2;
}
}
if (!filters.isEmpty()) {
if (operation == ComplexOperation.OR && query.getKeyFilters() != null) {
// Under OR: each KeyFilter gets its own EntityKeyMapping (no same-key grouping)
for (KeyFilter keyFilter : query.getKeyFilters()) {
EntityKeyMapping mapping = new EntityKeyMapping();
mapping.setIndex(index);
mapping.setAlias(String.format("alias%s", index));
mapping.setKeyFilters(Collections.singletonList(keyFilter));
EntityKey filterKey = keyFilter.getKey();
mapping.setLatest(!filterKey.getType().equals(EntityKeyType.ENTITY_FIELD));
mapping.setEntityKey(filterKey);
mapping.setEntityKeyColumn(entityType, entityFilterType);
mapping.setSelection(mapping.getEntityKeyColumn() == null);
mappings.add(mapping);
if (filterKey.getType().equals(EntityKeyType.ENTITY_FIELD)) {
index += 1;
} else {
index += 2;
}
}
} else if (!filters.isEmpty()) {
// Under AND: create mappings for filter-only keys not in selection
for (EntityKey filterField : filters.keySet()) {
EntityKeyMapping mapping = new EntityKeyMapping();
mapping.setIndex(index);
@ -464,25 +534,45 @@ public class EntityKeyMapping {
public static List<EntityKeyMapping> prepareEntityCountKeyMapping(EntityCountQuery query) {
EntityType entityType = resolveEntityType(query.getEntityFilter());
EntityFilterType entityFilterType = query.getEntityFilter().getType();
ComplexOperation operation = query.getKeyFiltersOperationOrDefault();
Map<EntityKey, List<KeyFilter>> filters =
query.getKeyFilters() != null ?
query.getKeyFilters().stream().collect(Collectors.groupingBy(KeyFilter::getKey)) : Collections.emptyMap();
int index = 2;
List<EntityKeyMapping> mappings = new ArrayList<>();
if (!filters.isEmpty()) {
for (EntityKey filterField : filters.keySet()) {
if (operation == ComplexOperation.OR && query.getKeyFilters() != null && !query.getKeyFilters().isEmpty()) {
// Under OR: each KeyFilter gets its own mapping (no same-key grouping)
for (KeyFilter keyFilter : query.getKeyFilters()) {
EntityKeyMapping mapping = new EntityKeyMapping();
mapping.setIndex(index);
mapping.setAlias(String.format("alias%s", index));
mapping.setKeyFilters(filters.get(filterField));
mapping.setLatest(!filterField.getType().equals(EntityKeyType.ENTITY_FIELD));
mapping.setEntityKey(filterField);
mapping.setKeyFilters(Collections.singletonList(keyFilter));
EntityKey filterKey = keyFilter.getKey();
mapping.setLatest(!filterKey.getType().equals(EntityKeyType.ENTITY_FIELD));
mapping.setEntityKey(filterKey);
mapping.setEntityKeyColumn(entityType, entityFilterType);
mapping.setSelection(mapping.getEntityKeyColumn() == null);
mappings.add(mapping);
index += 1;
}
} else {
// Under AND (or null/default): group same-key filters together
Map<EntityKey, List<KeyFilter>> filters =
query.getKeyFilters() != null ?
query.getKeyFilters().stream().collect(Collectors.groupingBy(KeyFilter::getKey)) : Collections.emptyMap();
if (!filters.isEmpty()) {
for (EntityKey filterField : filters.keySet()) {
EntityKeyMapping mapping = new EntityKeyMapping();
mapping.setIndex(index);
mapping.setAlias(String.format("alias%s", index));
mapping.setKeyFilters(filters.get(filterField));
mapping.setLatest(!filterField.getType().equals(EntityKeyType.ENTITY_FIELD));
mapping.setEntityKey(filterField);
mapping.setEntityKeyColumn(entityType, entityFilterType);
mapping.setSelection(mapping.getEntityKeyColumn() == null);
mappings.add(mapping);
index += 1;
}
}
}
return mappings;
@ -529,22 +619,27 @@ public class EntityKeyMapping {
private String buildKeyQuery(SqlQueryContext ctx, String alias, KeyFilter keyFilter,
EntityFilterType filterType) {
return this.buildPredicateQuery(ctx, alias, keyFilter.getKey(), keyFilter.getPredicate(), filterType);
return this.buildKeyQuery(ctx, alias, keyFilter, filterType, false);
}
private String buildKeyQuery(SqlQueryContext ctx, String alias, KeyFilter keyFilter,
EntityFilterType filterType, boolean useAliasAsField) {
return this.buildPredicateQuery(ctx, alias, keyFilter.getKey(), keyFilter.getPredicate(), filterType, useAliasAsField);
}
private String buildPredicateQuery(SqlQueryContext ctx, String alias, EntityKey key,
KeyFilterPredicate predicate, EntityFilterType filterType) {
KeyFilterPredicate predicate, EntityFilterType filterType, boolean useAliasAsField) {
if (predicate.getType().equals(FilterPredicateType.COMPLEX)) {
return this.buildComplexPredicateQuery(ctx, alias, key, (ComplexFilterPredicate) predicate, filterType);
return this.buildComplexPredicateQuery(ctx, alias, key, (ComplexFilterPredicate) predicate, filterType, useAliasAsField);
} else {
return this.buildSimplePredicateQuery(ctx, alias, key, predicate, filterType);
return this.buildSimplePredicateQuery(ctx, alias, key, predicate, filterType, useAliasAsField);
}
}
private String buildComplexPredicateQuery(SqlQueryContext ctx, String alias, EntityKey key,
ComplexFilterPredicate predicate, EntityFilterType filterType) {
ComplexFilterPredicate predicate, EntityFilterType filterType, boolean useAliasAsField) {
String result = predicate.getPredicates().stream()
.map(keyFilterPredicate -> this.buildPredicateQuery(ctx, alias, key, keyFilterPredicate, filterType))
.map(keyFilterPredicate -> this.buildPredicateQuery(ctx, alias, key, keyFilterPredicate, filterType, useAliasAsField))
.filter(StringUtils::isNotEmpty)
.collect(Collectors.joining(" " + predicate.getOperation().name() + " "));
if (!result.trim().isEmpty()) {
@ -554,9 +649,9 @@ public class EntityKeyMapping {
}
private String buildSimplePredicateQuery(SqlQueryContext ctx, String alias, EntityKey key,
KeyFilterPredicate predicate, EntityFilterType filterType) {
KeyFilterPredicate predicate, EntityFilterType filterType, boolean useAliasAsField) {
if (key.getType().equals(EntityKeyType.ENTITY_FIELD)) {
String field = (getEntityKeyColumn() != null) ? alias + "." + getEntityKeyColumn() : alias;
String field = useAliasAsField || getEntityKeyColumn() == null ? alias : alias + "." + getEntityKeyColumn();
if (predicate.getType().equals(FilterPredicateType.NUMERIC)) {
return this.buildNumericPredicateQuery(ctx, field, (NumericFilterPredicate) predicate);
} else if (predicate.getType().equals(FilterPredicateType.STRING)) {

47
dao/src/test/java/org/thingsboard/server/dao/TbTimescaleDBContainerProvider.java

@ -0,0 +1,47 @@
/**
* Copyright © 2016-2026 The Thingsboard Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.thingsboard.server.dao;
import org.testcontainers.containers.JdbcDatabaseContainer;
import org.testcontainers.containers.TimescaleDBContainerProvider;
/**
* Extends the upstream {@link TimescaleDBContainerProvider} to disable the
* timescaledb-tune entrypoint script via NO_TS_TUNE=true.
*
* Works around a shell bug in /docker-entrypoint-initdb.d/001_timescaledb_tune.sh
* that crashes the container entrypoint on cgroup v2 hosts (including CI agents)
* when the kernel reports the 64-bit max for memory.max.
*
* Activated by the jdbc:tc:tbtimescaledb:&lt;tag&gt;:///... URL prefix
* registered via META-INF/services.
*/
public class TbTimescaleDBContainerProvider extends TimescaleDBContainerProvider {
private static final String NAME = "tbtimescaledb";
@Override
public boolean supports(String databaseType) {
return NAME.equals(databaseType);
}
@Override
public JdbcDatabaseContainer newInstance(String tag) {
JdbcDatabaseContainer container = super.newInstance(tag);
container.withEnv("NO_TS_TUNE", "true");
return container;
}
}

1
dao/src/test/resources/META-INF/services/org.testcontainers.containers.JdbcDatabaseContainerProvider

@ -0,0 +1 @@
org.thingsboard.server.dao.TbTimescaleDBContainerProvider

2
dao/src/test/resources/nosql-test.properties

@ -13,6 +13,6 @@ spring.jpa.show-sql=false
spring.jpa.hibernate.ddl-auto=none
spring.datasource.username=postgres
spring.datasource.password=postgres
spring.datasource.url=jdbc:tc:postgresql:16.6:///thingsboard?TC_DAEMON=true&TC_TMPFS=/testtmpfs:rw&?TC_INITFUNCTION=org.thingsboard.server.dao.PostgreSqlInitializer::initDb
spring.datasource.url=jdbc:tc:postgresql:18:///thingsboard?TC_DAEMON=true&TC_TMPFS=/testtmpfs:rw&?TC_INITFUNCTION=org.thingsboard.server.dao.PostgreSqlInitializer::initDb
spring.datasource.driverClassName=org.testcontainers.jdbc.ContainerDatabaseDriver
spring.datasource.hikari.maximumPoolSize=16

2
dao/src/test/resources/sql-test.properties

@ -14,7 +14,7 @@ spring.jpa.show-sql=false
spring.jpa.hibernate.ddl-auto=none
spring.datasource.username=postgres
spring.datasource.password=postgres
spring.datasource.url=jdbc:tc:postgresql:16.6:///thingsboard?TC_DAEMON=true&TC_TMPFS=/testtmpfs:rw&?TC_INITFUNCTION=org.thingsboard.server.dao.PostgreSqlInitializer::initDb
spring.datasource.url=jdbc:tc:postgresql:18:///thingsboard?TC_DAEMON=true&TC_TMPFS=/testtmpfs:rw&?TC_INITFUNCTION=org.thingsboard.server.dao.PostgreSqlInitializer::initDb
spring.datasource.driverClassName=org.testcontainers.jdbc.ContainerDatabaseDriver
spring.datasource.hikari.maximumPoolSize=16

2
dao/src/test/resources/timescale-test.properties

@ -13,6 +13,6 @@ spring.jpa.show-sql=false
spring.jpa.hibernate.ddl-auto=none
spring.datasource.username=postgres
spring.datasource.password=postgres
spring.datasource.url=jdbc:tc:timescaledb:latest-pg12:///thingsboard?TC_DAEMON=true&TC_TMPFS=/testtmpfs:rw&?TC_INITFUNCTION=org.thingsboard.server.dao.TimescaleSqlInitializer::initDb
spring.datasource.url=jdbc:tc:tbtimescaledb:latest-pg18:///thingsboard?TC_DAEMON=true&TC_TMPFS=/testtmpfs:rw&?TC_INITFUNCTION=org.thingsboard.server.dao.TimescaleSqlInitializer::initDb
spring.datasource.driverClassName=org.testcontainers.jdbc.ContainerDatabaseDriver
spring.datasource.hikari.maximumPoolSize = 50

57
edqs/src/test/java/org/thingsboard/server/edqs/repo/DeviceTypeFilterTest.java

@ -19,15 +19,20 @@ import org.junit.After;
import org.junit.Assert;
import org.junit.Before;
import org.junit.Test;
import org.thingsboard.server.common.data.AttributeScope;
import org.thingsboard.server.common.data.Device;
import org.thingsboard.server.common.data.DeviceProfile;
import org.thingsboard.server.common.data.DeviceProfileType;
import org.thingsboard.server.common.data.EntityType;
import org.thingsboard.server.common.data.edqs.AttributeKv;
import org.thingsboard.server.common.data.edqs.LatestTsKv;
import org.thingsboard.server.common.data.id.DeviceId;
import org.thingsboard.server.common.data.id.DeviceProfileId;
import org.thingsboard.server.common.data.kv.BaseAttributeKvEntry;
import org.thingsboard.server.common.data.kv.BasicTsKvEntry;
import org.thingsboard.server.common.data.kv.BooleanDataEntry;
import org.thingsboard.server.common.data.kv.StringDataEntry;
import org.thingsboard.server.common.data.query.BooleanFilterPredicate;
import org.thingsboard.server.common.data.query.DeviceTypeFilter;
import org.thingsboard.server.common.data.query.EntityDataPageLink;
import org.thingsboard.server.common.data.query.EntityDataQuery;
@ -39,8 +44,10 @@ import org.thingsboard.server.common.data.query.FilterPredicateValue;
import org.thingsboard.server.common.data.query.KeyFilter;
import org.thingsboard.server.common.data.query.StringFilterPredicate;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
import java.util.List;
import java.util.UUID;
public class DeviceTypeFilterTest extends AbstractEDQTest {
@ -119,7 +126,50 @@ public class DeviceTypeFilterTest extends AbstractEDQTest {
Assert.assertEquals("42", first.getLatest().get(EntityKeyType.ENTITY_FIELD).get("createdTime").getValue());
}
@Test
public void testFindDeviceByBooleanAttributeWithMixedTypes() {
DeviceId device1Id = createLoraDevice("LoRa-1");
DeviceId device2Id = createLoraDevice("LoRa-2");
DeviceId device3Id = createLoraDevice("LoRa-3");
long ts = System.currentTimeMillis();
addOrUpdate(new AttributeKv(device1Id, AttributeScope.SERVER_SCOPE,
new BaseAttributeKvEntry(new BooleanDataEntry("active", true), ts), 1L));
addOrUpdate(new AttributeKv(device2Id, AttributeScope.SERVER_SCOPE,
new BaseAttributeKvEntry(new BooleanDataEntry("active", false), ts), 1L));
addOrUpdate(new AttributeKv(device3Id, AttributeScope.SERVER_SCOPE,
new BaseAttributeKvEntry(new StringDataEntry("active", "true"), ts), 1L));
KeyFilter activeFilter = new KeyFilter();
activeFilter.setKey(new EntityKey(EntityKeyType.SERVER_ATTRIBUTE, "active"));
activeFilter.setValueType(EntityKeyValueType.BOOLEAN);
BooleanFilterPredicate predicate = new BooleanFilterPredicate();
predicate.setOperation(BooleanFilterPredicate.BooleanOperation.EQUAL);
predicate.setValue(FilterPredicateValue.fromBoolean(true));
activeFilter.setPredicate(predicate);
var result = repository.countEntitiesByQuery(tenantId, null,
getDeviceTypeQuery("LoRa", List.of(activeFilter)), false);
Assert.assertEquals(2, result);
}
private DeviceId createLoraDevice(String name) {
DeviceId deviceId = new DeviceId(UUID.randomUUID());
Device device = new Device();
device.setId(deviceId);
device.setTenantId(tenantId);
device.setDeviceProfileId(loraProfileId);
device.setName(name);
device.setCreatedTime(42L);
addOrUpdate(EntityType.DEVICE, device);
return deviceId;
}
private static EntityDataQuery getDeviceTypeQuery(String deviceType) {
return getDeviceTypeQuery(deviceType, null);
}
private static EntityDataQuery getDeviceTypeQuery(String deviceType, List<KeyFilter> extraFilters) {
DeviceTypeFilter filter = new DeviceTypeFilter();
filter.setDeviceTypes(Collections.singletonList(deviceType));
var pageLink = new EntityDataPageLink(20, 0, null, new EntityDataSortOrder(new EntityKey(EntityKeyType.TIME_SERIES, "state"), EntityDataSortOrder.Direction.DESC), false);
@ -135,7 +185,12 @@ public class DeviceTypeFilterTest extends AbstractEDQTest {
nameFilter.setPredicate(predicate);
nameFilter.setValueType(EntityKeyValueType.STRING);
return new EntityDataQuery(filter, pageLink, entityFields, latestValues, Arrays.asList(nameFilter));
List<KeyFilter> keyFilters = new ArrayList<>();
keyFilters.add(nameFilter);
if (extraFilters != null) {
keyFilters.addAll(extraFilters);
}
return new EntityDataQuery(filter, pageLink, entityFields, latestValues, keyFilters);
}
}

163
edqs/src/test/java/org/thingsboard/server/edqs/repo/RepositoryUtilsTest.java

@ -15,6 +15,7 @@
*/
package org.thingsboard.server.edqs.repo;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.Arguments;
import org.junit.jupiter.params.provider.MethodSource;
@ -26,7 +27,7 @@ import org.thingsboard.server.common.data.edqs.fields.DeviceProfileFields;
import org.thingsboard.server.common.data.query.BooleanFilterPredicate;
import org.thingsboard.server.common.data.query.BooleanFilterPredicate.BooleanOperation;
import org.thingsboard.server.common.data.query.ComplexFilterPredicate;
import org.thingsboard.server.common.data.query.ComplexFilterPredicate.ComplexOperation;
import org.thingsboard.server.common.data.query.ComplexOperation;
import org.thingsboard.server.common.data.query.EntityKeyType;
import org.thingsboard.server.common.data.query.EntityKeyValueType;
import org.thingsboard.server.common.data.query.FilterPredicateValue;
@ -44,6 +45,7 @@ import org.thingsboard.server.edqs.query.DataKey;
import org.thingsboard.server.edqs.query.EdqsFilter;
import org.thingsboard.server.edqs.util.RepositoryUtils;
import java.util.Collections;
import java.util.List;
import java.util.UUID;
import java.util.stream.Stream;
@ -469,4 +471,163 @@ public class RepositoryUtilsTest {
return complexFilterPredicate;
}
// --- OR operation tests for checkKeyFilters ---
@Test
public void testCheckKeyFiltersWithOrOperation() {
// Entity with temperature=60; filters: temperature>50 (match) and humidity>80 (no data)
DeviceData deviceData = new DeviceData(UUID.randomUUID());
deviceData.setCustomerId(UUID.randomUUID());
deviceData.setFields(DeviceFields.builder().name("orDevice1").build());
deviceData.putTs(5, new DoubleDataPoint(System.currentTimeMillis(), 60.0));
EdqsFilter tempGt50 = getTemperatureFilter(NumericOperation.GREATER, 50);
EdqsFilter humidityGt80 = getHumidityFilter(NumericOperation.GREATER, 80);
// OR: temperature>50 matches, short-circuits to true
assertThat(RepositoryUtils.checkKeyFilters(deviceData, List.of(tempGt50, humidityGt80), ComplexOperation.OR)).isTrue();
}
@Test
public void testCheckKeyFiltersWithOrOperationNoMatch() {
// Entity with temperature=30; filters: temperature>50 (no match) and temperature<10 (no match)
DeviceData deviceData = new DeviceData(UUID.randomUUID());
deviceData.setCustomerId(UUID.randomUUID());
deviceData.setFields(DeviceFields.builder().name("orDevice2").build());
deviceData.putTs(5, new DoubleDataPoint(System.currentTimeMillis(), 30.0));
EdqsFilter tempGt50 = getTemperatureFilter(NumericOperation.GREATER, 50);
EdqsFilter tempLt10 = getTemperatureFilter(NumericOperation.LESS, 10);
// OR: neither filter matches
assertThat(RepositoryUtils.checkKeyFilters(deviceData, List.of(tempGt50, tempLt10), ComplexOperation.OR)).isFalse();
}
@Test
public void testCheckKeyFiltersWithOrOperationSameKey() {
// Entity with temperature=5; filters: temperature>50 (no match) and temperature<10 (match)
DeviceData deviceData = new DeviceData(UUID.randomUUID());
deviceData.setCustomerId(UUID.randomUUID());
deviceData.setFields(DeviceFields.builder().name("orDevice3").build());
deviceData.putTs(5, new DoubleDataPoint(System.currentTimeMillis(), 5.0));
EdqsFilter tempGt50 = getTemperatureFilter(NumericOperation.GREATER, 50);
EdqsFilter tempLt10 = getTemperatureFilter(NumericOperation.LESS, 10);
// OR on same key: second filter matches
assertThat(RepositoryUtils.checkKeyFilters(deviceData, List.of(tempGt50, tempLt10), ComplexOperation.OR)).isTrue();
}
@Test
public void testCheckKeyFiltersWithAndOperationUnchanged() {
// Entity with temperature=60 and humidity=90
DeviceData deviceData = new DeviceData(UUID.randomUUID());
deviceData.setCustomerId(UUID.randomUUID());
deviceData.setFields(DeviceFields.builder().name("andDevice1").build());
deviceData.putTs(5, new DoubleDataPoint(System.currentTimeMillis(), 60.0));
deviceData.putTs(6, new LongDataPoint(System.currentTimeMillis(), 90));
EdqsFilter tempGt50 = getTemperatureFilter(NumericOperation.GREATER, 50);
EdqsFilter humidityGt80 = getHumidityFilter(NumericOperation.GREATER, 80);
// AND: both match
assertThat(RepositoryUtils.checkKeyFilters(deviceData, List.of(tempGt50, humidityGt80), ComplexOperation.AND)).isTrue();
// Now with humidity=70 -- humidity>80 fails
DeviceData deviceData2 = new DeviceData(UUID.randomUUID());
deviceData2.setCustomerId(UUID.randomUUID());
deviceData2.setFields(DeviceFields.builder().name("andDevice2").build());
deviceData2.putTs(5, new DoubleDataPoint(System.currentTimeMillis(), 60.0));
deviceData2.putTs(6, new LongDataPoint(System.currentTimeMillis(), 70));
// AND: humidity>80 fails
assertThat(RepositoryUtils.checkKeyFilters(deviceData2, List.of(tempGt50, humidityGt80), ComplexOperation.AND)).isFalse();
}
@Test
public void testCheckKeyFiltersWithNullOperationDefaultsToAnd() {
// Entity with temperature=60 and humidity=90
DeviceData deviceData = new DeviceData(UUID.randomUUID());
deviceData.setCustomerId(UUID.randomUUID());
deviceData.setFields(DeviceFields.builder().name("nullOpDevice").build());
deviceData.putTs(5, new DoubleDataPoint(System.currentTimeMillis(), 60.0));
deviceData.putTs(6, new LongDataPoint(System.currentTimeMillis(), 90));
EdqsFilter tempGt50 = getTemperatureFilter(NumericOperation.GREATER, 50);
EdqsFilter humidityGt80 = getHumidityFilter(NumericOperation.GREATER, 80);
// null operation should behave like AND
boolean nullResult = RepositoryUtils.checkKeyFilters(deviceData, List.of(tempGt50, humidityGt80), null);
boolean andResult = RepositoryUtils.checkKeyFilters(deviceData, List.of(tempGt50, humidityGt80), ComplexOperation.AND);
assertThat(nullResult).isEqualTo(andResult);
assertThat(nullResult).isTrue();
// Entity with temperature=60 and humidity=70 -- humidity>80 fails
DeviceData deviceData2 = new DeviceData(UUID.randomUUID());
deviceData2.setCustomerId(UUID.randomUUID());
deviceData2.setFields(DeviceFields.builder().name("nullOpDevice2").build());
deviceData2.putTs(5, new DoubleDataPoint(System.currentTimeMillis(), 60.0));
deviceData2.putTs(6, new LongDataPoint(System.currentTimeMillis(), 70));
boolean nullResult2 = RepositoryUtils.checkKeyFilters(deviceData2, List.of(tempGt50, humidityGt80), null);
boolean andResult2 = RepositoryUtils.checkKeyFilters(deviceData2, List.of(tempGt50, humidityGt80), ComplexOperation.AND);
assertThat(nullResult2).isEqualTo(andResult2);
assertThat(nullResult2).isFalse();
}
@Test
public void testCheckKeyFiltersWithOrEmptyFilters() {
DeviceData deviceData = new DeviceData(UUID.randomUUID());
deviceData.setCustomerId(UUID.randomUUID());
deviceData.setFields(DeviceFields.builder().name("emptyFilterDevice").build());
// OR with empty filters = include all (matches SQL behavior)
assertThat(RepositoryUtils.checkKeyFilters(deviceData, Collections.emptyList(), ComplexOperation.OR)).isTrue();
}
@Test
public void testCheckKeyFiltersWithOrStringEntityFieldFilters() {
// Entity with name="loranet device 123"
DeviceData deviceData = new DeviceData(UUID.randomUUID());
deviceData.setCustomerId(UUID.randomUUID());
deviceData.setFields(DeviceFields.builder().name("loranet device 123").build());
// Filter 1: name STARTS_WITH "mqtt" (no match)
EdqsFilter nameStartsMqtt = getStringEntityFieldFilter("name", StringOperation.STARTS_WITH, "mqtt");
// Filter 2: name CONTAINS "lora" (match)
EdqsFilter nameContainsLora = getStringEntityFieldFilter("name", StringOperation.CONTAINS, "lora");
// OR: second filter matches
assertThat(RepositoryUtils.checkKeyFilters(deviceData, List.of(nameStartsMqtt, nameContainsLora), ComplexOperation.OR)).isTrue();
// AND: first filter fails
assertThat(RepositoryUtils.checkKeyFilters(deviceData, List.of(nameStartsMqtt, nameContainsLora), ComplexOperation.AND)).isFalse();
}
@Test
public void testCheckKeyFiltersWithOrEntityFieldNullStringSpecialCase() {
// Entity with name=null — tests the special case where null string + entity field = match
DeviceData deviceData = new DeviceData(UUID.randomUUID());
deviceData.setCustomerId(UUID.randomUUID());
deviceData.setFields(DeviceFields.builder().name(null).build());
// Filter on entity field "label" which is not set (null)
EdqsFilter labelFilter = getStringEntityFieldFilter("label", StringOperation.STARTS_WITH, "test");
// Filter on timeseries (non-entity-field) that doesn't exist
EdqsFilter tempFilter = getTemperatureFilter(NumericOperation.GREATER, 50);
// Entity field with null string = match (special case in evaluateSingleFilter)
assertThat(RepositoryUtils.checkKeyFilters(deviceData, List.of(labelFilter), ComplexOperation.OR)).isTrue();
assertThat(RepositoryUtils.checkKeyFilters(deviceData, List.of(labelFilter), ComplexOperation.AND)).isTrue();
// Non-entity-field with missing data = no match
assertThat(RepositoryUtils.checkKeyFilters(deviceData, List.of(tempFilter), ComplexOperation.OR)).isFalse();
// OR: entity field null match + missing timeseries = true (first matches)
assertThat(RepositoryUtils.checkKeyFilters(deviceData, List.of(labelFilter, tempFilter), ComplexOperation.OR)).isTrue();
// AND: entity field null match + missing timeseries = false (second fails)
assertThat(RepositoryUtils.checkKeyFilters(deviceData, List.of(labelFilter, tempFilter), ComplexOperation.AND)).isFalse();
}
}

9
monitoring/pom.xml

@ -79,6 +79,10 @@
<groupId>org.apache.httpcomponents</groupId>
<artifactId>httpclient</artifactId>
</dependency>
<dependency>
<groupId>com.slack.api</groupId>
<artifactId>slack-api-client</artifactId>
</dependency>
<dependency>
<groupId>org.eclipse.leshan</groupId>
<artifactId>leshan-client-cf</artifactId>
@ -118,6 +122,11 @@
<groupId>ch.qos.logback</groupId>
<artifactId>logback-classic</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
<build>

10
monitoring/src/main/java/org/thingsboard/monitoring/config/transport/TransportInfo.java

@ -16,13 +16,21 @@
package org.thingsboard.monitoring.config.transport;
import lombok.Data;
import org.thingsboard.monitoring.data.notification.ShortNameProvider;
@Data
public class TransportInfo {
public class TransportInfo implements ShortNameProvider {
private final TransportType type;
private final TransportMonitoringTarget target;
public String getShortName() {
if (target.getQueue().equals("Main")) {
return type.getName();
}
return type.getName() + " " + target.getQueue();
}
@Override
public String toString() {
if (target.getQueue().equals("Main")) {

3
monitoring/src/main/java/org/thingsboard/monitoring/data/MonitoredServiceKey.java

@ -18,6 +18,9 @@ package org.thingsboard.monitoring.data;
public class MonitoredServiceKey {
public static final String GENERAL = "Monitoring";
public static final String LOGIN = "Login";
public static final String WS_CONNECT = "WS Connect";
public static final String WS_SUBSCRIBE = "WS Subscribe";
public static final String EDQS = "*EDQS*";
}

34
monitoring/src/main/java/org/thingsboard/monitoring/data/notification/AffectedService.java

@ -0,0 +1,34 @@
/**
* Copyright © 2016-2026 The Thingsboard Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.thingsboard.monitoring.data.notification;
public record AffectedService(String name, Status status, int failureCount) {
public enum Status { FAILING, RECOVERED, HIGH_LATENCY }
public static AffectedService failing(String name, int failureCount) {
return new AffectedService(name, Status.FAILING, failureCount);
}
public static AffectedService recovered(String name) {
return new AffectedService(name, Status.RECOVERED, 0);
}
public static AffectedService highLatency(String name) {
return new AffectedService(name, Status.HIGH_LATENCY, 0);
}
}

9
monitoring/src/main/java/org/thingsboard/monitoring/data/notification/HighLatencyNotification.java

@ -18,6 +18,8 @@ package org.thingsboard.monitoring.data.notification;
import org.thingsboard.monitoring.data.Latency;
import java.util.Collection;
import java.util.List;
import java.util.stream.Collectors;
public class HighLatencyNotification implements Notification {
@ -39,4 +41,11 @@ public class HighLatencyNotification implements Notification {
return text.toString();
}
@Override
public List<AffectedService> getAffectedServices() {
return highLatencies.stream()
.map(latency -> AffectedService.highLatency(latency.getKey()))
.collect(Collectors.toList());
}
}

5
monitoring/src/main/java/org/thingsboard/monitoring/data/notification/InfoNotification.java

@ -24,4 +24,9 @@ public class InfoNotification implements Notification {
public String getText() {
return message;
}
@Override
public boolean isIncident() {
return false;
}
}

10
monitoring/src/main/java/org/thingsboard/monitoring/data/notification/Notification.java

@ -15,8 +15,18 @@
*/
package org.thingsboard.monitoring.data.notification;
import java.util.List;
public interface Notification {
String getText();
default boolean isIncident() {
return true;
}
default List<AffectedService> getAffectedServices() {
return List.of();
}
}

62
monitoring/src/main/java/org/thingsboard/monitoring/data/notification/ServiceFailureNotification.java

@ -18,6 +18,10 @@ package org.thingsboard.monitoring.data.notification;
import lombok.Getter;
import org.apache.commons.lang3.exception.ExceptionUtils;
import java.util.List;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
@Getter
public class ServiceFailureNotification implements Notification {
@ -43,7 +47,65 @@ public class ServiceFailureNotification implements Notification {
if (errorMsg == null) {
errorMsg = error.getClass().getSimpleName();
}
errorMsg = stripResponseBody(errorMsg);
errorMsg = linkifyRequestUrl(errorMsg);
return String.format("%s - Failure: %s (number of subsequent failures: %s)", serviceKey, errorMsg, failuresCount);
}
// Spring RestClient: '... request for "<URL>"'
private static final Pattern REQUEST_FOR_URL_PATTERN = Pattern.compile("request for \"(https?://[^\"\\s]+)\"");
// Apache HttpClient wrapped by Spring: 'I/O error on POST request: Connect to <URL> failed: <reason>'
private static final Pattern REQUEST_CONNECT_PATTERN = Pattern.compile("request: Connect to (https?://\\S+?) failed:");
static String linkifyRequestUrl(String msg) {
if (msg == null) {
return null;
}
// Slack mrkdwn link: <url|label>
Matcher m = REQUEST_FOR_URL_PATTERN.matcher(msg);
if (m.find()) {
return m.replaceAll("<$1|request>");
}
Matcher m2 = REQUEST_CONNECT_PATTERN.matcher(msg);
if (m2.find()) {
return m2.replaceAll("<$1|request>:");
}
return msg;
}
static String stripResponseBody(String msg) {
if (msg == null) {
return null;
}
int htmlIdx = -1;
for (String marker : new String[]{"<html", "<HTML", "<!DOCTYPE", "<!doctype"}) {
int idx = msg.indexOf(marker);
if (idx >= 0 && (htmlIdx < 0 || idx < htmlIdx)) {
htmlIdx = idx;
}
}
if (htmlIdx > 0) {
msg = msg.substring(0, htmlIdx).stripTrailing();
if (msg.endsWith("\"")) {
msg = msg.substring(0, msg.length() - 1).stripTrailing();
}
if (msg.endsWith(":")) {
msg = msg.substring(0, msg.length() - 1).stripTrailing();
}
}
return msg;
}
@Override
public List<AffectedService> getAffectedServices() {
return List.of(AffectedService.failing(shortName(serviceKey), failuresCount));
}
static String shortName(Object serviceKey) {
if (serviceKey instanceof ShortNameProvider provider) {
return provider.getShortName();
}
return serviceKey.toString();
}
}

7
monitoring/src/main/java/org/thingsboard/monitoring/data/notification/ServiceRecoveryNotification.java

@ -15,6 +15,8 @@
*/
package org.thingsboard.monitoring.data.notification;
import java.util.List;
public class ServiceRecoveryNotification implements Notification {
private final Object serviceKey;
@ -28,4 +30,9 @@ public class ServiceRecoveryNotification implements Notification {
return String.format("%s is OK", serviceKey);
}
@Override
public List<AffectedService> getAffectedServices() {
return List.of(AffectedService.recovered(ServiceFailureNotification.shortName(serviceKey)));
}
}

14
ui-ngx/src/app/modules/home/components/filter/filter-dialog.component.scss → monitoring/src/main/java/org/thingsboard/monitoring/data/notification/ShortNameProvider.java

@ -13,16 +13,10 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
:host {
.tb-editable-switch {
padding-left: 10px;
package org.thingsboard.monitoring.data.notification;
.editable-switch {
margin: 0;
}
public interface ShortNameProvider {
String getShortName();
.editable-label {
margin: 5px 0;
}
}
}

2
monitoring/src/main/java/org/thingsboard/monitoring/notification/NotificationService.java

@ -51,7 +51,7 @@ public class NotificationService {
return notificationChannels.stream().map(notificationChannel ->
notificationExecutor.submit(() -> {
try {
notificationChannel.sendNotification(message);
notificationChannel.sendNotification(message, notification);
} catch (Exception e) {
log.error("Failed to send notification to {}", notificationChannel.getClass().getSimpleName(), e);
}

4
monitoring/src/main/java/org/thingsboard/monitoring/notification/channels/NotificationChannel.java

@ -15,8 +15,10 @@
*/
package org.thingsboard.monitoring.notification.channels;
import org.thingsboard.monitoring.data.notification.Notification;
public interface NotificationChannel {
void sendNotification(String message);
void sendNotification(String message, Notification notification);
}

125
monitoring/src/main/java/org/thingsboard/monitoring/notification/channels/impl/SlackApiClient.java

@ -0,0 +1,125 @@
/**
* Copyright © 2016-2026 The Thingsboard Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.thingsboard.monitoring.notification.channels.impl;
import com.slack.api.Slack;
import com.slack.api.SlackConfig;
import com.slack.api.methods.MethodsClient;
import com.slack.api.methods.SlackApiTextResponse;
import com.slack.api.methods.request.chat.ChatPostMessageRequest;
import com.slack.api.methods.request.chat.ChatUpdateRequest;
import com.slack.api.methods.response.chat.ChatPostMessageResponse;
import com.slack.api.methods.response.chat.ChatUpdateResponse;
import lombok.extern.slf4j.Slf4j;
@Slf4j
public class SlackApiClient {
private static final int DEFAULT_CALL_TIMEOUT_MS = 5000;
private final Slack slack;
private final String botToken;
public SlackApiClient(String botToken) {
this(botToken, DEFAULT_CALL_TIMEOUT_MS);
}
public SlackApiClient(String botToken, int callTimeoutMs) {
this.botToken = botToken;
SlackConfig config = new SlackConfig();
config.setHttpClientCallTimeoutMillis(callTimeoutMs);
config.setHttpClientReadTimeoutMillis(callTimeoutMs);
config.setHttpClientWriteTimeoutMillis(callTimeoutMs);
this.slack = Slack.getInstance(config);
}
public String postMessage(String channelId, String text) {
ChatPostMessageRequest request = ChatPostMessageRequest.builder()
.channel(channelId)
.text(text)
.build();
ChatPostMessageResponse response = sendRequest(request);
return response.getTs();
}
public String postThreadReply(String channelId, String threadTs, String text) {
ChatPostMessageRequest request = ChatPostMessageRequest.builder()
.channel(channelId)
.text(text)
.threadTs(threadTs)
.build();
ChatPostMessageResponse response = sendRequest(request);
return response.getTs();
}
public void close() {
try {
slack.close();
} catch (Exception e) {
log.warn("Failed to close Slack client", e);
}
}
public void updateMessage(String channelId, String ts, String text) {
ChatUpdateRequest request = ChatUpdateRequest.builder()
.channel(channelId)
.ts(ts)
.text(text)
.build();
MethodsClient client = slack.methods(botToken);
ChatUpdateResponse response;
try {
response = client.chatUpdate(request);
} catch (Exception e) {
throw new RuntimeException("Failed to update Slack message: " + e.getMessage(), e);
}
checkResponse(response);
}
private ChatPostMessageResponse sendRequest(ChatPostMessageRequest request) {
MethodsClient client = slack.methods(botToken);
ChatPostMessageResponse response;
try {
response = client.chatPostMessage(request);
} catch (Exception e) {
throw new RuntimeException("Failed to send Slack message: " + e.getMessage(), e);
}
checkResponse(response);
return response;
}
private void checkResponse(SlackApiTextResponse response) {
if (response.isOk()) {
return;
}
String error = response.getError();
if (error != null) {
switch (error) {
case "missing_scope" -> {
String neededScope = response.getNeeded();
error = "bot token scope '" + neededScope + "' is needed";
}
case "not_in_channel" -> error = "app needs to be added to the channel";
}
} else if (response.getWarning() != null) {
error = "warning: " + response.getWarning();
} else {
error = "unknown error";
}
throw new RuntimeException("Slack API error: " + error);
}
}

45
monitoring/src/main/java/org/thingsboard/monitoring/notification/channels/impl/SlackIncidentTransport.java

@ -0,0 +1,45 @@
/**
* Copyright © 2016-2026 The Thingsboard Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.thingsboard.monitoring.notification.channels.impl;
import org.thingsboard.monitoring.notification.incident.IncidentTransport;
public class SlackIncidentTransport implements IncidentTransport {
private final SlackApiClient slackApiClient;
private final String channelId;
public SlackIncidentTransport(SlackApiClient slackApiClient, String channelId) {
this.slackApiClient = slackApiClient;
this.channelId = channelId;
}
@Override
public String postIncident(String text) {
return slackApiClient.postMessage(channelId, text);
}
@Override
public void postThreadReply(String threadId, String text) {
slackApiClient.postThreadReply(channelId, threadId, text);
}
@Override
public void updateIncident(String threadId, String text) {
slackApiClient.updateMessage(channelId, threadId, text);
}
}

69
monitoring/src/main/java/org/thingsboard/monitoring/notification/channels/impl/SlackNotificationChannel.java

@ -16,13 +16,16 @@
package org.thingsboard.monitoring.notification.channels.impl;
import jakarta.annotation.PostConstruct;
import jakarta.annotation.PreDestroy;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.boot.web.client.RestTemplateBuilder;
import org.springframework.stereotype.Component;
import org.springframework.web.client.RestTemplate;
import org.thingsboard.monitoring.data.notification.Notification;
import org.thingsboard.monitoring.notification.channels.NotificationChannel;
import org.thingsboard.monitoring.notification.incident.IncidentManager;
import java.time.Duration;
import java.util.Map;
@ -35,19 +38,73 @@ public class SlackNotificationChannel implements NotificationChannel {
@Value("${monitoring.notifications.slack.webhook_url}")
private String webhookUrl;
@Value("${monitoring.notifications.slack.bot_token:}")
private String botToken;
@Value("${monitoring.notifications.slack.channel_id:}")
private String channelId;
@Value("${monitoring.notifications.incident.enabled:}")
private boolean incidentEnabled;
@Value("${monitoring.notifications.incident.resolution_timeout_s:}")
private long resolutionTimeoutSeconds;
@Value("${monitoring.notifications.incident.tag_channel:}")
private boolean tagChannel;
@Value("${monitoring.notifications.message_prefix:}")
private String messagePrefix;
private RestTemplate restTemplate;
private SlackApiClient slackApiClient;
private IncidentManager incidentManager;
@PostConstruct
private void init() {
restTemplate = new RestTemplateBuilder()
.setConnectTimeout(Duration.ofSeconds(5))
.setReadTimeout(Duration.ofSeconds(2))
.build();
boolean hasBotConfig = botToken != null && !botToken.isEmpty() && channelId != null && !channelId.isEmpty();
if (hasBotConfig) {
slackApiClient = new SlackApiClient(botToken);
log.info("Slack API mode enabled (channel: {})", channelId);
if (incidentEnabled) {
incidentManager = new IncidentManager(new SlackIncidentTransport(slackApiClient, channelId),
resolutionTimeoutSeconds, messagePrefix, tagChannel);
log.info("Incident grouping enabled via Slack (resolution timeout: {}s)", resolutionTimeoutSeconds);
}
} else {
if (incidentEnabled) {
log.warn("Incident grouping is enabled but Slack bot_token/channel_id are not set; " +
"falling back to plain webhook mode without incident support");
}
restTemplate = new RestTemplateBuilder()
.setConnectTimeout(Duration.ofSeconds(5))
.setReadTimeout(Duration.ofSeconds(2))
.build();
log.info("Slack webhook mode enabled");
}
}
@Override
public void sendNotification(String message) {
restTemplate.postForObject(webhookUrl, Map.of("text", message), String.class);
public void sendNotification(String message, Notification notification) {
if (incidentManager != null && notification.isIncident()) {
// Pass the raw notification text: IncidentManager already puts the prefix into the
// incident header, so pre-prefixing the thread reply would double it up.
incidentManager.sendAlert(notification.getText(), notification.getAffectedServices());
} else if (slackApiClient != null) {
slackApiClient.postMessage(channelId, message);
} else {
restTemplate.postForObject(webhookUrl, Map.of("text", message), String.class);
}
}
@PreDestroy
private void destroy() {
if (incidentManager != null) {
incidentManager.shutdown();
}
if (slackApiClient != null) {
slackApiClient.close();
}
}
}

292
monitoring/src/main/java/org/thingsboard/monitoring/notification/incident/IncidentManager.java

@ -0,0 +1,292 @@
/**
* Copyright © 2016-2026 The Thingsboard Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.thingsboard.monitoring.notification.incident;
import lombok.extern.slf4j.Slf4j;
import org.thingsboard.monitoring.data.notification.AffectedService;
import java.time.Duration;
import java.time.Instant;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.Executors;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.ScheduledFuture;
import java.util.concurrent.TimeUnit;
/**
* Thread-safety: all public entry points and scheduled callbacks are {@code synchronized} on the
* manager instance. Transport I/O is performed while the monitor is held. This is safe under the
* assumptions that (a) the transport enforces a short per-call timeout, and (b) notification
* producers are single-threaded; see the Slack client (default 5s) for the Slack-based transport.
*/
@Slf4j
public class IncidentManager {
private final IncidentTransport transport;
private final long resolutionTimeoutSeconds;
private final String messagePrefix;
private final boolean tagChannel;
private final ScheduledExecutorService scheduler;
private String activeIncidentThreadId;
private ScheduledFuture<?> resolutionTask;
private ScheduledFuture<?> durationUpdateTask;
private Instant incidentStartTime;
private Instant lastAlertTime;
private final Map<String, Integer> failingServices = new LinkedHashMap<>();
private final Map<String, Integer> recoveredServices = new LinkedHashMap<>();
private final Set<String> highLatencyServices = new LinkedHashSet<>();
public IncidentManager(IncidentTransport transport, long resolutionTimeoutSeconds,
String messagePrefix, boolean tagChannel) {
this.transport = transport;
this.resolutionTimeoutSeconds = resolutionTimeoutSeconds;
this.messagePrefix = messagePrefix;
this.tagChannel = tagChannel;
this.scheduler = Executors.newSingleThreadScheduledExecutor(r -> {
Thread t = new Thread(r, "incident-manager");
t.setDaemon(true);
return t;
});
}
public synchronized void sendAlert(String message, List<AffectedService> affectedServices) {
try {
if (activeIncidentThreadId == null) {
if (affectedServices.stream().allMatch(s -> s.status() == AffectedService.Status.RECOVERED)) {
return;
}
incidentStartTime = Instant.now();
failingServices.clear();
recoveredServices.clear();
highLatencyServices.clear();
applyAffectedServices(affectedServices);
activeIncidentThreadId = transport.postIncident(buildOngoingMessageText());
startDurationUpdater();
log.info("New incident created, thread id: {}", activeIncidentThreadId);
} else if (applyAffectedServices(affectedServices)) {
safeUpdateHeader();
}
try {
transport.postThreadReply(activeIncidentThreadId, message);
log.debug("Alert added to incident thread {}", activeIncidentThreadId);
} catch (Exception e) {
log.error("Failed to post alert to incident thread {}", activeIncidentThreadId, e);
}
} finally {
if (activeIncidentThreadId != null) {
lastAlertTime = Instant.now();
// High latency is a warning only — it has no explicit recovery signal
// (HighLatencyNotification fires only when something is above threshold),
// so resolution hinges on failing services alone.
if (failingServices.isEmpty()) {
resetResolutionTimer();
} else {
cancelResolutionTimer();
}
}
}
}
private boolean applyAffectedServices(List<AffectedService> affectedServices) {
boolean changed = false;
Set<String> latencySnapshot = null;
for (AffectedService service : affectedServices) {
String name = service.name();
switch (service.status()) {
case FAILING -> {
Integer prev = failingServices.put(name, service.failureCount());
if (prev == null || prev.intValue() != service.failureCount()) {
changed = true;
}
if (recoveredServices.remove(name) != null) {
changed = true;
}
}
case RECOVERED -> {
Integer lastFailureCount = failingServices.remove(name);
if (lastFailureCount != null) {
recoveredServices.put(name, lastFailureCount);
changed = true;
}
}
case HIGH_LATENCY -> {
if (latencySnapshot == null) {
latencySnapshot = new LinkedHashSet<>();
}
latencySnapshot.add(name);
}
}
}
// HighLatencyNotification carries the full current set of high latencies, so treat it as a
// snapshot: replace highLatencyServices entirely. Without this, a brief spike would stay
// yellow in the header until the incident resolves.
if (latencySnapshot != null && !latencySnapshot.equals(highLatencyServices)) {
highLatencyServices.clear();
highLatencyServices.addAll(latencySnapshot);
changed = true;
}
return changed;
}
private String buildOngoingMessageText() {
StringBuilder sb = new StringBuilder();
if (tagChannel) {
sb.append("<!channel> ");
}
if (messagePrefix != null && !messagePrefix.isEmpty()) {
sb.append("*").append(messagePrefix).append("*");
}
sb.append(" :rotating_light:");
Duration elapsed = Duration.between(incidentStartTime, Instant.now());
if (elapsed.toMinutes() >= 1) {
sb.append(" (").append(formatDuration(elapsed)).append(")");
}
if (hasAffected()) {
sb.append(" | ").append(formatAffectedServices());
}
return sb.toString();
}
private boolean hasAffected() {
return !failingServices.isEmpty() || !recoveredServices.isEmpty() || !highLatencyServices.isEmpty();
}
private void safeUpdateHeader() {
try {
transport.updateIncident(activeIncidentThreadId, buildOngoingMessageText());
} catch (Exception e) {
log.error("Failed to update incident message", e);
}
}
private void resetResolutionTimer() {
cancelResolutionTimer();
resolutionTask = scheduler.schedule(this::resolveIncident, resolutionTimeoutSeconds, TimeUnit.SECONDS);
}
private void cancelResolutionTimer() {
if (resolutionTask != null) {
resolutionTask.cancel(false);
resolutionTask = null;
}
}
private void startDurationUpdater() {
if (durationUpdateTask != null) {
durationUpdateTask.cancel(false);
}
durationUpdateTask = scheduler.scheduleAtFixedRate(this::updateDuration, 60, 60, TimeUnit.SECONDS);
}
private synchronized void updateDuration() {
if (activeIncidentThreadId == null) {
return;
}
safeUpdateHeader();
}
private void stopDurationUpdater() {
if (durationUpdateTask != null) {
durationUpdateTask.cancel(false);
durationUpdateTask = null;
}
}
static String formatDuration(Duration duration) {
long totalMinutes = duration.toMinutes();
if (totalMinutes < 60) {
return totalMinutes + "m";
}
long hours = totalMinutes / 60;
long minutes = totalMinutes % 60;
return minutes > 0 ? hours + "h" + minutes + "m" : hours + "h";
}
synchronized void resolveIncident() {
if (activeIncidentThreadId == null) {
return;
}
String threadId = activeIncidentThreadId;
stopDurationUpdater();
String resolutionMessage = buildResolutionMessage();
activeIncidentThreadId = null;
resolutionTask = null;
failingServices.clear();
recoveredServices.clear();
highLatencyServices.clear();
try {
transport.updateIncident(threadId, resolutionMessage);
log.info("Incident resolved (thread was {})", threadId);
} catch (Exception e) {
log.error("Failed to send incident resolution message", e);
}
}
private String buildResolutionMessage() {
Duration totalDuration = lastAlertTime != null
? Duration.between(incidentStartTime, lastAlertTime)
: Duration.between(incidentStartTime, Instant.now());
StringBuilder sb = new StringBuilder();
if (messagePrefix != null && !messagePrefix.isEmpty()) {
sb.append("*").append(messagePrefix).append("*");
}
sb.append(" :white_check_mark:");
sb.append(" (").append(formatDuration(totalDuration)).append(")");
if (hasAffected()) {
sb.append(" | ").append(formatAffectedServices()).append("\n");
}
return sb.toString();
}
private String formatAffectedServices() {
StringBuilder sb = new StringBuilder();
boolean first = true;
for (Map.Entry<String, Integer> entry : failingServices.entrySet()) {
if (!first) sb.append(", ");
sb.append(":red_circle: ").append(entry.getKey()).append(" (").append(entry.getValue()).append(")");
first = false;
}
for (String name : highLatencyServices) {
if (!first) sb.append(", ");
sb.append(":large_yellow_circle: ").append(name);
first = false;
}
for (Map.Entry<String, Integer> entry : recoveredServices.entrySet()) {
if (!first) sb.append(", ");
sb.append(":large_green_circle: ").append(entry.getKey()).append(" (").append(entry.getValue()).append(")");
first = false;
}
return sb.toString();
}
public void shutdown() {
scheduler.shutdownNow();
try {
if (!scheduler.awaitTermination(5, TimeUnit.SECONDS)) {
log.warn("Incident scheduler did not terminate in time");
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}
}

26
monitoring/src/main/java/org/thingsboard/monitoring/notification/incident/IncidentTransport.java

@ -0,0 +1,26 @@
/**
* Copyright © 2016-2026 The Thingsboard Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.thingsboard.monitoring.notification.incident;
public interface IncidentTransport {
String postIncident(String text);
void postThreadReply(String threadId, String text);
void updateIncident(String threadId, String text);
}

3
monitoring/src/main/java/org/thingsboard/monitoring/service/BaseHealthChecker.java

@ -89,11 +89,10 @@ public abstract class BaseHealthChecker<C extends MonitoringConfig, T extends Mo
checkWsUpdates(wsClient, testValue);
reporter.serviceIsOk(info);
reporter.serviceIsOk(MonitoredServiceKey.GENERAL);
} catch (ServiceFailureException e) {
reporter.serviceFailure(e.getServiceKey(), e);
} catch (Exception e) {
reporter.serviceFailure(MonitoredServiceKey.GENERAL, e);
reporter.serviceFailure(info, e);
}
associates.values().forEach(healthChecker -> {

54
monitoring/src/main/java/org/thingsboard/monitoring/service/BaseMonitoringService.java

@ -117,28 +117,60 @@ public abstract class BaseMonitoringService<C extends MonitoringConfig<T>, T ext
}
try {
log.info("Starting {}", getName());
stopWatch.start();
String accessToken = tbClient.logIn();
reporter.reportLatency(Latencies.LOG_IN, stopWatch.getTime());
try (WsClient wsClient = wsClientFactory.createClient(accessToken)) {
String accessToken;
try {
stopWatch.start();
wsClient.subscribeForTelemetry(devices, getTestTelemetryKeys()).waitForReply();
reporter.reportLatency(Latencies.WS_SUBSCRIBE, stopWatch.getTime());
accessToken = tbClient.logIn();
reporter.reportLatency(Latencies.LOG_IN, stopWatch.getTime());
reporter.serviceIsOk(MonitoredServiceKey.LOGIN);
} catch (Exception e) {
reporter.serviceFailure(MonitoredServiceKey.LOGIN, e);
return;
}
WsClient wsClient;
try {
wsClient = wsClientFactory.createClient(accessToken);
reporter.serviceIsOk(MonitoredServiceKey.WS_CONNECT);
} catch (Exception e) {
reporter.serviceFailure(MonitoredServiceKey.WS_CONNECT, e);
return;
}
try (WsClient ws = wsClient) {
try {
stopWatch.start();
ws.subscribeForTelemetry(devices, getTestTelemetryKeys()).waitForReply();
reporter.reportLatency(Latencies.WS_SUBSCRIBE, stopWatch.getTime());
reporter.serviceIsOk(MonitoredServiceKey.WS_SUBSCRIBE);
} catch (Exception e) {
reporter.serviceFailure(MonitoredServiceKey.WS_SUBSCRIBE, e);
return;
}
for (BaseHealthChecker<C, T> healthChecker : healthCheckers) {
check(healthChecker, wsClient);
check(healthChecker, ws);
}
}
if (checkEdqs) {
stopWatch.start();
checkEdqs();
reporter.reportLatency(Latencies.EDQS_QUERY, stopWatch.getTime());
reporter.serviceIsOk(MonitoredServiceKey.EDQS);
try {
stopWatch.start();
checkEdqs();
reporter.reportLatency(Latencies.EDQS_QUERY, stopWatch.getTime());
reporter.serviceIsOk(MonitoredServiceKey.EDQS);
} catch (ServiceFailureException e) {
reporter.serviceFailure(e.getServiceKey(), e);
return;
} catch (Exception e) {
reporter.serviceFailure(MonitoredServiceKey.EDQS, e);
return;
}
}
reporter.reportLatencies();
reporter.serviceIsOk(MonitoredServiceKey.GENERAL);
log.debug("Finished {}", getName());
} catch (ServiceFailureException e) {
reporter.serviceFailure(e.getServiceKey(), e);

5
monitoring/src/main/java/org/thingsboard/monitoring/service/MonitoringReporter.java

@ -25,7 +25,6 @@ import org.springframework.stereotype.Component;
import org.thingsboard.common.util.JacksonUtil;
import org.thingsboard.monitoring.client.TbClient;
import org.thingsboard.monitoring.data.Latency;
import org.thingsboard.monitoring.data.MonitoredServiceKey;
import org.thingsboard.monitoring.data.notification.HighLatencyNotification;
import org.thingsboard.monitoring.data.notification.ServiceFailureNotification;
import org.thingsboard.monitoring.data.notification.ServiceRecoveryNotification;
@ -119,9 +118,7 @@ public class MonitoringReporter {
public void serviceIsOk(Object serviceKey) {
ServiceRecoveryNotification notification = new ServiceRecoveryNotification(serviceKey);
if (!serviceKey.equals(MonitoredServiceKey.GENERAL)) {
log.info(notification.getText());
}
log.info(notification.getText());
AtomicInteger failuresCounter = failuresCounters.get(serviceKey);
if (failuresCounter != null) {
if (failuresCounter.get() >= failuresThreshold) {

13
monitoring/src/main/resources/tb-monitoring.yml

@ -121,11 +121,24 @@ monitoring:
notifications:
message_prefix: '${NOTIFICATION_MESSAGE_PREFIX:}'
# Incident grouping (threads alerts into incidents, auto-resolves after timeout).
# Requires a channel that supports it — currently only Slack API (bot_token + channel_id).
incident:
# Enable incident grouping
enabled: '${INCIDENT_ENABLED:false}'
# Incident resolution timeout in seconds
resolution_timeout_s: '${INCIDENT_RESOLUTION_TIMEOUT_S:90}'
# Tag @channel in incident messages
tag_channel: '${INCIDENT_TAG_CHANNEL:false}'
slack:
# Enable notifying via Slack
enabled: '${SLACK_NOTIFICATION_CHANNEL_ENABLED:false}'
# Slack webhook url
webhook_url: '${SLACK_WEBHOOK_URL:}'
# Slack Bot OAuth token (xoxb-...) for API-based messaging with incident support, requires chat:write:bot scope
bot_token: '${SLACK_BOT_TOKEN:}'
# Slack channel ID (e.g. C01234ABCDE) - required when incident feature is enabled
channel_id: '${SLACK_CHANNEL_ID:}'
latency:
# Enable latencies reporting

97
monitoring/src/test/java/org/thingsboard/monitoring/data/notification/ServiceFailureNotificationTest.java

@ -0,0 +1,97 @@
/**
* Copyright © 2016-2026 The Thingsboard Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.thingsboard.monitoring.data.notification;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
class ServiceFailureNotificationTest {
@Test
void stripResponseBodyRemovesNginxErrorHtml() {
String msg = "503 Service Temporarily Unavailable on POST request for \"https://domain/api/auth/login\": \""
+ "<html><head><title>503 Service Temporarily Unavailable</title></head>"
+ "<body><center><h1>503 Service Temporarily Unavailable</h1></center><hr><center>nginx</center></body></html>\"";
String sanitized = ServiceFailureNotification.stripResponseBody(msg);
assertThat(sanitized)
.isEqualTo("503 Service Temporarily Unavailable on POST request for \"https://domain/api/auth/login\"");
}
@Test
void stripResponseBodyRemovesDoctypeHtml() {
String msg = "500 Internal Server Error: \"<!DOCTYPE html><html>...</html>\"";
String sanitized = ServiceFailureNotification.stripResponseBody(msg);
assertThat(sanitized).isEqualTo("500 Internal Server Error");
}
@Test
void stripResponseBodyLeavesPlainMessagesUntouched() {
String msg = "Connection refused";
assertThat(ServiceFailureNotification.stripResponseBody(msg)).isEqualTo(msg);
}
@Test
void stripResponseBodyHandlesNull() {
assertThat(ServiceFailureNotification.stripResponseBody(null)).isNull();
}
@Test
void linkifyReplacesRequestForUrlWithSlackMrkdwnLink() {
String msg = "503 Service Temporarily Unavailable on POST request for \"https://example.com/api/auth/login\"";
assertThat(ServiceFailureNotification.linkifyRequestUrl(msg))
.isEqualTo("503 Service Temporarily Unavailable on POST <https://example.com/api/auth/login|request>");
}
@Test
void linkifyReplacesRequestConnectToUrlFailed() {
String msg = "I/O error on POST request: Connect to https://example.com:443 failed: Connect timed out";
assertThat(ServiceFailureNotification.linkifyRequestUrl(msg))
.isEqualTo("I/O error on POST <https://example.com:443|request>: Connect timed out");
}
@Test
void linkifyLeavesMessagesWithoutRequestUrlUntouched() {
String msg = "Connection refused";
assertThat(ServiceFailureNotification.linkifyRequestUrl(msg)).isEqualTo(msg);
}
@Test
void linkifyHandlesNull() {
assertThat(ServiceFailureNotification.linkifyRequestUrl(null)).isNull();
}
@Test
void shortNameUsesShortNameProviderWhenAvailable() {
ShortNameProvider provider = () -> "MQTT";
assertThat(ServiceFailureNotification.shortName(provider)).isEqualTo("MQTT");
}
@Test
void shortNameFallsBackToToStringForOtherKeys() {
Object key = new Object() {
@Override public String toString() { return "LOGIN"; }
};
assertThat(ServiceFailureNotification.shortName(key)).isEqualTo("LOGIN");
}
}

183
monitoring/src/test/java/org/thingsboard/monitoring/notification/incident/IncidentManagerTest.java

@ -0,0 +1,183 @@
/**
* Copyright © 2016-2026 The Thingsboard Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.thingsboard.monitoring.notification.incident;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.thingsboard.monitoring.data.notification.AffectedService;
import java.time.Duration;
import java.util.List;
import java.util.concurrent.atomic.AtomicInteger;
import static org.assertj.core.api.Assertions.assertThat;
class IncidentManagerTest {
private RecordingTransport transport;
private IncidentManager manager;
@BeforeEach
void setUp() {
transport = new RecordingTransport();
manager = new IncidentManager(transport, 3600L, "tbqa", false);
}
@AfterEach
void tearDown() {
manager.shutdown();
}
@Test
void formatDurationRendersMinutesAndHours() {
assertThat(IncidentManager.formatDuration(Duration.ofSeconds(30))).isEqualTo("0m");
assertThat(IncidentManager.formatDuration(Duration.ofMinutes(5))).isEqualTo("5m");
assertThat(IncidentManager.formatDuration(Duration.ofMinutes(59))).isEqualTo("59m");
assertThat(IncidentManager.formatDuration(Duration.ofMinutes(60))).isEqualTo("1h");
assertThat(IncidentManager.formatDuration(Duration.ofMinutes(75))).isEqualTo("1h15m");
assertThat(IncidentManager.formatDuration(Duration.ofMinutes(120))).isEqualTo("2h");
}
@Test
void firstFailureOpensIncidentAndPostsHeaderAndReply() {
manager.sendAlert("CoAP failure message",
List.of(AffectedService.failing("CoAP", 1)));
assertThat(transport.incidents).hasSize(1);
assertThat(transport.incidents.get(0)).contains(":rotating_light:").contains(":red_circle: CoAP (1)");
assertThat(transport.replies).hasSize(1);
assertThat(transport.replies.get(0).text()).isEqualTo("CoAP failure message");
}
@Test
void isolatedRecoveryWithoutActiveIncidentIsIgnored() {
manager.sendAlert("Login is OK",
List.of(AffectedService.recovered("Login")));
assertThat(transport.incidents).isEmpty();
assertThat(transport.replies).isEmpty();
assertThat(transport.updates).isEmpty();
}
@Test
void subsequentFailureUpdatesHeaderAndPostsReply() {
manager.sendAlert("CoAP failure", List.of(AffectedService.failing("CoAP", 1)));
manager.sendAlert("CoAP repeat", List.of(AffectedService.failing("CoAP", 3)));
assertThat(transport.incidents).hasSize(1);
assertThat(transport.replies).hasSize(2);
assertThat(transport.updates).hasSize(1);
assertThat(transport.updates.get(0).text()).contains(":red_circle: CoAP (3)");
}
@Test
void recoveryAfterFailureMovesServiceToGreenAndKeepsFailureCount() {
manager.sendAlert("CoAP failure", List.of(AffectedService.failing("CoAP", 4)));
manager.sendAlert("CoAP is OK", List.of(AffectedService.recovered("CoAP")));
assertThat(transport.updates).hasSize(1);
String updated = transport.updates.get(0).text();
assertThat(updated).contains(":large_green_circle: CoAP (4)").doesNotContain(":red_circle:");
}
@Test
void highLatencyIsTrackedAsYellow() {
manager.sendAlert("high latency",
List.of(AffectedService.highLatency("logInLatency")));
assertThat(transport.incidents.get(0)).contains(":large_yellow_circle: logInLatency");
}
@Test
void repeatingSameFailureCountDoesNotTriggerRedundantUpdate() {
manager.sendAlert("CoAP failure", List.of(AffectedService.failing("CoAP", 3)));
manager.sendAlert("CoAP still failing", List.of(AffectedService.failing("CoAP", 3)));
assertThat(transport.updates).isEmpty();
assertThat(transport.replies).hasSize(2);
}
@Test
void fullLifecycleStartFailRecoverResolve() {
manager.sendAlert("Login failure", List.of(AffectedService.failing("Login", 1)));
manager.sendAlert("WS failure", List.of(AffectedService.failing("WS Connect", 1)));
manager.sendAlert("Login is OK", List.of(AffectedService.recovered("Login")));
assertThat(transport.incidents).hasSize(1);
manager.resolveIncident();
assertThat(transport.updates).last()
.extracting(RecordingTransport.Message::text)
.asString()
.contains(":white_check_mark:")
.contains(":red_circle: WS Connect")
.contains(":large_green_circle: Login (1)");
}
@Test
void resolveWithoutActiveIncidentIsNoOp() {
manager.resolveIncident();
assertThat(transport.updates).isEmpty();
}
@Test
void doesNotAutoResolveWhileServicesAreStillFailing() throws Exception {
manager.shutdown();
transport = new RecordingTransport();
manager = new IncidentManager(transport, 1L, "tbqa", false);
manager.sendAlert("CoAP failure", List.of(AffectedService.failing("CoAP", 1)));
Thread.sleep(1500);
assertThat(transport.updates)
.extracting(RecordingTransport.Message::text)
.noneMatch(t -> t.contains(":white_check_mark:"));
manager.sendAlert("CoAP is OK", List.of(AffectedService.recovered("CoAP")));
Thread.sleep(1500);
assertThat(transport.updates)
.extracting(RecordingTransport.Message::text)
.anyMatch(t -> t.contains(":white_check_mark:"));
}
private static class RecordingTransport implements IncidentTransport {
private final AtomicInteger threadCounter = new AtomicInteger();
final java.util.List<String> incidents = new java.util.ArrayList<>();
final java.util.List<Message> replies = new java.util.ArrayList<>();
final java.util.List<Message> updates = new java.util.ArrayList<>();
@Override
public String postIncident(String text) {
incidents.add(text);
return "thread-" + threadCounter.incrementAndGet();
}
@Override
public void postThreadReply(String threadId, String text) {
replies.add(new Message(threadId, text));
}
@Override
public void updateIncident(String threadId, String text) {
updates.add(new Message(threadId, text));
}
record Message(String threadId, String text) {}
}
}

6
pom.xml

@ -167,7 +167,7 @@
<exp4j.version>0.4.8</exp4j.version>
<aerogear-otp.version>1.0.0</aerogear-otp.version>
<slack-api.version>1.39.0</slack-api.version>
<slack-api.version>1.48.0</slack-api.version>
<oshi.version>6.6.0</oshi.version>
<google-oauth-client.version>1.35.0</google-oauth-client.version>
<weisj-jsvg.version>1.6.1</weisj-jsvg.version>
@ -621,7 +621,6 @@
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-assembly-plugin</artifactId>
<configuration>
<skipAssembly>${pkg.skip.zip}</skipAssembly>
<finalName>${pkg.name}</finalName>
<descriptors>
<descriptor>${main.dir}/packaging/${pkg.type}/assembly/windows.xml</descriptor>
@ -634,6 +633,9 @@
<goals>
<goal>single</goal>
</goals>
<configuration>
<skipAssembly>${pkg.skip.zip}</skipAssembly>
</configuration>
</execution>
</executions>
</plugin>

3
ui-ngx/src/app/core/api/alarm-data-subscription.ts

@ -23,6 +23,7 @@ import { DatasourceType } from '@shared/models/widget.models';
import {
AlarmData,
AlarmDataPageLink,
ComplexOperation,
EntityFilter,
EntityKey,
EntityKeyType,
@ -47,6 +48,7 @@ export interface AlarmDataSubscriptionOptions {
pageLink?: AlarmDataPageLink;
keyFilters?: Array<KeyFilter>;
additionalKeyFilters?: Array<KeyFilter>;
keyFiltersOperation?: ComplexOperation;
subscriptionTimewindow?: SubscriptionTimewindow;
}
@ -120,6 +122,7 @@ export class AlarmDataSubscription {
entityFilter: this.alarmDataSubscriptionOptions.entityFilter,
pageLink: deepClone(this.alarmDataSubscriptionOptions.pageLink),
keyFilters,
keyFiltersOperation: this.alarmDataSubscriptionOptions.keyFiltersOperation,
alarmFields,
entityFields,
latestValues

1
ui-ngx/src/app/core/api/alarm-data.service.ts

@ -85,6 +85,7 @@ export class AlarmDataService {
alarmDataSubscriptionOptions.entityFilter = alarmSource.entityFilter;
alarmDataSubscriptionOptions.pageLink = pageLink;
alarmDataSubscriptionOptions.keyFilters = alarmSource.keyFilters;
alarmDataSubscriptionOptions.keyFiltersOperation = alarmSource.keyFiltersOperation;
alarmDataSubscriptionOptions.additionalKeyFilters = additionalKeyFilters;
}
return alarmDataSubscriptionOptions;

10
ui-ngx/src/app/core/api/alias-controller.ts

@ -314,7 +314,15 @@ export class AliasController implements IAliasController {
|| newDatasource.type === DatasourceType.entityCount
|| newDatasource.type === DatasourceType.alarmCount) {
if (newDatasource.filterId) {
newDatasource.keyFilters = this.getKeyFilters(newDatasource.filterId);
const filterInfo = this.getFilterInfo(newDatasource.filterId);
if (filterInfo) {
newDatasource.keyFilters = filterInfoToKeyFilters(filterInfo);
if (filterInfo.keyFiltersOperation) {
newDatasource.keyFiltersOperation = filterInfo.keyFiltersOperation;
}
} else {
newDatasource.keyFilters = [];
}
}
if (newDatasource.type === DatasourceType.alarmCount) {
newDatasource.alarmFilter = this.entityService.resolveAlarmFilter(newDatasource.alarmFilterConfig, false);

10
ui-ngx/src/app/core/api/entity-data-subscription.ts

@ -34,6 +34,7 @@ import {
import {
AlarmFilter,
ComparisonTsValue,
ComplexOperation,
EntityData,
EntityDataPageLink,
EntityFilter,
@ -112,6 +113,7 @@ export interface EntityDataSubscriptionOptions {
pageLink?: EntityDataPageLink;
keyFilters?: Array<KeyFilter>;
additionalKeyFilters?: Array<KeyFilter>;
keyFiltersOperation?: ComplexOperation;
subscriptionTimewindow?: SubscriptionTimewindow;
latestTsOffset?: number;
}
@ -356,7 +358,6 @@ export class EntityDataSubscription {
this.subscriber = new TelemetrySubscriber(this.telemetryService);
this.dataCommand = new EntityDataCmd();
let keyFilters = this.entityDataSubscriptionOptions.keyFilters;
if (this.entityDataSubscriptionOptions.additionalKeyFilters) {
if (keyFilters) {
@ -370,6 +371,7 @@ export class EntityDataSubscription {
entityFilter: this.entityDataSubscriptionOptions.entityFilter,
pageLink: this.entityDataSubscriptionOptions.pageLink,
keyFilters,
keyFiltersOperation: this.entityDataSubscriptionOptions.keyFiltersOperation,
entityFields,
latestValues: this.latestValues
};
@ -494,7 +496,8 @@ export class EntityDataSubscription {
}
this.countCommand.query = {
entityFilter: this.entityDataSubscriptionOptions.entityFilter,
keyFilters
keyFilters,
keyFiltersOperation: this.entityDataSubscriptionOptions.keyFiltersOperation
};
this.subscriber.subscriptionCommands.push(this.countCommand);
@ -569,7 +572,8 @@ export class EntityDataSubscription {
}
this.alarmCountCommand.query = {
entityFilter: this.entityDataSubscriptionOptions.entityFilter,
keyFilters
keyFilters,
keyFiltersOperation: this.entityDataSubscriptionOptions.keyFiltersOperation
};
if (this.entityDataSubscriptionOptions.alarmFilter) {
this.alarmCountCommand.query = {...this.alarmCountCommand.query, ...this.entityDataSubscriptionOptions.alarmFilter};

1
ui-ngx/src/app/core/api/entity-data.service.ts

@ -183,6 +183,7 @@ export class EntityDataService {
entityDataSubscriptionOptions.alarmFilter = datasource.alarmFilter;
entityDataSubscriptionOptions.keyFilters = keyFilters;
entityDataSubscriptionOptions.additionalKeyFilters = additionalKeyFilters;
entityDataSubscriptionOptions.keyFiltersOperation = datasource.keyFiltersOperation;
if (entityDataSubscriptionOptions.datasourceType === DatasourceType.entity) {
entityDataSubscriptionOptions.pageLink = pageLink;
}

1
ui-ngx/src/app/core/auth/auth.models.ts

@ -40,6 +40,7 @@ export interface SysParamsState {
calculatedFieldDebugPerTenantLimitsConfiguration?: string;
intermediateAggregationIntervalInSecForCF: number;
trendzSettings: TrendzSettings;
allowKeyFiltersOrConditions: boolean;
}
export interface SysParams extends SysParamsState {

3
ui-ngx/src/app/core/auth/auth.reducer.ts

@ -42,7 +42,8 @@ const emptyUserAuthState: AuthPayload = {
maxDebugModeDurationMinutes: 0,
intermediateAggregationIntervalInSecForCF: 0,
userSettings: initialUserSettings,
trendzSettings: initialTrendzSettings
trendzSettings: initialTrendzSettings,
allowKeyFiltersOrConditions: true
};
export const initialState: AuthState = {

23
ui-ngx/src/app/modules/home/components/filter/complex-filter-predicate-dialog.component.html

@ -26,17 +26,16 @@
</button>
</mat-toolbar>
<div mat-dialog-content>
<fieldset [disabled]="isLoading$ | async" class="flex flex-col">
<mat-form-field class="mat-block">
<mat-label translate>filter.operation.operation</mat-label>
<mat-select required formControlName="operation">
@for (operation of complexOperations; track operation) {
<mat-option [value]="operation">
{{complexOperationTranslations.get(complexOperationEnum[operation]) | translate}}
</mat-option>
}
</mat-select>
</mat-form-field>
<section class="tb-form-panel">
<div class="flex flex-row items-center justify-between">
<div class="tb-form-panel-title">{{ 'filter.filters' | translate }}
</div>
<tb-toggle-select formControlName="operation"
selectMediaBreakpoint="xs">
<tb-toggle-option [value]="complexOperationEnum.AND">{{ complexOperationTranslations.get(complexOperationEnum.AND) | translate }}</tb-toggle-option>
<tb-toggle-option [value]="complexOperationEnum.OR">{{ complexOperationTranslations.get(complexOperationEnum.OR) | translate }}</tb-toggle-option>
</tb-toggle-select>
</div>
<tb-filter-predicate-list
[valueType]="data.valueType"
[displayUserParameters]="data.displayUserParameters"
@ -46,7 +45,7 @@
[key]="data.key"
formControlName="predicates">
</tb-filter-predicate-list>
</fieldset>
</section>
</div>
<div mat-dialog-actions class="flex items-center justify-end">
<button mat-button color="primary"

77
ui-ngx/src/app/modules/home/components/filter/filter-dialog.component.html

@ -16,60 +16,61 @@
-->
<form [formGroup]="filterFormGroup" (ngSubmit)="save()" style="width: 700px;">
<mat-toolbar color="primary">
<mat-toolbar color="primary" class="justify-between">
<h2>{{ (isAdd ? 'filter.add' : 'filter.edit') | translate }}</h2>
<span class="flex-1"></span>
<button mat-icon-button
(click)="cancel()"
type="button">
<mat-icon class="material-icons">close</mat-icon>
</button>
</mat-toolbar>
@if (isLoading$ | async) {
<mat-progress-bar color="warn" mode="indeterminate">
</mat-progress-bar>
}
<div mat-dialog-content>
<fieldset [disabled]="isLoading$ | async">
<div class="flex flex-1 flex-col">
<div class="flex flex-row">
<mat-form-field class="mat-block flex-1">
<mat-label translate>filter.name</mat-label>
<input matInput formControlName="filter" required>
@if (filterFormGroup.get('filter').hasError('required')) {
<mat-error>
{{ 'filter.name-required' | translate }}
</mat-error>
}
@if (filterFormGroup.get('filter').hasError('duplicateFilterName')) {
<mat-error>
{{ 'filter.duplicate-filter' | translate }}
</mat-error>
}
</mat-form-field>
<section class="tb-editable-switch flex flex-col items-center justify-start">
<label class="tb-small editable-label" translate>filter.editable</label>
<mat-slide-toggle class="editable-switch"
formControlName="editable">
</mat-slide-toggle>
</section>
</div>
<tb-key-filter-list
formControlName="keyFilters">
</tb-key-filter-list>
</div>
</fieldset>
<div mat-dialog-content class="flex flex-col">
<div class="flex flex-row items-start gap-2">
<mat-form-field class="mat-block flex-1" appearance="outline">
<mat-label translate>filter.name</mat-label>
<input matInput formControlName="filter" required>
@if (filterFormGroup.controls.filter.hasError('required')) {
<mat-error>{{ 'filter.name-required' | translate }}</mat-error>
}
@if (filterFormGroup.controls.filter.hasError('duplicateFilterName')) {
<mat-error>{{ 'filter.duplicate-filter' | translate }}</mat-error>
}
</mat-form-field>
<button mat-stroked-button
type="button"
class="tb-rounded-btn mt-2"
color="primary"
[class.active]="filterFormGroup.get('editable').value"
(click)="onEditableChange($event)">
<mat-icon [class.invisible]="!filterFormGroup.get('editable').value">checkmark</mat-icon>
<span translate>filter.editable</span>
</button>
</div>
<tb-key-filter-list
formControlName="keyFilters"
[operation]="filterFormGroup.get('keyFiltersOperation').value">
<tb-toggle-select keyFilterHeaderActions
formControlName="keyFiltersOperation"
[matTooltip]="!allowKeyFiltersOrConditions ? ('filter.or-conditions-disabled' | translate) : ''"
selectMediaBreakpoint="xs">
<tb-toggle-option
[value]="ComplexOperation.AND">{{ complexOperationTranslationMap.get(ComplexOperation.AND) | translate | titlecase }}
</tb-toggle-option>
<tb-toggle-option
[value]="ComplexOperation.OR">{{ complexOperationTranslationMap.get(ComplexOperation.OR) | translate | titlecase }}
</tb-toggle-option>
</tb-toggle-select>
</tb-key-filter-list>
</div>
<div mat-dialog-actions class="flex items-center justify-end">
<button mat-button color="primary"
type="button"
[disabled]="(isLoading$ | async)"
(click)="cancel()" cdkFocusInitial>
{{ 'action.cancel' | translate }}
</button>
<button mat-raised-button color="primary"
type="submit"
[disabled]="(isLoading$ | async) || filterFormGroup.invalid || !filterFormGroup.dirty">
[disabled]="filterFormGroup.invalid || !filterFormGroup.dirty">
{{ (isAdd ? 'action.add' : 'action.update') | translate }}
</button>
</div>

87
ui-ngx/src/app/modules/home/components/filter/filter-dialog.component.ts

@ -14,25 +14,23 @@
/// limitations under the License.
///
import { Component, Inject, OnInit, SkipSelf } from '@angular/core';
import { ErrorStateMatcher } from '@angular/material/core';
import { Component, Inject } from '@angular/core';
import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog';
import { Store } from '@ngrx/store';
import { AppState } from '@core/core.state';
import {
UntypedFormBuilder,
UntypedFormControl,
UntypedFormGroup,
FormGroupDirective,
NgForm,
ValidatorFn,
Validators
} from '@angular/forms';
import { FormBuilder, FormControl, FormGroup, ValidatorFn, Validators } from '@angular/forms';
import { Router } from '@angular/router';
import { DialogComponent } from '@app/shared/components/dialog.component';
import { UtilsService } from '@core/services/utils.service';
import { TranslateService } from '@ngx-translate/core';
import { Filter, Filters } from '@shared/models/query/query.models';
import {
ComplexOperation,
complexOperationTranslationMap,
Filter,
FilterInfo,
Filters
} from '@shared/models/query/query.models';
import { getCurrentAuthState } from '@core/auth/auth.selectors';
import { FormControlsFrom } from '@shared/models/tenant.model';
export interface FilterDialogData {
isAdd: boolean;
@ -41,32 +39,29 @@ export interface FilterDialogData {
}
@Component({
selector: 'tb-filter-dialog',
templateUrl: './filter-dialog.component.html',
providers: [{ provide: ErrorStateMatcher, useExisting: FilterDialogComponent }],
styleUrls: ['./filter-dialog.component.scss'],
standalone: false
selector: 'tb-filter-dialog',
templateUrl: './filter-dialog.component.html',
standalone: false
})
export class FilterDialogComponent extends DialogComponent<FilterDialogComponent, Filter>
implements OnInit, ErrorStateMatcher {
export class FilterDialogComponent extends DialogComponent<FilterDialogComponent, Filter> {
isAdd: boolean;
filters: Array<Filter>;
filter: Filter;
filterFormGroup: FormGroup<FormControlsFrom<FilterInfo>>;
filterFormGroup: UntypedFormGroup;
ComplexOperation = ComplexOperation;
complexOperationTranslationMap = complexOperationTranslationMap;
allowKeyFiltersOrConditions: boolean;
submitted = false;
private readonly filter: Filter;
private filters: Array<Filter>;
constructor(protected store: Store<AppState>,
protected router: Router,
@Inject(MAT_DIALOG_DATA) public data: FilterDialogData,
@SkipSelf() private errorStateMatcher: ErrorStateMatcher,
public dialogRef: MatDialogRef<FilterDialogComponent, Filter>,
private fb: UntypedFormBuilder,
private utils: UtilsService,
public translate: TranslateService) {
protected dialogRef: MatDialogRef<FilterDialogComponent, Filter>,
private fb: FormBuilder,
private utils: UtilsService) {
super(store, router, dialogRef);
this.isAdd = data.isAdd;
if (Array.isArray(data.filters)) {
@ -87,16 +82,25 @@ export class FilterDialogComponent extends DialogComponent<FilterDialogComponent
} else {
this.filter = data.filter;
}
this.allowKeyFiltersOrConditions = getCurrentAuthState(this.store).allowKeyFiltersOrConditions !== false;
this.filterFormGroup = this.fb.group({
filter: [this.filter.filter, [this.validateDuplicateFilterName(), Validators.required]],
editable: [this.filter.editable],
keyFilters: [this.filter.keyFilters, Validators.required]
keyFilters: [this.filter.keyFilters, Validators.required],
keyFiltersOperation: [this.filter.keyFiltersOperation ?? ComplexOperation.AND]
});
if (!this.allowKeyFiltersOrConditions) {
if (this.filter.keyFiltersOperation === ComplexOperation.OR) {
this.filterFormGroup.controls.keyFiltersOperation.setValue(ComplexOperation.AND);
this.filterFormGroup.markAsDirty();
}
this.filterFormGroup.controls.keyFiltersOperation.disable();
}
}
validateDuplicateFilterName(): ValidatorFn {
return (c: UntypedFormControl) => {
return (c: FormControl<string>) => {
const newFilter = c.value.trim();
const found = this.filters.find((filter) => filter.filter === newFilter);
if (found) {
@ -112,13 +116,11 @@ export class FilterDialogComponent extends DialogComponent<FilterDialogComponent
};
}
ngOnInit(): void {
}
isErrorState(control: UntypedFormControl | null, form: FormGroupDirective | NgForm | null): boolean {
const originalErrorState = this.errorStateMatcher.isErrorState(control, form);
const customErrorState = !!(control && control.invalid && this.submitted);
return originalErrorState || customErrorState;
onEditableChange(event: MouseEvent): void {
event.stopPropagation();
const editableControl = this.filterFormGroup.controls.editable;
editableControl.setValue(!editableControl.value);
editableControl.markAsDirty();
}
cancel(): void {
@ -126,10 +128,11 @@ export class FilterDialogComponent extends DialogComponent<FilterDialogComponent
}
save(): void {
this.submitted = true;
this.filter.filter = this.filterFormGroup.get('filter').value.trim();
this.filter.editable = this.filterFormGroup.get('editable').value;
this.filter.keyFilters = this.filterFormGroup.get('keyFilters').value;
const {filter, editable, keyFilters, keyFiltersOperation} = this.filterFormGroup.getRawValue();
this.filter.filter = filter.trim();
this.filter.editable = editable;
this.filter.keyFilters = keyFilters;
this.filter.keyFiltersOperation = keyFiltersOperation;
if (this.isAdd) {
this.filter.id = this.utils.guid();
}

112
ui-ngx/src/app/modules/home/components/filter/filter-predicate-list.component.html

@ -15,82 +15,67 @@
limitations under the License.
-->
<section class="flex flex-col" [formGroup]="filterListFormGroup">
<mat-expansion-panel [expanded]="true">
<mat-expansion-panel-header>
<mat-panel-title>
<div translate>filter.filters</div>
</mat-panel-title>
</mat-expansion-panel-header>
<div class="flex flex-row">
<span class="max-w-8% flex-full"></span>
<div class="flex max-w-92% flex-full flex-row items-center justify-start gap-2">
<div class="flex flex-1 flex-row gap-2">
<div class="flex max-w-30% flex-full flex-row items-center justify-start gap-2">
<label translate class="tb-title no-padding flex-1">filter.operation.operation</label>
@if (valueType === valueTypeEnum.STRING) {
<label
translate class="tb-title no-padding" style="min-width: 70px;">filter.ignore-case</label>
}
<section class="flex flex-col gap-4" [formGroup]="filterListFormGroup">
@if (predicatesFormArray.length) {
<div class="tb-form-panel no-padding no-gap stroked">
<div class="flex flex-row">
<div class="flex flex-full flex-row items-center justify-start gap-2">
<div class="flex flex-1 flex-row pl-2">
<label translate class="filter-title max-w-30% flex-full">filter.operation.operation</label>
<label translate class="filter-title max-w-70% flex-full">filter.value</label>
</div>
<label translate class="tb-title no-padding max-w-70% flex-full">filter.value</label>
@if (displayUserParameters) {
<span [class.!hidden]="disabled" class="min-w-12">&nbsp;</span>
}
</div>
@if (displayUserParameters) {
<label
translate class="tb-title no-padding" style="width: 60px;">filter.user-parameters</label>
}
<span [class.!hidden]="disabled" style="min-width: 40px;">&nbsp;</span>
</div>
</div>
<mat-divider style="padding-bottom: 5px;"></mat-divider>
<div class="predicate-list">
@for (predicateControl of predicatesFormArray.controls; track predicateControl) {
<div class="flex flex-row items-stretch justify-start h-full min-h-19 max-h-24"
formArrayName="predicates"
>
<div class="filters-operation flex max-w-8% flex-full flex-row items-stretch justify-center">
@if ($index > 0) {
<span>{{ complexOperationTranslations.get(operation) | translate }}</span>
<mat-divider style="padding-bottom: 5px;"></mat-divider>
<div class="predicate-list">
@for (predicateControl of predicatesFormArray.controls; track predicateControl) {
<div class="relative flex flex-row items-stretch justify-start" [class.key-filter-list-divider]="$index">
@if ($index) {
<div class="filters-operation-container">
<span class="filters-operation-label">{{ complexOperationTranslations.get(operation) | translate }}</span>
</div>
}
</div>
<div class="flex max-w-92% min-w-147.5 flex-full flex-col">
<div class="flex flex-1 flex-row items-center justify-start gap-2">
<tb-filter-predicate
class="flex-1"
[valueType]="valueType"
[displayUserParameters]="displayUserParameters"
[allowUserDynamicSource]="allowUserDynamicSource"
[onlyUserDynamicSource]="onlyUserDynamicSource"
[key]="key"
[formControl]="predicateControl">
</tb-filter-predicate>
<button mat-icon-button color="primary"
[class.!hidden]="disabled"
type="button"
(click)="removePredicate($index)"
matTooltip="{{ 'filter.remove-filter' | translate }}"
matTooltipPosition="above">
<mat-icon>close</mat-icon>
</button>
<div class="flex flex-full flex-col">
<div class="flex flex-1 flex-row items-center justify-start">
<tb-filter-predicate
class="flex-1"
[valueType]="valueType"
[displayUserParameters]="displayUserParameters"
[allowUserDynamicSource]="allowUserDynamicSource"
[onlyUserDynamicSource]="onlyUserDynamicSource"
[key]="key"
[formControl]="predicateControl">
</tb-filter-predicate>
<button mat-icon-button color="primary"
[class.!hidden]="disabled"
type="button"
(click)="removePredicate($index)"
matTooltip="{{ 'filter.remove-filter' | translate }}"
matTooltipPosition="above">
<mat-icon>close</mat-icon>
</button>
</div>
</div>
</div>
</div>
}
<span [class.!hidden]="!!predicatesFormArray.length"
[class.disabled]="disabled"
class="no-data-found flex items-center justify-center" translate>filter.no-filters</span>
}
</div>
</div>
<div style="margin-top: 16px;" class="flex flex-row gap-2">
<button mat-button mat-raised-button color="primary"
[class.!hidden]="disabled"
} @else {
<span [class.disabled]="disabled" class="no-data-found tb-prompt flex items-center justify-center" translate>filter.no-filters</span>
}
<div class="flex flex-row gap-2" [class.!hidden]="disabled">
<button mat-button mat-stroked-button color="primary"
(click)="addPredicate(false)"
type="button"
matTooltip="{{ 'filter.add-filter' | translate }}"
matTooltipPosition="above">
{{ 'action.add' | translate }}
</button>
<button mat-button mat-raised-button color="primary"
[class.!hidden]="disabled"
<button mat-button
color="primary"
(click)="addPredicate(true)"
type="button"
matTooltip="{{ 'filter.add-complex-filter' | translate }}"
@ -98,5 +83,4 @@
{{ 'filter.add-complex' | translate }}
</button>
</div>
</mat-expansion-panel>
</section>

42
ui-ngx/src/app/modules/home/components/filter/filter-predicate-list.component.scss

@ -13,18 +13,40 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
@import "../../../../../scss/constants";
:host {
.predicate-list {
overflow-y: auto;
overflow-x: hidden;
max-height: 350px;
.no-data-found {
height: 50px;
}
.filter-title {
padding: 12px 8px;
font-size: 14px;
font-weight: 500;
}
.no-data-found {
height: 50px;
font-size: 16px;
}
.key-filter-list-divider {
border-top: 1px solid rgba(0, 0, 0, 0.12);
}
.filters-operation {
margin-top: -18px;
color: #666;
font-weight: 500;
display: flex;
justify-content: center;
margin-top: -14px;
&-container {
position: absolute;
top: -12px;
left: 10px;
background-color: white;
}
&-label {
font-size: 15px;
font-weight: 400;
color: $tb-primary-color;
padding: 0 8px;
border-radius: 4px;
border: 1px solid rgba($tb-primary-color, 0.32);
background-color: rgba($tb-primary-color, 0.04);
}
}
}

121
ui-ngx/src/app/modules/home/components/filter/filter-predicate-value.component.html

@ -15,70 +15,75 @@
limitations under the License.
-->
<div class="flex flex-1 flex-row items-center justify-start gap-2" [formGroup]="filterPredicateValueFormGroup">
<div class="flex flex-1 flex-col" [class.!hidden]="dynamicMode || onlyUserDynamicSource">
<div class="flex flex-col">
@switch (valueType) {
@case (valueTypeEnum.STRING) {
<mat-form-field hideRequiredMarker class="mat-block">
<input matInput formControlName="defaultValue" placeholder="{{'filter.value' | translate}}">
</mat-form-field>
}
@case (valueTypeEnum.NUMERIC) {
<mat-form-field hideRequiredMarker class="mat-block">
<input required type="number" matInput formControlName="defaultValue"
placeholder="{{'filter.value' | translate}}">
</mat-form-field>
}
@case (valueTypeEnum.DATE_TIME) {
<tb-datetime formControlName="defaultValue"
dateText="{{ 'filter.date' | translate }}"
required [showLabel]="false"></tb-datetime>
<div class="flex flex-1 flex-row items-center justify-between gap-2" [formGroup]="filterPredicateValueFormGroup">
<div class="flex flex-1 flex-col">
<div class="flex flex-1 flex-col" [class.!hidden]="dynamicMode || onlyUserDynamicSource">
<div class="tb-form-row no-padding no-border column-xs">
@switch (valueType) {
@case (valueTypeEnum.STRING) {
<mat-form-field hideRequiredMarker class="flex-1" appearance="outline" subscriptSizing="dynamic">
<mat-label> {{ hintText | translate }}</mat-label>
<input matInput formControlName="defaultValue" placeholder="{{'filter.value' | translate}}">
</mat-form-field>
}
@case (valueTypeEnum.NUMERIC) {
<mat-form-field hideRequiredMarker class="flex-1" appearance="outline" subscriptSizing="dynamic">
<mat-label> {{ hintText | translate }}</mat-label>
<input required type="number" matInput formControlName="defaultValue"
placeholder="{{'filter.value' | translate}}">
</mat-form-field>
}
@case (valueTypeEnum.DATE_TIME) {
<tb-datetime formControlName="defaultValue"
appearance="outline"
dateText="{{ 'filter.date' | translate }}"
required [showLabel]="false"></tb-datetime>
}
@case (valueTypeEnum.BOOLEAN) {
<mat-checkbox formControlName="defaultValue">
{{ (filterPredicateValueFormGroup.get('defaultValue').value ? 'value.true' : 'value.false') | translate }}
</mat-checkbox>
}
}
@case (valueTypeEnum.BOOLEAN) {
<mat-checkbox formControlName="defaultValue">
{{ (filterPredicateValueFormGroup.get('defaultValue').value ? 'value.true' : 'value.false') | translate }}
</mat-checkbox>
}
}
</div>
</div>
<div class="tb-hint">{{ hintText | translate }}</div>
</div>
<div class="flex flex-col flex-1" [class.!hidden]="!dynamicMode && !onlyUserDynamicSource">
<div formGroupName="dynamicValue" class="flex flex-row justify-start gap-2">
<div class="flex max-w-35% flex-full flex-col">
<mat-form-field hideRequiredMarker class="mat-block">
<mat-select formControlName="sourceType" placeholder="{{'filter.dynamic-source-type' | translate}}">
<mat-option [value]="null">
{{'filter.no-dynamic-value' | translate}}
</mat-option>
@for (sourceType of dynamicValueSourceTypes; track sourceType) {
<mat-option [value]="sourceType">
{{dynamicValueSourceTypeTranslations.get(sourceType) | translate}}
<div class="flex flex-col flex-1" [class.!hidden]="!dynamicMode && !onlyUserDynamicSource">
<div formGroupName="dynamicValue" class="flex flex-row justify-start gap-2">
<div class="tb-form-row no-padding no-border max-w-35% flex-full">
<mat-form-field hideRequiredMarker class="flex-1" appearance="outline" subscriptSizing="dynamic">
<mat-label translate>filter.dynamic-source-type</mat-label>
<mat-select formControlName="sourceType" placeholder="{{'filter.dynamic-source-type' | translate}}">
<mat-option [value]="null">
{{'filter.no-dynamic-value' | translate}}
</mat-option>
}
</mat-select>
</mat-form-field>
<div class="tb-hint" translate>filter.dynamic-source-type</div>
</div>
<div class="flex flex-1 flex-col">
<mat-form-field hideRequiredMarker class="mat-block">
<input matInput formControlName="sourceAttribute" placeholder="{{'filter.source-attribute' | translate}}">
</mat-form-field>
<div class="tb-hint" translate>filter.source-attribute</div>
</div>
@if (!allow && inheritMode) {
<div
class="flex flex-col"
style="padding-top: 6px">
<mat-checkbox formControlName="inherit">
{{ 'filter.inherit-owner' | translate}}
</mat-checkbox>
<div class="tb-hint" translate>filter.source-attribute-not-set</div>
@for (sourceType of dynamicValueSourceTypes; track sourceType) {
<mat-option [value]="sourceType">
{{dynamicValueSourceTypeTranslations.get(sourceType) | translate}}
</mat-option>
}
</mat-select>
</mat-form-field>
</div>
<div class="tb-form-row no-padding no-border column-xs flex-full">
<mat-form-field hideRequiredMarker class="flex-1" appearance="outline" subscriptSizing="dynamic">
<mat-label translate>filter.source-attribute</mat-label>
<input matInput formControlName="sourceAttribute" placeholder="{{'filter.source-attribute' | translate}}">
</mat-form-field>
</div>
}
@if (!allow && inheritMode) {
<div
class="flex flex-col"
style="padding-top: 6px">
<mat-checkbox formControlName="inherit">
{{ 'filter.inherit-owner' | translate}}
</mat-checkbox>
<div class="tb-hint" translate>filter.source-attribute-not-set</div>
</div>
}
</div>
</div>
</div>
@if (!onlyUserDynamicSource) {
<button mat-icon-button
class="mat-elevation-z1 tb-mat-32"

101
ui-ngx/src/app/modules/home/components/filter/filter-predicate.component.html

@ -15,51 +15,84 @@
limitations under the License.
-->
<div class="flex flex-1 flex-row items-center justify-start gap-2" [formGroup]="filterPredicateFormGroup">
<div class="flex flex-1 flex-col">
<div class="flex flex-1 flex-row items-center justify-start gap-4" [formGroup]="filterPredicateFormGroup">
<div class="tb-form-panel no-border no-padding-right flex-1 items-center" style="flex-direction: row">
@switch (type) {
@case (filterPredicateType.STRING) {
<tb-string-filter-predicate
[allowUserDynamicSource]="allowUserDynamicSource"
[onlyUserDynamicSource]="onlyUserDynamicSource"
formControlName="predicate">
</tb-string-filter-predicate>
<div class="tb-form-row no-border no-padding w-full max-w-30%">
<mat-form-field hideRequiredMarker class="mat-block flex-1" appearance="outline" subscriptSizing="dynamic">
<mat-select required formControlName="operation" placeholder="{{'filter.operation.operation' | translate}}">
@for (operation of stringOperations; track operation) {
<mat-option [value]="operation">
{{stringOperationTranslations.get(stringOperationEnum[operation]) | translate}}
</mat-option>
}
</mat-select>
</mat-form-field>
<mat-chip-listbox formControlName="ignoreCase" [hideSingleSelectionIndicator]="true">
<mat-chip-option color="primary" [value]="true">{{ 'alarm-rule.ignore-case' | translate }}</mat-chip-option>
</mat-chip-listbox>
</div>
}
@case (filterPredicateType.NUMERIC) {
<tb-numeric-filter-predicate
[allowUserDynamicSource]="allowUserDynamicSource"
[onlyUserDynamicSource]="onlyUserDynamicSource"
[valueType]="valueType"
formControlName="predicate">
</tb-numeric-filter-predicate>
<div class="tb-form-row no-border no-padding w-full max-w-30%">
<mat-form-field hideRequiredMarker class="flex-1" appearance="outline" subscriptSizing="dynamic">
<mat-select required formControlName="operation" placeholder="{{'filter.operation.operation' | translate}}">
@for (operation of numericOperations; track operation) {
<mat-option [value]="operation">
{{numericOperationTranslations.get(numericOperationEnum[operation]) | translate}}
</mat-option>
}
</mat-select>
</mat-form-field>
</div>
}
@case (filterPredicateType.BOOLEAN) {
<tb-boolean-filter-predicate
[allowUserDynamicSource]="allowUserDynamicSource"
[onlyUserDynamicSource]="onlyUserDynamicSource"
formControlName="predicate">
</tb-boolean-filter-predicate>
<div class="tb-form-row no-border no-padding w-full max-w-30%">
<mat-form-field hideRequiredMarker class="flex-1" appearance="outline" subscriptSizing="dynamic">
<mat-select required formControlName="operation" placeholder="{{'filter.operation.operation' | translate}}">
@for (operation of booleanOperations; track operation) {
<mat-option [value]="operation">
{{booleanOperationTranslations.get(booleanOperationEnum[operation]) | translate}}
</mat-option>
}
</mat-select>
</mat-form-field>
</div>
}
@case (filterPredicateType.COMPLEX) {
<tb-complex-filter-predicate
[allowUserDynamicSource]="allowUserDynamicSource"
[onlyUserDynamicSource]="onlyUserDynamicSource"
[key]="key"
[valueType]="valueType"
[displayUserParameters]="displayUserParameters"
formControlName="predicate">
</tb-complex-filter-predicate>
<div class="tb-form-row no-border no-padding flex-1">
<mat-label translate>filter.complex-filter</mat-label>
<button mat-icon-button color="primary"
class="tb-mat-32"
type="button"
(click)="openComplexFilterDialog()"
matTooltip="{{ (disabled ? 'filter.complex-filter' : 'filter.edit-complex-filter') | translate }}"
matTooltipPosition="above">
<mat-icon>{{ disabled ? 'more_vert' : 'edit' }}</mat-icon>
</button>
</div>
}
}
@if (type !== filterPredicateType.COMPLEX) {
<tb-filter-predicate-value
[allowUserDynamicSource]="allowUserDynamicSource"
[onlyUserDynamicSource]="onlyUserDynamicSource"
[operation]="filterPredicateFormGroup.get('operation').value"
class="max-w-70% flex-full"
[valueType]="valueType"
formControlName="value">
</tb-filter-predicate-value>
}
</div>
@if (type !== filterPredicateType.COMPLEX && displayUserParameters) {
<tb-filter-user-info
style="width: 60px;"
class="flex flex-row items-stretch justify-center"
[valueType]="valueType"
[operation]="filterPredicateFormGroup.get('predicate').value?.operation"
[key]="key"
formControlName="userInfo">
</tb-filter-user-info>
<button mat-icon-button color="primary"
class="tb-mat-32"
type="button"
(click)="openFilterUserInfoDialog()"
matTooltip="{{ (disabled ? 'filter.filter-user-params' : 'filter.edit-filter-user-params') | translate }}"
matTooltipPosition="above">
<mat-icon>settings</mat-icon>
</button>
}
</div>

142
ui-ngx/src/app/modules/home/components/filter/filter-predicate.component.ts

@ -14,19 +14,35 @@
/// limitations under the License.
///
import { Component, DestroyRef, forwardRef, Input, OnInit } from '@angular/core';
import { Component, DestroyRef, forwardRef, Input } from '@angular/core';
import {
ControlValueAccessor,
UntypedFormBuilder,
UntypedFormGroup,
NG_VALIDATORS,
NG_VALUE_ACCESSOR,
ValidationErrors,
Validator,
Validators
Validators,
} from '@angular/forms';
import { EntityKeyValueType, FilterPredicateType, KeyFilterPredicateInfo } from '@shared/models/query/query.models';
import {
EntityKeyValueType,
FilterPredicateType,
KeyFilterPredicateInfo,
BooleanOperation, booleanOperationTranslationMap,
NumericOperation, numericOperationTranslationMap,
StringOperation, stringOperationTranslationMap, ComplexFilterPredicateInfo, KeyFilterPredicateUserInfo,
KeyFilterPredicate
} from '@shared/models/query/query.models';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { ComplexFilterPredicateDialogData } from '@home/components/filter/filter-component.models';
import {
ComplexFilterPredicateDialogComponent
} from '@home/components/filter/complex-filter-predicate-dialog.component';
import { MatDialog } from '@angular/material/dialog';
import {
FilterUserInfoDialogComponent,
FilterUserInfoDialogData
} from '@home/components/filter/filter-user-info-dialog.component';
@Component({
selector: 'tb-filter-predicate',
@ -46,7 +62,7 @@ import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
],
standalone: false
})
export class FilterPredicateComponent implements ControlValueAccessor, Validator, OnInit {
export class FilterPredicateComponent implements ControlValueAccessor, Validator {
@Input() disabled: boolean;
@ -60,23 +76,35 @@ export class FilterPredicateComponent implements ControlValueAccessor, Validator
@Input() onlyUserDynamicSource = false;
filterPredicateFormGroup: UntypedFormGroup;
filterPredicateFormGroup = this.fb.group({
operation: [],
ignoreCase: false,
predicates: [],
value: [],
userInfo: []
});
type: FilterPredicateType;
filterPredicateType = FilterPredicateType;
stringOperations = Object.keys(StringOperation);
stringOperationEnum = StringOperation;
stringOperationTranslations = stringOperationTranslationMap;
numericOperations = Object.keys(NumericOperation);
numericOperationEnum = NumericOperation;
numericOperationTranslations = numericOperationTranslationMap;
booleanOperations = Object.keys(BooleanOperation);
booleanOperationEnum = BooleanOperation;
booleanOperationTranslations = booleanOperationTranslationMap;
private propagateChange = null;
constructor(private fb: UntypedFormBuilder,
private destroyRef: DestroyRef) {
}
ngOnInit(): void {
this.filterPredicateFormGroup = this.fb.group({
predicate: [null, [Validators.required]],
userInfo: [null, []]
});
private destroyRef: DestroyRef,
private dialog: MatDialog) {
this.filterPredicateFormGroup.valueChanges.pipe(
takeUntilDestroyed(this.destroyRef)
).subscribe(() => {
@ -93,7 +121,7 @@ export class FilterPredicateComponent implements ControlValueAccessor, Validator
setDisabledState(isDisabled: boolean): void {
this.disabled = isDisabled;
if (this.disabled) {
if (isDisabled) {
this.filterPredicateFormGroup.disable({emitEvent: false});
} else {
this.filterPredicateFormGroup.enable({emitEvent: false});
@ -108,19 +136,97 @@ export class FilterPredicateComponent implements ControlValueAccessor, Validator
writeValue(predicate: KeyFilterPredicateInfo): void {
this.type = predicate.keyFilterPredicate.type;
this.filterPredicateFormGroup.get('predicate').patchValue(predicate.keyFilterPredicate, {emitEvent: false});
this.updateValidators();
this.filterPredicateFormGroup.patchValue(predicate.keyFilterPredicate, {emitEvent: false});
this.filterPredicateFormGroup.get('userInfo').patchValue(predicate.userInfo, {emitEvent: false});
}
private updateValidators(): void {
const operationCtrl = this.filterPredicateFormGroup.get('operation');
const predicatesCtrl = this.filterPredicateFormGroup.get('predicates');
operationCtrl.setValidators([Validators.required]);
if (this.type === FilterPredicateType.COMPLEX) {
predicatesCtrl.setValidators([Validators.required]);
} else {
predicatesCtrl.clearValidators();
}
operationCtrl.updateValueAndValidity({emitEvent: false});
predicatesCtrl.updateValueAndValidity({emitEvent: false});
}
private updateModel() {
let predicate: KeyFilterPredicateInfo = null;
if (this.filterPredicateFormGroup.valid) {
const v = this.filterPredicateFormGroup.getRawValue();
let keyFilterPredicate: KeyFilterPredicate;
if (this.type === FilterPredicateType.COMPLEX) {
keyFilterPredicate = {
type: FilterPredicateType.COMPLEX,
operation: v.operation,
predicates: v.predicates
} as KeyFilterPredicate;
} else {
keyFilterPredicate = {
type: this.type,
value: v.value,
operation: v.operation,
ignoreCase: !!v.ignoreCase
} as KeyFilterPredicate;
}
predicate = {
keyFilterPredicate: this.filterPredicateFormGroup.getRawValue().predicate,
userInfo: this.filterPredicateFormGroup.getRawValue().userInfo
keyFilterPredicate,
userInfo: v.userInfo
};
}
this.propagateChange(predicate);
}
public openComplexFilterDialog() {
this.dialog.open<ComplexFilterPredicateDialogComponent, ComplexFilterPredicateDialogData,
ComplexFilterPredicateInfo>(ComplexFilterPredicateDialogComponent, {
disableClose: true,
panelClass: ['tb-dialog', 'tb-fullscreen-dialog'],
data: {
complexPredicate: {
type: FilterPredicateType.COMPLEX,
operation: this.filterPredicateFormGroup.get('operation').value,
predicates: this.filterPredicateFormGroup.get('predicates').value
},
readonly: this.disabled,
valueType: this.valueType,
isAdd: false,
key: this.key,
displayUserParameters: this.displayUserParameters,
allowUserDynamicSource: this.allowUserDynamicSource,
onlyUserDynamicSource: this.onlyUserDynamicSource
}
}).afterClosed().subscribe(
(result) => {
if (result) {
this.filterPredicateFormGroup.patchValue(result);
}
}
);
}
public openFilterUserInfoDialog() {
this.dialog.open<FilterUserInfoDialogComponent, FilterUserInfoDialogData,
KeyFilterPredicateUserInfo>(FilterUserInfoDialogComponent, {
disableClose: true,
panelClass: ['tb-dialog', 'tb-fullscreen-dialog'],
data: {
keyFilterPredicateUserInfo: this.filterPredicateFormGroup.get('userInfo').value,
valueType: this.valueType,
key: this.key,
operation: this.filterPredicateFormGroup.get('operation').value,
readonly: this.disabled
}
}).afterClosed().subscribe(
(result) => {
if (result) {
this.filterPredicateFormGroup.get('userInfo').patchValue(result);
}
}
);
}
}

65
ui-ngx/src/app/modules/home/components/filter/filter-text.component.ts

@ -14,37 +14,31 @@
/// limitations under the License.
///
import { Component, forwardRef, Input, OnInit } from '@angular/core';
import { ControlValueAccessor, UntypedFormBuilder, NG_VALUE_ACCESSOR } from '@angular/forms';
import { MatDialog } from '@angular/material/dialog';
import { KeyFilter, keyFiltersToText } from '@shared/models/query/query.models';
import { Component, forwardRef, Input, OnChanges, SimpleChanges } from '@angular/core';
import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms';
import { ComplexOperation, KeyFilter, keyFiltersToText } from '@shared/models/query/query.models';
import { TranslateService } from '@ngx-translate/core';
import { DatePipe } from '@angular/common';
import { coerceBooleanProperty } from '@angular/cdk/coercion';
import { coerceBoolean } from '@shared/decorators/coercion';
@Component({
selector: 'tb-filter-text',
templateUrl: './filter-text.component.html',
styleUrls: ['./filter-text.component.scss'],
providers: [
{
provide: NG_VALUE_ACCESSOR,
useExisting: forwardRef(() => FilterTextComponent),
multi: true
}
],
standalone: false
selector: 'tb-filter-text',
templateUrl: './filter-text.component.html',
styleUrls: ['./filter-text.component.scss'],
providers: [
{
provide: NG_VALUE_ACCESSOR,
useExisting: forwardRef(() => FilterTextComponent),
multi: true
}
],
standalone: false
})
export class FilterTextComponent implements ControlValueAccessor, OnInit {
export class FilterTextComponent implements ControlValueAccessor, OnChanges {
private requiredValue: boolean;
get required(): boolean {
return this.requiredValue;
}
@Input()
set required(value: boolean) {
this.requiredValue = coerceBooleanProperty(value);
}
@coerceBoolean()
required = false;
@Input()
disabled: boolean;
@ -58,26 +52,30 @@ export class FilterTextComponent implements ControlValueAccessor, OnInit {
@Input()
nowrap = false;
@Input()
operation: ComplexOperation = ComplexOperation.AND;
requiredClass = false;
public filterText: string;
private propagateChange = (v: any) => { };
private currentValue: Array<KeyFilter>;
constructor(private dialog: MatDialog,
private fb: UntypedFormBuilder,
private translate: TranslateService,
constructor(private translate: TranslateService,
private datePipe: DatePipe) {
}
registerOnChange(fn: any): void {
this.propagateChange = fn;
registerOnChange(_fn: any): void {
}
registerOnTouched(fn: any): void {
registerOnTouched(_fn: any): void {
}
ngOnInit() {
ngOnChanges(changes: SimpleChanges) {
if (changes.operation && !changes.operation.firstChange
&& changes.operation.currentValue !== changes.operation.previousValue) {
this.updateFilterText(this.currentValue);
}
}
setDisabledState(isDisabled: boolean): void {
@ -85,13 +83,14 @@ export class FilterTextComponent implements ControlValueAccessor, OnInit {
}
writeValue(value: Array<KeyFilter>): void {
this.currentValue = value;
this.updateFilterText(value);
}
private updateFilterText(value: Array<KeyFilter>) {
this.requiredClass = false;
if (value && value.length) {
this.filterText = keyFiltersToText(this.translate, this.datePipe, value);
this.filterText = keyFiltersToText(this.translate, this.datePipe, value, this.operation);
} else {
if (this.required && !this.disabled) {
this.filterText = this.addFilterPrompt;

111
ui-ngx/src/app/modules/home/components/filter/filters-dialog.component.html

@ -25,72 +25,61 @@
<mat-icon class="material-icons">close</mat-icon>
</button>
</mat-toolbar>
@if (isLoading$ | async) {
<mat-progress-bar color="warn" mode="indeterminate">
</mat-progress-bar>
}
@if ((isLoading$ | async) === false) {
<div style="height: 4px;"></div>
}
<div mat-dialog-content>
<div class="tb-filters-header flex flex-row items-center justify-start">
<span class="max-w-5% flex-full"></span>
<div class="flex max-w-95% flex-full flex-row items-center justify-start">
<div class="tb-header-label flex-1" translate>filter.filter</div>
<div class="tb-header-label flex min-w-20 max-w-20 flex-1 basis-20 flex-col items-center justify-center" translate
style="padding-left: 5px;">filter.editable</div>
<div style="min-width: 132px;"></div>
<div class="tb-form-panel no-border no-padding">
<div class="tb-form-panel stroked no-padding no-gap" [class.!hidden]="!filtersFormArray().length">
<table mat-table [dataSource]="filtersFormArray().controls" class="overflow-hidden bg-transparent">
<ng-container matColumnDef="filter">
<mat-header-cell *matHeaderCellDef translate>filter.filter</mat-header-cell>
<mat-cell *matCellDef="let control">
<span>{{ control.get('filter').value }}</span>
</mat-cell>
</ng-container>
<ng-container matColumnDef="editable">
<mat-header-cell *matHeaderCellDef translate>filter.editable</mat-header-cell>
<mat-cell *matCellDef="let control">
<mat-slide-toggle [formControl]="control.get('editable')"></mat-slide-toggle>
</mat-cell>
</ng-container>
<ng-container matColumnDef="actions" stickyEnd>
<mat-header-cell *matHeaderCellDef/>
<mat-cell *matCellDef="let control; let i = index">
<div class="tb-form-table-row-cell-buttons flex-end">
<button mat-icon-button color="primary" type="button"
[disabled]="isLoading$ | async"
(click)="editFilter(i)"
matTooltip="{{ 'filter.edit' | translate }}"
matTooltipPosition="above">
<mat-icon>edit</mat-icon>
</button>
<button mat-icon-button color="primary" type="button"
[disabled]="isLoading$ | async"
(click)="duplicateFilter(i)"
matTooltip="{{ 'filter.duplicate-filter-action' | translate }}"
matTooltipPosition="above">
<mat-icon>content_copy</mat-icon>
</button>
<button mat-icon-button color="primary" type="button"
[disabled]="isLoading$ | async"
(click)="removeFilter(i)"
matTooltip="{{ 'filter.remove-filter' | translate }}"
matTooltipPosition="above">
<mat-icon>close</mat-icon>
</button>
</div>
</mat-cell>
</ng-container>
<mat-header-row *matHeaderRowDef="displayedColumns"></mat-header-row>
<mat-row *matRowDef="let row; columns: displayedColumns"></mat-row>
</table>
</div>
<div [class.!hidden]="!!filtersFormArray().length" class="tb-prompt flex items-center justify-center" translate>
filter.no-filters
</div>
</div>
<fieldset [disabled]="isLoading$ | async">
<mat-divider></mat-divider>
@for (filterControl of filtersFormArray().controls; track filterControl) {
<div class="flex flex-1 flex-row items-center justify-start"
formArrayName="filters"
>
<span class="max-w-5% flex-full">{{$index + 1}}.</span>
<div class="mat-elevation-z4 tb-filter flex max-w-95% flex-full flex-row items-center justify-start">
<mat-label class="flex-1">{{filterControl.get('filter').value}}</mat-label>
<section style="padding-left: 10px;"
class="tb-editable-switch flex min-w-20 max-w-20 flex-1 basis-20 flex-col items-center justify-center">
<mat-slide-toggle class="editable-switch"
[formControl]="filterControl.get('editable')">
</mat-slide-toggle>
</section>
<button [disabled]="isLoading$ | async"
mat-icon-button color="primary"
style="min-width: 40px;"
type="button"
(click)="editFilter($index)"
matTooltip="{{ 'filter.edit' | translate }}"
matTooltipPosition="above">
<mat-icon>edit</mat-icon>
</button>
<button [disabled]="isLoading$ | async"
mat-icon-button color="primary"
style="min-width: 48px;"
type="button"
(click)="duplicateFilter($index)"
matTooltip="{{ 'filter.duplicate-filter-action' | translate }}"
matTooltipPosition="above">
<mat-icon>content_copy</mat-icon>
</button>
<button [disabled]="isLoading$ | async"
mat-icon-button color="primary"
style="min-width: 40px;"
type="button"
(click)="removeFilter($index)"
matTooltip="{{ 'filter.remove-filter' | translate }}"
matTooltipPosition="above">
<mat-icon>close</mat-icon>
</button>
</div>
</div>
}
</fieldset>
</div>
<div mat-dialog-actions>
<button mat-raised-button color="primary"
<button mat-stroked-button color="primary"
type="button"
(click)="addFilter()"
[class.!hidden]="disableAdd"

42
ui-ngx/src/app/modules/home/components/filter/filters-dialog.component.scss

@ -14,39 +14,15 @@
* limitations under the License.
*/
:host {
.tb-filters-header {
min-height: 40px;
padding: 0 11px;
margin: 5px;
.tb-header-label {
font-size: 14px;
color: rgba(0, 0, 0, .570588);
}
}
mat-divider{
margin: -1px -24px;
}
.tb-filter {
padding: 0 0 0 10px;
margin: 5px;
.tb-editable-switch {
padding-left: 10px;
.editable-switch {
margin: 0;
}
}
.mat-mdc-header-cell.mat-column-editable,
.mat-mdc-cell.mat-column-editable,
.mat-mdc-header-cell.mat-column-actions,
.mat-mdc-cell.mat-column-actions {
width: 1%;
white-space: nowrap;
}
}
:host ::ng-deep {
.mat-mdc-dialog-content {
padding-top: 0 !important;
padding-bottom: 0 !important;
.mat-mdc-row:last-child .mat-mdc-cell {
border-bottom: none;
}
}
}

11
ui-ngx/src/app/modules/home/components/filter/filters-dialog.component.ts

@ -37,7 +37,7 @@ import { TranslateService } from '@ngx-translate/core';
import { ActionNotificationShow } from '@core/notification/notification.actions';
import { DialogService } from '@core/services/dialog.service';
import { deepClone, isUndefined } from '@core/utils';
import { Filter, Filters, KeyFilterInfo } from '@shared/models/query/query.models';
import { ComplexOperation, Filter, Filters, KeyFilterInfo } from '@shared/models/query/query.models';
import { FilterDialogComponent, FilterDialogData } from '@home/components/filter/filter-dialog.component';
import { DashboardUtilsService } from '@core/services/dashboard-utils.service';
@ -70,6 +70,8 @@ export class FiltersDialogComponent extends DialogComponent<FiltersDialogCompone
filtersFormGroup: UntypedFormGroup;
displayedColumns = ['filter', 'editable', 'actions'];
submitted = false;
constructor(protected store: Store<AppState>,
@ -132,7 +134,8 @@ export class FiltersDialogComponent extends DialogComponent<FiltersDialogCompone
id: [filterId],
filter: [filter ? filter.filter : null, [Validators.required]],
keyFilters: [filter ? filter.keyFilters : [], [Validators.required]],
editable: [filter ? filter.editable : true]
editable: [filter ? filter.editable : true],
keyFiltersOperation: [filter?.keyFiltersOperation]
});
return filterFormControl;
}
@ -228,6 +231,7 @@ export class FiltersDialogComponent extends DialogComponent<FiltersDialogCompone
filterFormControl.get('filter').patchValue(result.filter);
filterFormControl.get('editable').patchValue(result.editable);
filterFormControl.get('keyFilters').patchValue(result.keyFilters);
filterFormControl.get('keyFiltersOperation').patchValue(result.keyFiltersOperation);
}
this.filterNames.add(result.filter);
this.filtersFormGroup.markAsDirty();
@ -253,6 +257,7 @@ export class FiltersDialogComponent extends DialogComponent<FiltersDialogCompone
const filter: string = filterValue.filter;
const keyFilters: Array<KeyFilterInfo> = filterValue.keyFilters;
const editable: boolean = filterValue.editable;
const keyFiltersOperation: ComplexOperation = filterValue.keyFiltersOperation;
if (uniqueFilterList[filter]) {
valid = false;
message = this.translate.instant('filter.duplicate-filter-error', {filter});
@ -263,7 +268,7 @@ export class FiltersDialogComponent extends DialogComponent<FiltersDialogCompone
break;
} else {
uniqueFilterList[filter] = filter;
filters[filterId] = {id: filterId, filter, keyFilters, editable};
filters[filterId] = {id: filterId, filter, keyFilters, editable, keyFiltersOperation};
}
}
if (valid) {

205
ui-ngx/src/app/modules/home/components/filter/key-filter-dialog.component.html

@ -26,119 +26,128 @@
</button>
</mat-toolbar>
<div mat-dialog-content>
<fieldset [disabled]="isLoading$ | async" class="flex flex-col">
<section class="entity-key flex flex-row gap-2">
<section class="flex flex-full flex-row gap-2"
[class]="{
<section class="tb-form-panel no-border no-padding">
<section class="tb-form-panel">
<div class="tb-form-panel-title">{{ 'filter.key-filter' | translate }}</div>
<div class="flex flex-row gap-2 xs:flex-col">
<div class="flex flex-row flex-full gap-2 xs:flex-col"
[class]="{
'max-w-45%': isConstantKeyType,
'max-w-70%': !isConstantKeyType
}"
formGroupName="key">
<mat-form-field class="mat-block max-w-40% flex-full">
<mat-label translate>filter.key-type.key-type</mat-label>
<mat-select required formControlName="type">
@for (type of entityKeyTypes; track type) {
<mat-option [value]="type">
{{entityKeyTypeTranslations.get(type) | translate}}
</mat-option>
formGroupName="key">
<mat-form-field class="mat-block max-w-40% flex-full" appearance="outline">
<mat-label translate>filter.key-type.key-type</mat-label>
<mat-select required formControlName="type">
@for (type of entityKeyTypes; track type) {
<mat-option [value]="type">
{{entityKeyTypeTranslations.get(type) | translate}}
</mat-option>
}
</mat-select>
</mat-form-field>
<mat-form-field class="mat-block max-w-60% flex-full" appearance="outline">
<mat-label translate>filter.key-name</mat-label>
<input matInput required formControlName="key"
#keyNameInput
(focusin)="onFocus()"
[matAutocomplete]="keyName"
[matAutocompleteDisabled]="!showAutocomplete">
@if (keyFilterFormGroup.get('key.key').value && showAutocomplete) {
<button
type="button"
matSuffix mat-icon-button aria-label="Clear"
(click)="clear()">
<mat-icon class="material-icons">close</mat-icon>
</button>
}
</mat-select>
</mat-form-field>
<mat-form-field class="mat-block max-w-60% flex-full">
<mat-label translate>filter.key-name</mat-label>
<input matInput required formControlName="key"
#keyNameInput
(focusin)="onFocus()"
[matAutocomplete]="keyName"
[matAutocompleteDisabled]="!showAutocomplete">
@if (keyFilterFormGroup.get('key.key').value && showAutocomplete) {
<button
type="button"
matSuffix mat-icon-button aria-label="Clear"
(click)="clear()">
<mat-icon class="material-icons">close</mat-icon>
</button>
}
<mat-autocomplete autoActiveFirstOption #keyName="matAutocomplete">
@for (keyName of filteredKeysName | async; track keyName) {
<mat-option [value]="keyName">
<span [innerHTML]="keyName | highlight:searchText"></span>
<mat-autocomplete autoActiveFirstOption #keyName="matAutocomplete">
@for (keyName of filteredKeysName | async; track keyName) {
<mat-option [value]="keyName">
<span [innerHTML]="keyName | highlight:searchText"></span>
</mat-option>
}
</mat-autocomplete>
@if (keyFilterFormGroup.get('key.key').hasError('required')) {
<mat-error>
{{ 'filter.key-name-required' | translate }}
</mat-error>
}
</mat-form-field>
</div>
<mat-form-field class="mat-block tb-value-type flex-full" appearance="outline"
[class]="{
'max-w-20%': isConstantKeyType,
'max-w-30%': !isConstantKeyType
}">
<mat-label translate>filter.value-type.value-type</mat-label>
<mat-select formControlName="valueType">
<mat-select-trigger>
<mat-icon class="tb-mat-18" svgIcon="{{ entityKeyValueTypes.get(keyFilterFormGroup.get('valueType').value)?.icon }}"></mat-icon>
<span>{{ entityKeyValueTypes.get(keyFilterFormGroup.get('valueType').value)?.name | translate }}</span>
</mat-select-trigger>
@for (valueType of entityKeyValueTypesKeys; track valueType) {
<mat-option [value]="valueType">
<mat-icon class="tb-mat-18" svgIcon="{{ entityKeyValueTypes.get(entityKeyValueTypeEnum[valueType]).icon }}"></mat-icon>
<span>{{ entityKeyValueTypes.get(entityKeyValueTypeEnum[valueType]).name | translate }}</span>
</mat-option>
}
</mat-autocomplete>
@if (keyFilterFormGroup.get('key.key').hasError('required')) {
</mat-select>
@if (keyFilterFormGroup.get('valueType').hasError('required')) {
<mat-error>
{{ 'filter.key-name-required' | translate }}
{{ 'filter.value-type-required' | translate }}
</mat-error>
}
</mat-form-field>
</section>
<mat-form-field class="mat-block tb-value-type flex-full"
[class]="{
'max-w-20%': isConstantKeyType,
'max-w-30%': !isConstantKeyType
}">
<mat-label translate>filter.value-type.value-type</mat-label>
<mat-select formControlName="valueType">
<mat-select-trigger>
<mat-icon class="tb-mat-18" svgIcon="{{ entityKeyValueTypes.get(keyFilterFormGroup.get('valueType').value)?.icon }}"></mat-icon>
<span>{{ entityKeyValueTypes.get(keyFilterFormGroup.get('valueType').value)?.name | translate }}</span>
</mat-select-trigger>
@for (valueType of entityKeyValueTypesKeys; track valueType) {
<mat-option [value]="valueType">
<mat-icon class="tb-mat-18" svgIcon="{{ entityKeyValueTypes.get(entityKeyValueTypeEnum[valueType]).icon }}"></mat-icon>
<span>{{ entityKeyValueTypes.get(entityKeyValueTypeEnum[valueType]).name | translate }}</span>
</mat-option>
}
</mat-select>
@if (keyFilterFormGroup.get('valueType').hasError('required')) {
<mat-error>
{{ 'filter.value-type-required' | translate }}
</mat-error>
}
</mat-form-field>
@if (isConstantKeyType) {
<div class="flex-1">
<div class="flex flex-col">
@switch (keyFilterFormGroup.get('valueType').value) {
@case (entityKeyValueTypeEnum.STRING) {
<mat-form-field class="mat-block">
<mat-label translate>filter.value</mat-label>
<input matInput required formControlName="value" placeholder="{{'filter.value' | translate}}">
</mat-form-field>
}
@case (entityKeyValueTypeEnum.NUMERIC) {
<mat-form-field class="mat-block">
<mat-label translate>filter.value</mat-label>
<input required type="number" matInput formControlName="value">
</mat-form-field>
}
@case (entityKeyValueTypeEnum.DATE_TIME) {
<tb-datetime formControlName="value"
dateText="{{ 'filter.date' | translate }}"
required></tb-datetime>
}
@case (entityKeyValueTypeEnum.BOOLEAN) {
<mat-checkbox formControlName="value" style="margin-top: 20px">
{{ (keyFilterFormGroup.get('value').value ? 'value.true' : 'value.false') | translate }}
</mat-checkbox>
@if (isConstantKeyType) {
<div class="flex-1">
<div class="flex flex-col">
@switch (keyFilterFormGroup.get('valueType').value) {
@case (entityKeyValueTypeEnum.STRING) {
<mat-form-field class="mat-block" appearance="outline">
<mat-label translate>filter.value</mat-label>
<input matInput required formControlName="value" placeholder="{{'filter.value' | translate}}">
</mat-form-field>
}
@case (entityKeyValueTypeEnum.NUMERIC) {
<mat-form-field class="mat-block" appearance="outline">
<mat-label translate>filter.value</mat-label>
<input required type="number" matInput formControlName="value">
</mat-form-field>
}
@case (entityKeyValueTypeEnum.DATE_TIME) {
<tb-datetime formControlName="value"
dateText="{{ 'filter.date' | translate }}"
required></tb-datetime>
}
@case (entityKeyValueTypeEnum.BOOLEAN) {
<mat-checkbox formControlName="value" style="margin-top: 20px">
{{ (keyFilterFormGroup.get('value').value ? 'value.true' : 'value.false') | translate }}
</mat-checkbox>
}
}
}
</div>
</div>
</div>
}
}
</div>
</section>
@if (keyFilterFormGroup.get('valueType').value) {
<tb-filter-predicate-list
[allowUserDynamicSource]="data.allowUserDynamicSource"
[onlyUserDynamicSource]="isConstantKeyType"
[displayUserParameters]="data.displayUserParameters"
[valueType]="keyFilterFormGroup.get('valueType').value"
[key]="keyFilterFormGroup.get('key.key').value"
formControlName="predicates">
</tb-filter-predicate-list>
<section class="tb-form-panel">
<div class="flex flex-row items-center justify-between">
<div class="tb-form-panel-title">{{ 'filter.filters' | translate }}
</div>
</div>
<tb-filter-predicate-list
[allowUserDynamicSource]="data.allowUserDynamicSource"
[onlyUserDynamicSource]="isConstantKeyType"
[displayUserParameters]="data.displayUserParameters"
[valueType]="keyFilterFormGroup.get('valueType').value"
[key]="keyFilterFormGroup.get('key.key').value"
formControlName="predicates">
</tb-filter-predicate-list>
</section>
}
</fieldset>
</section>
</div>
<div mat-dialog-actions class="flex items-center justify-end">
<button mat-button color="primary"

148
ui-ngx/src/app/modules/home/components/filter/key-filter-list.component.html

@ -15,82 +15,86 @@
limitations under the License.
-->
<section class="flex flex-col" [formGroup]="keyFilterListFormGroup">
<mat-accordion [multi]="true">
<mat-expansion-panel [expanded]="true">
<mat-expansion-panel-header>
<mat-panel-title>
<div translate>filter.key-filters</div>
</mat-panel-title>
</mat-expansion-panel-header>
<div class="flex flex-row">
<span class="max-w-8% flex-full"></span>
<div class="flex max-w-92% flex-full flex-row items-center justify-start">
<label translate class="tb-title no-padding flex-1">filter.key-name</label>
<label translate class="tb-title no-padding flex-1">filter.key-type.key-type</label>
<span [class.!hidden]="disabled" style="min-width: 80px;">&nbsp;</span>
<span [class.!hidden]="!disabled" style="min-width: 40px;">&nbsp;</span>
<section class="tb-form-panel no-padding no-border">
<div class="tb-form-panel stroked">
<div class="flex flex-row items-center justify-between">
<div class="tb-form-panel-title" translate>filter.key-filters</div>
<ng-content select="[keyFilterHeaderActions]"></ng-content>
</div>
@if (keyFiltersFormArray.length) {
<section class="tb-form-panel no-padding no-gap stroked">
<div class="flex flex-row">
<span class="min-w-16"></span>
<div class="flex flex-1 flex-row items-center justify-start">
<div translate class="filter-title flex-1">filter.key-name</div>
<div translate class="filter-title flex-1">filter.key-type.key-type</div>
<span [class.!hidden]="disabled" style="min-width: 96px;">&nbsp;</span>
<span [class.!hidden]="!disabled" style="min-width: 48px;">&nbsp;</span>
</div>
</div>
</div>
<mat-divider></mat-divider>
<div class="key-filter-list">
@for (keyFilterControl of keyFiltersFormArray.controls; track keyFilterControl) {
<div class="flex flex-row items-stretch justify-start" style="max-height: 76px;"
formArrayName="keyFilters"
>
<div class="filters-operation max-w-8% flex-full">
@if ($index > 0) {
<span translate>filter.operation.and</span>
}
</div>
<div class="flex max-w-92% flex-full flex-col">
<div class="flex flex-row items-center justify-start">
<div class="flex-1">{{ keyFilterControl.value.key.key }}</div>
<div class="flex-1">{{ entityKeyTypeTranslations.get(keyFilterControl.value.key.type) | translate }}</div>
<button mat-icon-button color="primary"
type="button"
(click)="editKeyFilter($index)"
matTooltip="{{ (disabled ? 'filter.key-filter' : 'filter.edit-key-filter') | translate }}"
matTooltipPosition="above">
<mat-icon>{{disabled ? 'more_vert' : 'edit'}}</mat-icon>
</button>
<button mat-icon-button color="primary"
[class.!hidden]="disabled"
type="button"
(click)="removeKeyFilter($index)"
matTooltip="{{ 'filter.remove-key-filter' | translate }}"
matTooltipPosition="above">
<mat-icon>close</mat-icon>
</button>
<mat-divider></mat-divider>
<div class="filter-list">
@for (keyFilterControl of keyFiltersFormArray.controls; track keyFilterControl; let index = $index) {
<div class="flex flex-row items-stretch justify-start"
[class.filter-list-divider]="index">
<div class="filters-operation min-w-16">
@if (index) {
<div class="filters-operation-container">
<span class="filters-operation-label">{{ complexOperationTranslationMap.get(operation) | translate }}</span>
</div>
}
</div>
<div class="flex flex-1 flex-col">
<div class="flex flex-row items-center justify-start">
<div class="filters-text flex-1" tbTruncateWithTooltip>{{ keyFilterControl.value.key.key }}</div>
<div class="filters-text flex-1" tbTruncateWithTooltip>{{ entityKeyTypeTranslations.get(keyFilterControl.value.key.type) | translate }}</div>
<button mat-icon-button color="primary"
type="button"
(click)="editKeyFilter(index)"
matTooltip="{{ (disabled ? 'filter.key-filter' : 'filter.edit-key-filter') | translate }}"
matTooltipPosition="above">
<mat-icon>{{disabled ? 'visibility' : 'edit'}}</mat-icon>
</button>
<button mat-icon-button color="primary"
[class.!hidden]="disabled"
type="button"
(click)="removeKeyFilter(index)"
matTooltip="{{ 'filter.remove-key-filter' | translate }}"
matTooltipPosition="above">
<mat-icon>close</mat-icon>
</button>
</div>
</div>
<mat-divider></mat-divider>
</div>
</div>
}
<span [class.!hidden]="!!keyFiltersFormArray.length"
[class.disabled]="disabled"
class="no-data-found flex items-center justify-center" translate>filter.no-key-filters</span>
</div>
<div style="margin-top: 16px;">
<button mat-button mat-raised-button color="primary"
[class.!hidden]="disabled"
(click)="addKeyFilter()"
type="button"
matTooltip="{{ 'filter.add-key-filter' | translate }}"
matTooltipPosition="above">
{{ 'filter.add-key-filter' | translate }}
</button>
</div>
</mat-expansion-panel>
<mat-expansion-panel [expanded]="true">
}
</div>
</section>
} @else {
<span class="no-data-found tb-prompt flex items-center justify-center"
[class.required]="!disabled"
translate>filter.no-key-filters</span>
}
<div>
<button mat-button mat-stroked-button color="primary"
[class.!hidden]="disabled"
(click)="addKeyFilter()"
type="button">
{{ 'filter.add-key-filter' | translate }}
</button>
</div>
</div>
<div class="tb-form-panel no-gap stroked">
<mat-expansion-panel class="tb-settings" [expanded]="true">
<mat-expansion-panel-header>
<mat-panel-title>
<div translate>filter.preview</div>
</mat-panel-title>
{{ 'filter.preview' | translate }}
</mat-expansion-panel-header>
<div class="tb-filter-preview">
<tb-filter-text [formControl]="keyFiltersControl"></tb-filter-text>
</div>
<ng-template matExpansionPanelContent>
@if (keyFiltersFormArray.length) {
<tb-filter-text [formControl]="keyFiltersControl" [operation]="operation"></tb-filter-text>
} @else {
<span class="tb-prompt justify-center text-base" translate>filter.no-filter-text</span>
}
</ng-template>
</mat-expansion-panel>
</mat-accordion>
</div>
</section>

51
ui-ngx/src/app/modules/home/components/filter/key-filter-list.component.scss

@ -13,30 +13,45 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
@import "../scss/constants";
:host {
.key-filter-list {
.filter-title {
padding: 12px 0;
font-size: 14px;
font-weight: 500;
}
.no-data-found {
height: 50px;
font-size: 16px;
}
.filter-list {
overflow: auto;
max-height: 300px;
.no-data-found {
height: 50px;
&-divider {
border-top: 1px solid rgba(0, 0, 0, 0.12);
}
.filters-text {
font-size: 14px;
}
}
.filters-operation {
margin-top: -18px;
color: #666;
font-weight: 500;
}
.tb-filter-preview {
padding: 8px;
border: 1px groove rgba(0, 0, 0, .25);
border-radius: 4px;
}
}
:host ::ng-deep {
.tb-filter-preview {
.tb-filter-text {
max-height: 200px;
display: flex;
justify-content: center;
margin-top: -14px;
&-container {
background-color: white;
}
&-label {
font-size: 15px;
font-weight: 400;
color: $tb-primary-color;
padding: 0 8px;
border-radius: 4px;
border: 1px solid rgba($tb-primary-color, 0.32);
background-color: rgba($tb-primary-color, 0.04);
}
}
}

126
ui-ngx/src/app/modules/home/components/filter/key-filter-list.component.ts

@ -14,24 +14,26 @@
/// limitations under the License.
///
import { Component, forwardRef, Input, OnDestroy, OnInit } from '@angular/core';
import { Component, DestroyRef, forwardRef, Input, OnInit } from '@angular/core';
import {
AbstractControl,
ControlValueAccessor,
UntypedFormArray,
UntypedFormBuilder,
UntypedFormControl,
UntypedFormGroup,
FormArray,
FormBuilder,
FormControl,
NG_VALIDATORS,
NG_VALUE_ACCESSOR,
ValidationErrors,
Validator,
Validators
} from '@angular/forms';
import { Observable, Subject } from 'rxjs';
import { Observable } from 'rxjs';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import {
ComplexOperation,
complexOperationTranslationMap,
EntityKeyType,
entityKeyTypeTranslationMap,
KeyFilter,
KeyFilterInfo,
keyFilterInfosToKeyFilters
} from '@shared/models/query/query.models';
@ -39,27 +41,26 @@ import { MatDialog } from '@angular/material/dialog';
import { deepClone } from '@core/utils';
import { KeyFilterDialogComponent, KeyFilterDialogData } from '@home/components/filter/key-filter-dialog.component';
import { EntityId } from '@shared/models/id/entity-id';
import { takeUntil } from 'rxjs/operators';
@Component({
selector: 'tb-key-filter-list',
templateUrl: './key-filter-list.component.html',
styleUrls: ['./key-filter-list.component.scss'],
providers: [
{
provide: NG_VALUE_ACCESSOR,
useExisting: forwardRef(() => KeyFilterListComponent),
multi: true
},
{
provide: NG_VALIDATORS,
useExisting: forwardRef(() => KeyFilterListComponent),
multi: true
}
],
standalone: false
selector: 'tb-key-filter-list',
templateUrl: './key-filter-list.component.html',
styleUrls: ['./key-filter-list.component.scss'],
providers: [
{
provide: NG_VALUE_ACCESSOR,
useExisting: forwardRef(() => KeyFilterListComponent),
multi: true
},
{
provide: NG_VALIDATORS,
useExisting: forwardRef(() => KeyFilterListComponent),
multi: true
}
],
standalone: false
})
export class KeyFilterListComponent implements ControlValueAccessor, Validator, OnInit, OnDestroy {
export class KeyFilterListComponent implements ControlValueAccessor, Validator, OnInit {
@Input() disabled: boolean;
@ -71,39 +72,32 @@ export class KeyFilterListComponent implements ControlValueAccessor, Validator,
@Input() entityId: EntityId;
keyFilterListFormGroup: UntypedFormGroup;
@Input() operation: ComplexOperation = ComplexOperation.AND;
complexOperationTranslationMap = complexOperationTranslationMap;
entityKeyTypeTranslations = entityKeyTypeTranslationMap;
keyFiltersControl: UntypedFormControl;
keyFiltersFormArray: FormArray<FormControl<KeyFilterInfo>>;
keyFiltersControl: FormControl<Array<KeyFilter>>;
private destroy$ = new Subject<void>();
private propagateChange = null;
constructor(private fb: UntypedFormBuilder,
private dialog: MatDialog) {
constructor(private fb: FormBuilder,
private dialog: MatDialog,
private destroyRef: DestroyRef) {
}
ngOnInit(): void {
this.keyFilterListFormGroup = this.fb.group({
keyFilters: this.fb.array([])
});
this.keyFiltersFormArray = this.fb.array<FormControl<KeyFilterInfo>>([]);
this.keyFiltersControl = this.fb.control(null);
this.keyFilterListFormGroup.valueChanges.pipe(
takeUntil(this.destroy$)
this.keyFiltersFormArray.valueChanges.pipe(
takeUntilDestroyed(this.destroyRef)
).subscribe(() => this.updateModel());
}
ngOnDestroy() {
this.destroy$.next();
this.destroy$.complete();
}
get keyFiltersFormArray(): UntypedFormArray {
return this.keyFilterListFormGroup.get('keyFilters') as UntypedFormArray;
}
registerOnChange(fn: any): void {
this.propagateChange = fn;
}
@ -114,61 +108,61 @@ export class KeyFilterListComponent implements ControlValueAccessor, Validator,
setDisabledState?(isDisabled: boolean): void {
this.disabled = isDisabled;
if (this.disabled) {
this.keyFilterListFormGroup.disable({emitEvent: false});
this.keyFiltersFormArray.disable({emitEvent: false});
this.keyFiltersControl.disable({emitEvent: false});
} else {
this.keyFilterListFormGroup.enable({emitEvent: false});
this.keyFiltersFormArray.enable({emitEvent: false});
this.keyFiltersControl.enable({emitEvent: false});
}
}
validate(): ValidationErrors | null {
return this.keyFilterListFormGroup.valid && this.keyFiltersControl.valid ? null : {
return this.keyFiltersFormArray.valid && this.keyFiltersControl.valid ? null : {
keyFilterList: {valid: false}
};
}
writeValue(keyFilters: Array<KeyFilterInfo>): void {
if (keyFilters.length === this.keyFiltersFormArray.length) {
if (keyFilters?.length === this.keyFiltersFormArray.length) {
this.keyFiltersFormArray.patchValue(keyFilters, {emitEvent: false});
} else {
const keyFilterControls: Array<AbstractControl> = [];
this.keyFiltersFormArray.clear({emitEvent: false});
if (keyFilters) {
for (const keyFilter of keyFilters) {
keyFilterControls.push(this.fb.control(keyFilter, [Validators.required]));
this.keyFiltersFormArray.push(
this.fb.control(keyFilter, [Validators.required]),
{emitEvent: false}
);
}
}
this.keyFilterListFormGroup.setControl('keyFilters', this.fb.array(keyFilterControls), {emitEvent: false});
if (this.disabled) {
this.keyFilterListFormGroup.disable({emitEvent: false});
this.keyFiltersFormArray.disable({emitEvent: false});
} else {
this.keyFilterListFormGroup.enable({emitEvent: false});
this.keyFiltersFormArray.enable({emitEvent: false});
}
}
const keyFiltersArray = keyFilterInfosToKeyFilters(keyFilters);
this.keyFiltersControl.patchValue(keyFiltersArray, {emitEvent: false});
}
public removeKeyFilter(index: number) {
(this.keyFilterListFormGroup.get('keyFilters') as UntypedFormArray).removeAt(index);
removeKeyFilter(index: number) {
this.keyFiltersFormArray.removeAt(index);
}
public addKeyFilter() {
const keyFiltersFormArray = this.keyFilterListFormGroup.get('keyFilters') as UntypedFormArray;
addKeyFilter() {
this.openKeyFilterDialog(null).subscribe((result) => {
if (result) {
keyFiltersFormArray.push(this.fb.control(result, [Validators.required]));
this.keyFiltersFormArray.push(this.fb.control(result, [Validators.required]));
}
});
}
public editKeyFilter(index: number) {
const keyFilter: KeyFilterInfo =
(this.keyFilterListFormGroup.get('keyFilters') as UntypedFormArray).at(index).value;
editKeyFilter(index: number) {
const keyFilter = this.keyFiltersFormArray.at(index).value;
this.openKeyFilterDialog(keyFilter).subscribe(
(result) => {
if (result) {
(this.keyFilterListFormGroup.get('keyFilters') as UntypedFormArray).at(index).patchValue(result);
this.keyFiltersFormArray.at(index).patchValue(result);
}
}
);
@ -204,12 +198,8 @@ export class KeyFilterListComponent implements ControlValueAccessor, Validator,
}
private updateModel() {
const keyFilters: Array<KeyFilterInfo> = this.keyFilterListFormGroup.getRawValue().keyFilters;
if (keyFilters.length) {
this.propagateChange(keyFilters);
} else {
this.propagateChange(null);
}
const keyFilters = this.keyFiltersFormArray.getRawValue();
this.propagateChange(keyFilters.length ? keyFilters : null);
const keyFiltersArray = keyFilterInfosToKeyFilters(keyFilters);
this.keyFiltersControl.patchValue(keyFiltersArray, {emitEvent: false});
}

81
ui-ngx/src/app/modules/home/components/filter/user-filter-dialog.component.html

@ -15,70 +15,61 @@
limitations under the License.
-->
<form [formGroup]="userFilterFormGroup" (ngSubmit)="save()" style="width: 400px;" class="user-filter-dialog">
<mat-toolbar color="primary">
<form (ngSubmit)="save()" style="width: 400px;" class="user-filter-dialog">
<mat-toolbar color="primary" class="justify-between">
<h2>{{ filter.filter | customTranslate }}</h2>
<span class="flex-1"></span>
<button mat-icon-button
(click)="cancel()"
type="button">
<mat-icon class="material-icons">close</mat-icon>
</button>
</mat-toolbar>
@if (isLoading$ | async) {
<mat-progress-bar color="warn" mode="indeterminate">
</mat-progress-bar>
}
<div mat-dialog-content>
<fieldset [disabled]="isLoading$ | async">
@for (userInputControl of userInputsFormArray().controls; track userInputControl) {
<div formArrayName="userInputs"
>
<div>
@switch (userInputControl.get('valueType').value) {
@case (valueTypeEnum.STRING) {
<mat-form-field class="mat-block" appearance="outline">
<mat-label>{{ userInputControl.get('label').value | customTranslate }}</mat-label>
<input matInput [formControl]="userInputControl.get('value')">
</mat-form-field>
}
@case (valueTypeEnum.NUMERIC) {
<mat-form-field class="mat-block" appearance="outline">
<mat-label>{{ userInputControl.get('label').value | customTranslate }}</mat-label>
<input required type="number" matInput [formControl]="userInputControl.get('value')">
<div matSuffix class="mr-2"
[class.!hidden]="!userInputControl.get('unitSymbol').value">
{{ userInputControl.get('unitSymbol').value }}
</div>
</mat-form-field>
}
@case (valueTypeEnum.DATE_TIME) {
<label class="tb-title no-padding tb-required">{{ userInputControl.get('label').value | customTranslate }}</label>
<tb-datetime [formControl]="userInputControl.get('value')"
dateText="{{ 'filter.date' | translate }}"
required [showLabel]="false"></tb-datetime>
}
@case (valueTypeEnum.BOOLEAN) {
<mat-checkbox labelPosition="before" [formControl]="userInputControl.get('value')">
{{ userInputControl.get('label').value | customTranslate }}
</mat-checkbox>
}
@for (userInputControl of userInputsFormArray.controls; track userInputControl) {
@switch (userInputControl.controls.valueType.value) {
@case (valueTypeEnum.STRING) {
<mat-form-field class="mat-block" appearance="outline">
<mat-label>{{ userInputControl.controls.label.value | customTranslate }}</mat-label>
<input matInput [formControl]="userInputControl.controls.value">
</mat-form-field>
}
@case (valueTypeEnum.NUMERIC) {
<mat-form-field class="mat-block numeric-field" appearance="outline">
<mat-label>{{ userInputControl.controls.label.value | customTranslate }}</mat-label>
<input required type="number" matInput [formControl]="userInputControl.controls.value">
@if (userInputControl.controls.unitSymbol.value) {
<div matSuffix class="mr-2">
{{ userInputControl.controls.unitSymbol.value }}
</div>
}
</div>
</div>
</mat-form-field>
}
@case (valueTypeEnum.DATE_TIME) {
<tb-datetime [formControl]="userInputControl.controls.value"
[fieldClass]="'mat-block'"
[dateText]="userInputControl.controls.label.value | customTranslate"
appearance="outline"
required></tb-datetime>
}
@case (valueTypeEnum.BOOLEAN) {
<mat-slide-toggle [formControl]="userInputControl.controls.value">
<span class="inline-block max-w-72" tbTruncateWithTooltip>
{{ userInputControl.controls.label.value | customTranslate }}
</span>
</mat-slide-toggle>
}
}
</fieldset>
}
</div>
<div mat-dialog-actions class="flex items-center justify-end">
<button mat-button color="primary"
type="button"
[disabled]="(isLoading$ | async)"
(click)="cancel()" cdkFocusInitial>
{{ 'action.cancel' | translate }}
</button>
<button mat-raised-button color="primary"
type="submit"
[disabled]="(isLoading$ | async) || userFilterFormGroup.invalid || !userFilterFormGroup.dirty">
[disabled]="userInputsFormArray.invalid || !userInputsFormArray.dirty">
{{ 'action.update' | translate }}
</button>
</div>

6
ui-ngx/src/app/modules/home/components/filter/user-filter-dialog.component.scss

@ -14,14 +14,14 @@
* limitations under the License.
*/
:host ::ng-deep {
.user-filter-dialog {
.mat-mdc-form-field.mat-mdc-form-field-has-icon-suffix.mat-form-field-hide-placeholder {
.numeric-field {
&.mat-mdc-form-field-has-icon-suffix.mat-form-field-hide-placeholder {
.mat-mdc-form-field-icon-suffix {
place-self: center;
}
}
.mat-mdc-form-field.mat-mdc-form-field-has-icon-suffix {
&.mat-mdc-form-field-has-icon-suffix {
.mat-mdc-form-field-icon-suffix {
place-self: baseline;
}

68
ui-ngx/src/app/modules/home/components/filter/user-filter-dialog.component.ts

@ -14,12 +14,11 @@
/// limitations under the License.
///
import { Component, DestroyRef, Inject, SkipSelf } from '@angular/core';
import { ErrorStateMatcher } from '@angular/material/core';
import { Component, DestroyRef, Inject } from '@angular/core';
import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog';
import { Store } from '@ngrx/store';
import { AppState } from '@core/core.state';
import { FormArray, FormBuilder, FormControl, FormGroup, FormGroupDirective, NgForm, Validators } from '@angular/forms';
import { FormArray, FormBuilder, FormControl, FormGroup, Validators } from '@angular/forms';
import { Router } from '@angular/router';
import { DialogComponent } from '@app/shared/components/dialog.component';
import { TranslateService } from '@ngx-translate/core';
@ -39,29 +38,31 @@ export interface UserFilterDialogData {
filter: Filter;
}
interface UserInputForm {
label: FormControl<string>;
valueType: FormControl<EntityKeyValueType>;
unitSymbol: FormControl<string>;
value: FormControl<string | number | boolean>;
}
@Component({
selector: 'tb-user-filter-dialog',
templateUrl: './user-filter-dialog.component.html',
providers: [{ provide: ErrorStateMatcher, useExisting: UserFilterDialogComponent }],
styleUrls: ['./user-filter-dialog.component.scss'],
standalone: false
})
export class UserFilterDialogComponent extends DialogComponent<UserFilterDialogComponent, Filter>
implements ErrorStateMatcher {
export class UserFilterDialogComponent extends DialogComponent<UserFilterDialogComponent, Filter> {
filter: Filter;
userFilterFormGroup: FormGroup;
userInputsFormArray: FormArray<FormGroup<UserInputForm>>;
valueTypeEnum = EntityKeyValueType;
submitted = false;
constructor(protected store: Store<AppState>,
protected router: Router,
@Inject(MAT_DIALOG_DATA) public data: UserFilterDialogData,
@SkipSelf() private errorStateMatcher: ErrorStateMatcher,
public dialogRef: MatDialogRef<UserFilterDialogComponent, Filter>,
protected dialogRef: MatDialogRef<UserFilterDialogComponent, Filter>,
private fb: FormBuilder,
private translate: TranslateService,
private destroyRef: DestroyRef,
@ -70,19 +71,13 @@ export class UserFilterDialogComponent extends DialogComponent<UserFilterDialogC
this.filter = data.filter;
const userInputs = filterToUserFilterInfoList(this.filter, this.translate);
const userInputControls: Array<FormGroup> = [];
for (const userInput of userInputs) {
userInputControls.push(this.createUserInputFormControl(userInput));
}
this.userFilterFormGroup = this.fb.group({
userInputs: this.fb.array(userInputControls)
});
const userInputControls = userInputs.map(input => this.createUserInputFormControl(input));
this.userInputsFormArray = this.fb.array(userInputControls);
}
private createUserInputFormControl(userInput: UserFilterInputInfo): FormGroup {
private createUserInputFormControl(userInput: UserFilterInputInfo): FormGroup<UserInputForm> {
const predicateValue: FilterPredicateValue<string | number | boolean> = (userInput.info.keyFilterPredicate as any).value;
let value = isDefinedAndNotNull(predicateValue.userValue) ? predicateValue.userValue : predicateValue.defaultValue;
let value: string | number | boolean = isDefinedAndNotNull(predicateValue.userValue) ? predicateValue.userValue : predicateValue.defaultValue;
let unitSymbol = '';
let valueConvertor: TbUnitConverter;
if (userInput.valueType === EntityKeyValueType.NUMERIC) {
@ -91,42 +86,31 @@ export class UserFilterDialogComponent extends DialogComponent<UserFilterDialogC
value = this.unitService.convertUnitValue(value as number, userInput.unit);
valueConvertor = this.unitService.geUnitConverter(unitSymbol, sourceUnit);
}
const userInputControl = this.fb.group({
label: [userInput.label],
valueType: [userInput.valueType],
unitSymbol: [unitSymbol],
value: [value,
const userInputControl = this.fb.group<UserInputForm>({
label: this.fb.control(userInput.label),
valueType: this.fb.control(userInput.valueType),
unitSymbol: this.fb.control(unitSymbol),
value: this.fb.control(value,
userInput.valueType === EntityKeyValueType.NUMERIC ||
userInput.valueType === EntityKeyValueType.DATE_TIME ? [Validators.required] : []]
userInput.valueType === EntityKeyValueType.DATE_TIME ? [Validators.required] : [])
});
userInputControl.get('value').valueChanges.pipe(
userInputControl.controls.value.valueChanges.pipe(
takeUntilDestroyed(this.destroyRef)
).subscribe(userValue => {
let value = userValue;
let val = userValue;
if (valueConvertor) {
value = valueConvertor(value as number);
val = valueConvertor(val as number);
}
(userInput.info.keyFilterPredicate as any).value.userValue = value;
(userInput.info.keyFilterPredicate as any).value.userValue = val;
});
return userInputControl;
}
userInputsFormArray(): FormArray {
return this.userFilterFormGroup.get('userInputs') as FormArray;
}
isErrorState(control: FormControl | null, form: FormGroupDirective | NgForm | null): boolean {
const originalErrorState = this.errorStateMatcher.isErrorState(control, form);
const customErrorState = !!(control && control.invalid && this.submitted);
return originalErrorState || customErrorState;
}
cancel(): void {
this.dialogRef.close(null);
}
save(): void {
this.submitted = true;
this.dialogRef.close(this.filter);
}
}

2
ui-ngx/src/app/shared/components/time/datetime.component.ts

@ -40,7 +40,7 @@ export class DatetimeComponent implements OnInit, ControlValueAccessor {
return this.requiredValue;
}
@Input()
set required(value: boolean) {
set required(value: unknown) {
this.requiredValue = coerceBooleanProperty(value);
}

18
ui-ngx/src/app/shared/models/query/query.models.ts

@ -401,20 +401,21 @@ export interface FilterInfo {
filter: string;
editable: boolean;
keyFilters: Array<KeyFilterInfo>;
keyFiltersOperation?: ComplexOperation;
}
export interface FiltersInfo {
datasourceFilters: {[datasourceIndex: number]: FilterInfo};
}
export function keyFiltersToText(translate: TranslateService, datePipe: DatePipe, keyFilters: Array<KeyFilter>): string {
export function keyFiltersToText(translate: TranslateService, datePipe: DatePipe, keyFilters: Array<KeyFilter>,
operation: ComplexOperation = ComplexOperation.AND): string {
const filtersText = keyFilters.map(keyFilter =>
keyFilterToText(translate, datePipe, keyFilter,
keyFilters.length > 1 ? ComplexOperation.AND : undefined));
keyFilterToText(translate, datePipe, keyFilter, operation));
let result: string;
if (filtersText.length > 1) {
const andText = translate.instant('filter.operation.and');
result = filtersText.join(' <span class="tb-filter-complex-operation">' + andText + '</span> ');
const opText = translate.instant(complexOperationTranslationMap.get(operation));
result = filtersText.join(' <span class="tb-filter-complex-operation">' + opText + '</span> ');
} else {
result = filtersText[0];
}
@ -802,6 +803,7 @@ export const singleEntityFilterFromDeviceId = (deviceId: string): EntityFilter =
export interface EntityCountQuery {
entityFilter: EntityFilter;
keyFilters?: Array<KeyFilter>;
keyFiltersOperation?: ComplexOperation;
}
export interface AbstractDataQuery<T extends EntityDataPageLink> extends EntityCountQuery {
@ -940,13 +942,15 @@ export const getFilterId = (filters: Filters, filterInfo: FilterInfo): string =>
const newFilterName = createFilterName(filters, filterInfo.filter);
newFilterId = guid();
filters[newFilterId] = {id: newFilterId, filter: newFilterName,
keyFilters: filterInfo.keyFilters, editable: filterInfo.editable};
keyFilters: filterInfo.keyFilters, editable: filterInfo.editable,
keyFiltersOperation: filterInfo.keyFiltersOperation};
}
return newFilterId;
}
const isFilterEqual = (filter1: FilterInfo, filter2: FilterInfo): boolean => {
return isEqual(filter1.keyFilters, filter2.keyFilters);
return isEqual(filter1.keyFilters, filter2.keyFilters) &&
filter1.keyFiltersOperation === filter2.keyFiltersOperation;
}
const createFilterName = (filters: Filters, filter: string): string => {

2
ui-ngx/src/app/shared/models/widget.models.ts

@ -24,6 +24,7 @@ import { EntityId } from '@shared/models/id/entity-id';
import {
AlarmFilter,
AlarmFilterConfig,
ComplexOperation,
EntityDataPageLink,
EntityFilter,
KeyFilter
@ -450,6 +451,7 @@ export interface Datasource {
origDatasourceIndex?: number;
pageLink?: EntityDataPageLink;
keyFilters?: Array<KeyFilter>;
keyFiltersOperation?: ComplexOperation;
entityFilter?: EntityFilter;
alarmFilterConfig?: AlarmFilterConfig;
alarmFilter?: AlarmFilter;

1
ui-ngx/src/assets/locale/locale.constant-en_US.json

@ -3610,6 +3610,7 @@
"order-priority": "Display order",
"key-filter": "Key filter",
"key-filters": "Key filters",
"or-conditions-disabled": "OR conditions between key filters are disabled by the system administrator.",
"key-name": "Key name",
"key-name-required": "Key name is required.",
"key-type": {

Loading…
Cancel
Save