Browse Source

Merge pull request #15506 from thingsboard/rc

Merge rc into master
pull/15098/merge
Viacheslav Klimov 1 month ago
committed by GitHub
parent
commit
492d79fa98
No known key found for this signature in database GPG Key ID: B5690EEEBB952194
  1. 4
      application/src/test/java/org/thingsboard/server/client/Oauth2ApiClientTest.java
  2. 497
      application/src/test/java/org/thingsboard/server/controller/DeviceProfileControllerTest.java
  3. 8
      application/src/test/java/org/thingsboard/server/edge/UserEdgeTest.java
  4. 5
      common/edqs/src/main/java/org/thingsboard/server/edqs/data/dp/StringDataPoint.java
  5. 47
      dao/src/test/java/org/thingsboard/server/dao/TbTimescaleDBContainerProvider.java
  6. 1
      dao/src/test/resources/META-INF/services/org.testcontainers.containers.JdbcDatabaseContainerProvider
  7. 2
      dao/src/test/resources/nosql-test.properties
  8. 2
      dao/src/test/resources/sql-test.properties
  9. 2
      dao/src/test/resources/timescale-test.properties
  10. 57
      edqs/src/test/java/org/thingsboard/server/edqs/repo/DeviceTypeFilterTest.java
  11. 9
      monitoring/pom.xml
  12. 10
      monitoring/src/main/java/org/thingsboard/monitoring/config/transport/TransportInfo.java
  13. 3
      monitoring/src/main/java/org/thingsboard/monitoring/data/MonitoredServiceKey.java
  14. 34
      monitoring/src/main/java/org/thingsboard/monitoring/data/notification/AffectedService.java
  15. 9
      monitoring/src/main/java/org/thingsboard/monitoring/data/notification/HighLatencyNotification.java
  16. 5
      monitoring/src/main/java/org/thingsboard/monitoring/data/notification/InfoNotification.java
  17. 10
      monitoring/src/main/java/org/thingsboard/monitoring/data/notification/Notification.java
  18. 62
      monitoring/src/main/java/org/thingsboard/monitoring/data/notification/ServiceFailureNotification.java
  19. 7
      monitoring/src/main/java/org/thingsboard/monitoring/data/notification/ServiceRecoveryNotification.java
  20. 22
      monitoring/src/main/java/org/thingsboard/monitoring/data/notification/ShortNameProvider.java
  21. 2
      monitoring/src/main/java/org/thingsboard/monitoring/notification/NotificationService.java
  22. 4
      monitoring/src/main/java/org/thingsboard/monitoring/notification/channels/NotificationChannel.java
  23. 125
      monitoring/src/main/java/org/thingsboard/monitoring/notification/channels/impl/SlackApiClient.java
  24. 45
      monitoring/src/main/java/org/thingsboard/monitoring/notification/channels/impl/SlackIncidentTransport.java
  25. 69
      monitoring/src/main/java/org/thingsboard/monitoring/notification/channels/impl/SlackNotificationChannel.java
  26. 292
      monitoring/src/main/java/org/thingsboard/monitoring/notification/incident/IncidentManager.java
  27. 26
      monitoring/src/main/java/org/thingsboard/monitoring/notification/incident/IncidentTransport.java
  28. 3
      monitoring/src/main/java/org/thingsboard/monitoring/service/BaseHealthChecker.java
  29. 54
      monitoring/src/main/java/org/thingsboard/monitoring/service/BaseMonitoringService.java
  30. 5
      monitoring/src/main/java/org/thingsboard/monitoring/service/MonitoringReporter.java
  31. 13
      monitoring/src/main/resources/tb-monitoring.yml
  32. 97
      monitoring/src/test/java/org/thingsboard/monitoring/data/notification/ServiceFailureNotificationTest.java
  33. 183
      monitoring/src/test/java/org/thingsboard/monitoring/notification/incident/IncidentManagerTest.java
  34. 6
      pom.xml

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));

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);

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);

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:<tag>:///... URL prefix
* registered via META-INF/services.
*/
public class TbTimescaleDBContainerProvider extends TimescaleDBContainerProvider {
private static final String NAME = "tbtimescaledb";
@Override
public boolean supports(String databaseType) {
return NAME.equals(databaseType);
}
@Override
public JdbcDatabaseContainer newInstance(String tag) {
JdbcDatabaseContainer container = super.newInstance(tag);
container.withEnv("NO_TS_TUNE", "true");
return container;
}
}

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);
}
}

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)));
}
}

22
monitoring/src/main/java/org/thingsboard/monitoring/data/notification/ShortNameProvider.java

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

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>

Loading…
Cancel
Save