Browse Source

merge with master

pull/9284/head
ShvaykaD 3 years ago
parent
commit
c835baffd2
  1. 1
      application/src/main/data/json/system/widget_bundles/charts.json
  2. 2
      application/src/main/data/json/system/widget_types/doughnut.json
  3. 2
      application/src/main/data/json/system/widget_types/horizontal_doughnut.json
  4. 31
      application/src/main/data/json/system/widget_types/range_chart.json
  5. 2
      application/src/main/java/org/thingsboard/server/install/ThingsboardInstallService.java
  6. 9
      application/src/main/java/org/thingsboard/server/service/transport/DefaultTransportApiService.java
  7. 5
      application/src/main/resources/thingsboard.yml
  8. 44
      application/src/test/java/org/thingsboard/server/controller/EdgeControllerTest.java
  9. 4
      application/src/test/java/org/thingsboard/server/service/notification/NotificationApiTest.java
  10. 32
      application/src/test/java/org/thingsboard/server/transport/coap/CoapTestClient.java
  11. 309
      application/src/test/java/org/thingsboard/server/transport/coap/client/CoapClientIntegrationTest.java
  12. 25
      common/cache/src/main/java/org/thingsboard/server/cache/TbTransactionalCache.java
  13. 6
      common/cluster-api/src/main/proto/queue.proto
  14. 4
      common/dao-api/src/main/java/org/thingsboard/server/dao/asset/AssetProfileService.java
  15. 4
      common/dao-api/src/main/java/org/thingsboard/server/dao/device/DeviceProfileService.java
  16. 2
      common/dao-api/src/main/java/org/thingsboard/server/dao/entityview/EntityViewService.java
  17. 1
      common/data/src/main/java/org/thingsboard/server/common/data/DataConstants.java
  18. 1
      common/transport/coap/src/main/java/org/thingsboard/server/transport/coap/callback/AbstractSyncSessionCallback.java
  19. 4
      common/transport/coap/src/main/java/org/thingsboard/server/transport/coap/callback/CoapOkCallback.java
  20. 22
      common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/session/AbstractGatewaySessionHandler.java
  21. 2
      common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/TransportService.java
  22. 74
      common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/limits/DefaultEntityLimitsCache.java
  23. 27
      common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/limits/EntityLimitKey.java
  24. 24
      common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/limits/EntityLimitsCache.java
  25. 48
      common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/service/DefaultTransportService.java
  26. 24
      dao/src/main/java/org/thingsboard/server/dao/asset/AssetProfileServiceImpl.java
  27. 24
      dao/src/main/java/org/thingsboard/server/dao/device/DeviceProfileServiceImpl.java
  28. 2
      dao/src/main/java/org/thingsboard/server/dao/device/DeviceServiceImpl.java
  29. 9
      dao/src/main/java/org/thingsboard/server/dao/entityview/EntityViewServiceImpl.java
  30. 35
      dao/src/main/java/org/thingsboard/server/dao/exception/EntitiesLimitException.java
  31. 3
      dao/src/main/java/org/thingsboard/server/dao/rule/BaseRuleChainService.java
  32. 3
      dao/src/main/java/org/thingsboard/server/dao/service/DataValidator.java
  33. 9
      dao/src/main/java/org/thingsboard/server/dao/service/validator/RuleChainDataValidator.java
  34. 39
      dao/src/test/java/org/thingsboard/server/dao/service/AssetServiceTest.java
  35. 29
      dao/src/test/java/org/thingsboard/server/dao/service/DeviceServiceTest.java
  36. 6
      msa/black-box-tests/src/test/java/org/thingsboard/server/msa/ThingsBoardDbInstaller.java
  37. 9
      msa/black-box-tests/src/test/java/org/thingsboard/server/msa/connectivity/MqttClientTest.java
  38. 4
      transport/coap/src/main/resources/tb-coap-transport.yml
  39. 4
      transport/http/src/main/resources/tb-http-transport.yml
  40. 4
      transport/lwm2m/src/main/resources/tb-lwm2m-transport.yml
  41. 4
      transport/mqtt/src/main/resources/tb-mqtt-transport.yml
  42. 4
      transport/snmp/src/main/resources/tb-snmp-transport.yml
  43. 12
      ui-ngx/src/app/modules/home/components/widget/config/basic/basic-widget-config.module.ts
  44. 1
      ui-ngx/src/app/modules/home/components/widget/config/basic/cards/value-card-basic-config.component.html
  45. 1
      ui-ngx/src/app/modules/home/components/widget/config/basic/chart/doughnut-basic-config.component.html
  46. 222
      ui-ngx/src/app/modules/home/components/widget/config/basic/chart/range-chart-basic-config.component.html
  47. 269
      ui-ngx/src/app/modules/home/components/widget/config/basic/chart/range-chart-basic-config.component.ts
  48. 34
      ui-ngx/src/app/modules/home/components/widget/lib/chart/range-chart-widget.component.html
  49. 105
      ui-ngx/src/app/modules/home/components/widget/lib/chart/range-chart-widget.component.scss
  50. 527
      ui-ngx/src/app/modules/home/components/widget/lib/chart/range-chart-widget.component.ts
  51. 103
      ui-ngx/src/app/modules/home/components/widget/lib/chart/range-chart-widget.models.ts
  52. 140
      ui-ngx/src/app/modules/home/components/widget/lib/settings/chart/range-chart-widget-settings.component.html
  53. 147
      ui-ngx/src/app/modules/home/components/widget/lib/settings/chart/range-chart-widget-settings.component.ts
  54. 4
      ui-ngx/src/app/modules/home/components/widget/lib/settings/common/color-range-settings.component.ts
  55. 15
      ui-ngx/src/app/modules/home/components/widget/lib/settings/common/date-format-select.component.ts
  56. 12
      ui-ngx/src/app/modules/home/components/widget/lib/settings/widget-settings.module.ts
  57. 7
      ui-ngx/src/app/modules/home/components/widget/widget-components.module.ts
  58. 2
      ui-ngx/src/app/shared/components/entity/entity-subtype-list.component.ts
  59. 77
      ui-ngx/src/app/shared/models/widget-settings.models.ts
  60. 2
      ui-ngx/src/app/shared/models/widget.models.ts
  61. 14
      ui-ngx/src/assets/locale/locale.constant-en_US.json

1
application/src/main/data/json/system/widget_bundles/charts.json

@ -11,6 +11,7 @@
"widgetTypeFqns": [ "widgetTypeFqns": [
"charts.basic_timeseries", "charts.basic_timeseries",
"charts.state_chart", "charts.state_chart",
"range_chart",
"charts.timeseries_bars_flot", "charts.timeseries_bars_flot",
"cards.aggregated_value_card", "cards.aggregated_value_card",
"charts.bars", "charts.bars",

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

File diff suppressed because one or more lines are too long

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

File diff suppressed because one or more lines are too long

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

File diff suppressed because one or more lines are too long

2
application/src/main/java/org/thingsboard/server/install/ThingsboardInstallService.java

@ -266,6 +266,8 @@ public class ThingsboardInstallService {
log.info("Upgrading ThingsBoard from version 3.6.0 to 3.6.1 ..."); log.info("Upgrading ThingsBoard from version 3.6.0 to 3.6.1 ...");
databaseEntitiesUpgradeService.upgradeDatabase("3.6.0"); databaseEntitiesUpgradeService.upgradeDatabase("3.6.0");
dataUpdateService.updateData("3.6.0"); dataUpdateService.updateData("3.6.0");
case "3.6.1":
log.info("Upgrading ThingsBoard from version 3.6.1 to 3.6.2 ...");
//TODO DON'T FORGET to update switch statement in the CacheCleanupService if you need to clear the cache //TODO DON'T FORGET to update switch statement in the CacheCleanupService if you need to clear the cache
break; break;
default: default:

9
application/src/main/java/org/thingsboard/server/service/transport/DefaultTransportApiService.java

@ -78,6 +78,7 @@ import org.thingsboard.server.dao.device.provision.ProvisionFailedException;
import org.thingsboard.server.dao.device.provision.ProvisionRequest; import org.thingsboard.server.dao.device.provision.ProvisionRequest;
import org.thingsboard.server.dao.device.provision.ProvisionResponse; import org.thingsboard.server.dao.device.provision.ProvisionResponse;
import org.thingsboard.server.dao.device.provision.ProvisionResponseStatus; import org.thingsboard.server.dao.device.provision.ProvisionResponseStatus;
import org.thingsboard.server.dao.exception.EntitiesLimitException;
import org.thingsboard.server.dao.ota.OtaPackageService; import org.thingsboard.server.dao.ota.OtaPackageService;
import org.thingsboard.server.dao.queue.QueueService; import org.thingsboard.server.dao.queue.QueueService;
import org.thingsboard.server.dao.relation.RelationService; import org.thingsboard.server.dao.relation.RelationService;
@ -164,6 +165,7 @@ public class DefaultTransportApiService implements TransportApiService {
public void init() { public void init() {
handlerExecutor = MoreExecutors.listeningDecorator(ThingsBoardExecutors.newWorkStealingPool(maxCoreHandlerThreads, "transport-api-service-core-handler")); handlerExecutor = MoreExecutors.listeningDecorator(ThingsBoardExecutors.newWorkStealingPool(maxCoreHandlerThreads, "transport-api-service-core-handler"));
} }
@PreDestroy @PreDestroy
public void destroy() { public void destroy() {
if (handlerExecutor != null) { if (handlerExecutor != null) {
@ -400,6 +402,13 @@ public class DefaultTransportApiService implements TransportApiService {
} catch (JsonProcessingException e) { } catch (JsonProcessingException e) {
log.warn("[{}] Failed to lookup device by gateway id and name: [{}]", gatewayId, requestMsg.getDeviceName(), e); log.warn("[{}] Failed to lookup device by gateway id and name: [{}]", gatewayId, requestMsg.getDeviceName(), e);
throw new RuntimeException(e); throw new RuntimeException(e);
} catch (EntitiesLimitException e) {
log.warn("[{}][{}] API limit exception: [{}]", e.getTenantId(), gatewayId, e.getMessage());
return TransportApiResponseMsg.newBuilder()
.setGetOrCreateDeviceResponseMsg(
GetOrCreateDeviceFromGatewayResponseMsg.newBuilder()
.setError(TransportProtos.TransportApiRequestErrorCode.ENTITY_LIMIT))
.build();
} finally { } finally {
deviceCreationLock.unlock(); deviceCreationLock.unlock();
} }

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

@ -583,6 +583,9 @@ cache:
rateLimits: rateLimits:
timeToLiveInMinutes: "${CACHE_SPECS_RATE_LIMITS_TTL:120}" # Rate limits cache TTL timeToLiveInMinutes: "${CACHE_SPECS_RATE_LIMITS_TTL:120}" # Rate limits cache TTL
maxSize: "${CACHE_SPECS_RATE_LIMITS_MAX_SIZE:200000}" # 0 means the cache is disabled maxSize: "${CACHE_SPECS_RATE_LIMITS_MAX_SIZE:200000}" # 0 means the cache is disabled
entityLimits:
timeToLiveInMinutes: "${CACHE_SPECS_ENTITY_LIMITS_TTL:5}" # Entity limits cache TTL
maxSize: "${CACHE_SPECS_ENTITY_LIMITS_MAX_SIZE:100000}" # 0 means the cache is disabled
# Spring data parameters # Spring data parameters
spring.data.redis.repositories.enabled: false # Disable this because it is not required. spring.data.redis.repositories.enabled: false # Disable this because it is not required.
@ -875,7 +878,7 @@ transport:
ip_limits_enabled: "${TB_TRANSPORT_IP_RATE_LIMITS_ENABLED:false}" ip_limits_enabled: "${TB_TRANSPORT_IP_RATE_LIMITS_ENABLED:false}"
# Maximum number of connect attempts with invalid credentials # Maximum number of connect attempts with invalid credentials
max_wrong_credentials_per_ip: "${TB_TRANSPORT_MAX_WRONG_CREDENTIALS_PER_IP:10}" max_wrong_credentials_per_ip: "${TB_TRANSPORT_MAX_WRONG_CREDENTIALS_PER_IP:10}"
# Timeout to expire block IP addresses # Timeout (in milliseconds) to expire block IP addresses
ip_block_timeout: "${TB_TRANSPORT_IP_BLOCK_TIMEOUT:60000}" ip_block_timeout: "${TB_TRANSPORT_IP_BLOCK_TIMEOUT:60000}"
# Local HTTP transport parameters # Local HTTP transport parameters
http: http:

44
application/src/test/java/org/thingsboard/server/controller/EdgeControllerTest.java

@ -23,6 +23,8 @@ import com.google.common.util.concurrent.ListenableFuture;
import com.google.common.util.concurrent.ListeningExecutorService; import com.google.common.util.concurrent.ListeningExecutorService;
import com.google.common.util.concurrent.MoreExecutors; import com.google.common.util.concurrent.MoreExecutors;
import com.google.protobuf.AbstractMessage; import com.google.protobuf.AbstractMessage;
import lombok.extern.slf4j.Slf4j;
import org.awaitility.Awaitility;
import org.junit.After; import org.junit.After;
import org.junit.Assert; import org.junit.Assert;
import org.junit.Before; import org.junit.Before;
@ -78,10 +80,11 @@ import org.thingsboard.server.gen.edge.v1.UserUpdateMsg;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.Collections; import java.util.Collections;
import java.util.List; import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.UUID; import java.util.UUID;
import java.util.concurrent.TimeUnit; import java.util.concurrent.TimeUnit;
import static org.assertj.core.api.Assertions.assertThat;
import static org.hamcrest.Matchers.containsString; import static org.hamcrest.Matchers.containsString;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
import static org.thingsboard.server.dao.model.ModelConstants.NULL_UUID; import static org.thingsboard.server.dao.model.ModelConstants.NULL_UUID;
@ -92,6 +95,7 @@ import static org.thingsboard.server.dao.model.ModelConstants.NULL_UUID;
}) })
@ContextConfiguration(classes = {EdgeControllerTest.Config.class}) @ContextConfiguration(classes = {EdgeControllerTest.Config.class})
@DaoSqlTest @DaoSqlTest
@Slf4j
public class EdgeControllerTest extends AbstractControllerTest { public class EdgeControllerTest extends AbstractControllerTest {
public static final String EDGE_HOST = "localhost"; public static final String EDGE_HOST = "localhost";
@ -863,10 +867,7 @@ public class EdgeControllerTest extends AbstractControllerTest {
Edge edge = doPost("/api/edge", constructEdge("Test Sync Edge", "test"), Edge.class); Edge edge = doPost("/api/edge", constructEdge("Test Sync Edge", "test"), Edge.class);
// simulate edge activation simulateEdgeActivation(edge);
ObjectNode attributes = JacksonUtil.newObjectNode();
attributes.put("active", true);
doPost("/api/plugins/telemetry/EDGE/" + edge.getId() + "/attributes/" + DataConstants.SERVER_SCOPE, attributes);
doPost("/api/edge/" + edge.getId().getId().toString() doPost("/api/edge/" + edge.getId().getId().toString()
+ "/device/" + savedDevice.getId().getId().toString(), Device.class); + "/device/" + savedDevice.getId().getId().toString(), Device.class);
@ -878,7 +879,7 @@ public class EdgeControllerTest extends AbstractControllerTest {
edgeImitator.expectMessageAmount(24); edgeImitator.expectMessageAmount(24);
edgeImitator.connect(); edgeImitator.connect();
assertThat(edgeImitator.waitForMessages()).as("await for messages on first connect").isTrue(); waitForMessages(edgeImitator);
verifyFetchersMsgs(edgeImitator); verifyFetchersMsgs(edgeImitator);
// verify queue msgs // verify queue msgs
@ -890,7 +891,7 @@ public class EdgeControllerTest extends AbstractControllerTest {
edgeImitator.expectMessageAmount(20); edgeImitator.expectMessageAmount(20);
doPost("/api/edge/sync/" + edge.getId()); doPost("/api/edge/sync/" + edge.getId());
assertThat(edgeImitator.waitForMessages()).as("await for messages after edge sync rest api call").isTrue(); waitForMessages(edgeImitator);
verifyFetchersMsgs(edgeImitator); verifyFetchersMsgs(edgeImitator);
Assert.assertTrue(edgeImitator.getDownlinkMsgs().isEmpty()); Assert.assertTrue(edgeImitator.getDownlinkMsgs().isEmpty());
@ -909,6 +910,35 @@ public class EdgeControllerTest extends AbstractControllerTest {
.andExpect(status().isOk()); .andExpect(status().isOk());
} }
private void simulateEdgeActivation(Edge edge) throws Exception {
ObjectNode attributes = JacksonUtil.newObjectNode();
attributes.put("active", true);
doPost("/api/plugins/telemetry/EDGE/" + edge.getId() + "/attributes/" + DataConstants.SERVER_SCOPE, attributes);
Awaitility.await()
.atMost(30, TimeUnit.SECONDS)
.until(() -> {
List<Map<String, Object>> values = doGetAsyncTyped("/api/plugins/telemetry/EDGE/" + edge.getId() +
"/values/attributes/SERVER_SCOPE", new TypeReference<>() {});
Optional<Map<String, Object>> activeAttrOpt = values.stream().filter(att -> att.get("key").equals("active")).findFirst();
if (activeAttrOpt.isEmpty()) {
return false;
}
Map<String, Object> activeAttr = activeAttrOpt.get();
return "true".equals(activeAttr.get("value").toString());
});
}
private void waitForMessages(EdgeImitator edgeImitator) throws Exception {
boolean success = edgeImitator.waitForMessages();
if (!success) {
List<AbstractMessage> downlinkMsgs = edgeImitator.getDownlinkMsgs();
for (AbstractMessage downlinkMsg : downlinkMsgs) {
log.error("{}\n{}", downlinkMsg.getClass(), downlinkMsg);
}
Assert.fail("Await for messages was not successful!");
}
}
private void verifyFetchersMsgs(EdgeImitator edgeImitator) { private void verifyFetchersMsgs(EdgeImitator edgeImitator) {
Assert.assertTrue(popQueueMsg(edgeImitator.getDownlinkMsgs(), UpdateMsgType.ENTITY_CREATED_RPC_MESSAGE, "Main")); Assert.assertTrue(popQueueMsg(edgeImitator.getDownlinkMsgs(), UpdateMsgType.ENTITY_CREATED_RPC_MESSAGE, "Main"));
Assert.assertTrue(popRuleChainMsg(edgeImitator.getDownlinkMsgs(), UpdateMsgType.ENTITY_CREATED_RPC_MESSAGE, "Edge Root Rule Chain")); Assert.assertTrue(popRuleChainMsg(edgeImitator.getDownlinkMsgs(), UpdateMsgType.ENTITY_CREATED_RPC_MESSAGE, "Edge Root Rule Chain"));

4
application/src/test/java/org/thingsboard/server/service/notification/NotificationApiTest.java

@ -113,15 +113,13 @@ public class NotificationApiTest extends AbstractNotificationApiTest {
@Test @Test
public void testSubscribingToUnreadNotificationsCount() { public void testSubscribingToUnreadNotificationsCount() {
wsClient.subscribeForUnreadNotificationsCount().waitForReply(true);
NotificationTarget notificationTarget = createNotificationTarget(customerUserId); NotificationTarget notificationTarget = createNotificationTarget(customerUserId);
String notificationText1 = "Notification 1"; String notificationText1 = "Notification 1";
submitNotificationRequest(notificationTarget.getId(), notificationText1); submitNotificationRequest(notificationTarget.getId(), notificationText1);
String notificationText2 = "Notification 2"; String notificationText2 = "Notification 2";
submitNotificationRequest(notificationTarget.getId(), notificationText2); submitNotificationRequest(notificationTarget.getId(), notificationText2);
wsClient.subscribeForUnreadNotificationsCount();
wsClient.waitForReply(true);
await().atMost(2, TimeUnit.SECONDS) await().atMost(2, TimeUnit.SECONDS)
.until(() -> wsClient.getLastCountUpdate().getTotalUnreadCount() == 2); .until(() -> wsClient.getLastCountUpdate().getTotalUnreadCount() == 2);
} }

32
application/src/test/java/org/thingsboard/server/transport/coap/CoapTestClient.java

@ -15,6 +15,7 @@
*/ */
package org.thingsboard.server.transport.coap; package org.thingsboard.server.transport.coap;
import lombok.Getter;
import org.eclipse.californium.core.CoapClient; import org.eclipse.californium.core.CoapClient;
import org.eclipse.californium.core.CoapHandler; import org.eclipse.californium.core.CoapHandler;
import org.eclipse.californium.core.CoapObserveRelation; import org.eclipse.californium.core.CoapObserveRelation;
@ -34,7 +35,10 @@ public class CoapTestClient {
private final CoapClient client; private final CoapClient client;
public CoapTestClient(){ @Getter
private CoAP.Type type = CoAP.Type.CON;
public CoapTestClient() {
this.client = createClient(); this.client = createClient();
} }
@ -80,9 +84,13 @@ public class CoapTestClient {
return client.setTimeout(CLIENT_REQUEST_TIMEOUT).get(); return client.setTimeout(CLIENT_REQUEST_TIMEOUT).get();
} }
public CoapObserveRelation getObserveRelation(CoapTestCallback callback){ public CoapObserveRelation getObserveRelation(CoapTestCallback callback) {
return getObserveRelation(callback, true);
}
public CoapObserveRelation getObserveRelation(CoapTestCallback callback, boolean confirmable) {
Request request = Request.newGet().setObserve(); Request request = Request.newGet().setObserve();
request.setType(CoAP.Type.CON); request.setType(confirmable ? CoAP.Type.CON : CoAP.Type.NON);
return client.observe(request, callback); return client.observe(request, callback);
} }
@ -94,12 +102,28 @@ public class CoapTestClient {
} }
public void setURI(String accessToken, FeatureType featureType) { public void setURI(String accessToken, FeatureType featureType) {
if (featureType == null){ if (featureType == null) {
featureType = FeatureType.ATTRIBUTES; featureType = FeatureType.ATTRIBUTES;
} }
setURI(getFeatureTokenUrl(accessToken, featureType)); setURI(getFeatureTokenUrl(accessToken, featureType));
} }
public void useCONs() {
if (client == null) {
throw new RuntimeException("Failed to connect! CoapClient is not initialized!");
}
type = CoAP.Type.CON;
client.useCONs();
}
public void useNONs() {
if (client == null) {
throw new RuntimeException("Failed to connect! CoapClient is not initialized!");
}
type = CoAP.Type.NON;
client.useNONs();
}
private CoapClient createClient() { private CoapClient createClient() {
return new CoapClient(); return new CoapClient();
} }

309
application/src/test/java/org/thingsboard/server/transport/coap/client/CoapClientIntegrationTest.java

@ -0,0 +1,309 @@
/**
* Copyright © 2016-2023 The Thingsboard Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.thingsboard.server.transport.coap.client;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.JsonNode;
import lombok.Getter;
import lombok.extern.slf4j.Slf4j;
import org.eclipse.californium.core.CoapHandler;
import org.eclipse.californium.core.CoapObserveRelation;
import org.eclipse.californium.core.CoapResponse;
import org.eclipse.californium.core.coap.CoAP;
import org.eclipse.californium.core.coap.MediaTypeRegistry;
import org.junit.After;
import org.junit.Before;
import org.junit.Test;
import org.thingsboard.common.util.JacksonUtil;
import org.thingsboard.server.common.data.id.DeviceId;
import org.thingsboard.server.common.data.query.EntityKey;
import org.thingsboard.server.common.data.query.EntityKeyType;
import org.thingsboard.server.common.data.query.SingleEntityFilter;
import org.thingsboard.server.common.msg.session.FeatureType;
import org.thingsboard.server.dao.service.DaoSqlTest;
import org.thingsboard.server.transport.coap.AbstractCoapIntegrationTest;
import org.thingsboard.server.transport.coap.CoapTestCallback;
import org.thingsboard.server.transport.coap.CoapTestClient;
import org.thingsboard.server.transport.coap.CoapTestConfigProperties;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.HashSet;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.TimeUnit;
import java.util.stream.Collectors;
import static org.assertj.core.api.Assertions.assertThat;
import static org.awaitility.Awaitility.await;
import static org.junit.Assert.assertArrayEquals;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertNotNull;
import static org.junit.Assert.assertTrue;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
import static org.thingsboard.server.common.data.query.EntityKeyType.CLIENT_ATTRIBUTE;
import static org.thingsboard.server.common.data.query.EntityKeyType.SHARED_ATTRIBUTE;
@Slf4j
@DaoSqlTest
public class CoapClientIntegrationTest extends AbstractCoapIntegrationTest {
private static final String PAYLOAD_VALUES_STR = "{\"key1\":\"value1\", \"key2\":true, \"key3\": 3.0, \"key4\": 4," +
" \"key5\": {\"someNumber\": 42, \"someArray\": [1,2,3], \"someNestedObject\": {\"key\": \"value\"}}}";
private static final List<String> EXPECTED_KEYS = Arrays.asList("key1", "key2", "key3", "key4", "key5");
private static final String DEVICE_RESPONSE = "{\"value1\":\"A\",\"value2\":\"B\"}";
@Before
public void beforeTest() throws Exception {
CoapTestConfigProperties configProperties = CoapTestConfigProperties.builder()
.deviceName("Test Post Attributes device")
.build();
processBeforeTest(configProperties);
}
@After
public void afterTest() throws Exception {
processAfterTest();
}
@Test
public void testConfirmableRequests() throws Exception {
boolean confirmable = true;
processAttributesTest(confirmable);
processTwoWayRpcTest(confirmable);
processTestRequestAttributesValuesFromTheServer(confirmable);
}
@Test
public void testNonConfirmableRequests() throws Exception {
boolean confirmable = false;
processAttributesTest(confirmable);
processTwoWayRpcTest(confirmable);
processTestRequestAttributesValuesFromTheServer(confirmable);
}
protected void processAttributesTest(boolean confirmable) throws Exception {
client = createClientForFeatureWithConfirmableParameter(FeatureType.ATTRIBUTES, confirmable);
CoapResponse coapResponse = client.postMethod(PAYLOAD_VALUES_STR.getBytes());
assertEquals(CoAP.ResponseCode.CREATED, coapResponse.getCode());
assertEquals("CoAP response type is wrong!", client.getType(), coapResponse.advanced().getType());
DeviceId deviceId = savedDevice.getId();
List<String> actualKeys = getActualKeysList(deviceId);
assertNotNull(actualKeys);
Set<String> actualKeySet = new HashSet<>(actualKeys);
Set<String> expectedKeySet = new HashSet<>(EXPECTED_KEYS);
assertEquals(expectedKeySet, actualKeySet);
String attributesValuesUrl = "/api/plugins/telemetry/DEVICE/" + deviceId + "/values/attributes/CLIENT_SCOPE?keys=" + String.join(",", actualKeySet);
;
List<Map<String, Object>> values = doGetAsyncTyped(attributesValuesUrl, new TypeReference<>() {
});
assertAttributesValues(values, actualKeySet);
String deleteAttributesUrl = "/api/plugins/telemetry/DEVICE/" + deviceId + "/CLIENT_SCOPE?keys=" + String.join(",", actualKeySet);
doDelete(deleteAttributesUrl);
}
protected void processTwoWayRpcTest(boolean confirmable) throws Exception {
client = createClientForFeatureWithConfirmableParameter(FeatureType.RPC, confirmable);
CoapTestCallback callbackCoap = new TestCoapCallbackForRPC(client);
CoapObserveRelation observeRelation = client.getObserveRelation(callbackCoap, confirmable);
String awaitAlias = "await Two Way Rpc (client.getObserveRelation)";
await(awaitAlias)
.atMost(DEFAULT_WAIT_TIMEOUT_SECONDS, TimeUnit.SECONDS)
.until(() -> CoAP.ResponseCode.VALID.equals(callbackCoap.getResponseCode()) &&
callbackCoap.getObserve() != null &&
0 == callbackCoap.getObserve());
validateCurrentStateNotification(callbackCoap);
String setGpioRequest = "{\"method\":\"setGpio\",\"params\":{\"pin\": \"26\",\"value\": 1}}";
String deviceId = savedDevice.getId().getId().toString();
int expectedObserveCountAfterGpioRequest1 = callbackCoap.getObserve() + 1;
String actualResult = doPostAsync("/api/rpc/twoway/" + deviceId, setGpioRequest, String.class, status().isOk());
awaitAlias = "await Two Way Rpc (setGpio(method, params, value) first";
await(awaitAlias)
.atMost(DEFAULT_WAIT_TIMEOUT_SECONDS, TimeUnit.SECONDS)
.until(() -> CoAP.ResponseCode.CONTENT.equals(callbackCoap.getResponseCode()) &&
callbackCoap.getObserve() != null &&
expectedObserveCountAfterGpioRequest1 == callbackCoap.getObserve());
validateTwoWayStateChangedNotification(callbackCoap, actualResult);
int expectedObserveCountAfterGpioRequest2 = callbackCoap.getObserve() + 1;
actualResult = doPostAsync("/api/rpc/twoway/" + deviceId, setGpioRequest, String.class, status().isOk());
awaitAlias = "await Two Way Rpc (setGpio(method, params, value) second";
await(awaitAlias)
.atMost(DEFAULT_WAIT_TIMEOUT_SECONDS, TimeUnit.SECONDS)
.until(() -> CoAP.ResponseCode.CONTENT.equals(callbackCoap.getResponseCode()) &&
callbackCoap.getObserve() != null &&
expectedObserveCountAfterGpioRequest2 == callbackCoap.getObserve());
validateTwoWayStateChangedNotification(callbackCoap, actualResult);
observeRelation.proactiveCancel();
assertTrue(observeRelation.isCanceled());
}
protected void processTestRequestAttributesValuesFromTheServer(boolean confirmable) throws Exception {
client = createClientForFeatureWithConfirmableParameter(FeatureType.ATTRIBUTES, confirmable);
SingleEntityFilter dtf = new SingleEntityFilter();
dtf.setSingleEntity(savedDevice.getId());
List<EntityKey> csKeys = getEntityKeys(CLIENT_ATTRIBUTE);
List<EntityKey> shKeys = getEntityKeys(SHARED_ATTRIBUTE);
List<EntityKey> keys = new ArrayList<>();
keys.addAll(csKeys);
keys.addAll(shKeys);
getWsClient().subscribeLatestUpdate(keys, dtf);
getWsClient().registerWaitForUpdate(2);
doPostAsync("/api/plugins/telemetry/DEVICE/" + savedDevice.getId().getId() + "/attributes/SHARED_SCOPE",
PAYLOAD_VALUES_STR, String.class, status().isOk());
CoapResponse coapResponse = client.postMethod(PAYLOAD_VALUES_STR);
assertEquals(CoAP.ResponseCode.CREATED, coapResponse.getCode());
String update = getWsClient().waitForUpdate();
assertThat(update).as("ws update received").isNotBlank();
String keysParam = String.join(",", EXPECTED_KEYS);
String featureTokenUrl = CoapTestClient.getFeatureTokenUrl(accessToken, FeatureType.ATTRIBUTES) + "?clientKeys=" + keysParam + "&sharedKeys=" + keysParam;
client.setURI(featureTokenUrl);
CoapResponse response = client.getMethod();
assertEquals("CoAP response type is wrong!", client.getType(), response.advanced().getType());
}
@SuppressWarnings({"unchecked", "rawtypes"})
protected void assertAttributesValues(List<Map<String, Object>> deviceValues, Set<String> keySet) {
for (Map<String, Object> map : deviceValues) {
String key = (String) map.get("key");
Object value = map.get("value");
assertTrue(keySet.contains(key));
switch (key) {
case "key1":
assertEquals("value1", value);
break;
case "key2":
assertEquals(true, value);
break;
case "key3":
assertEquals(3.0, value);
break;
case "key4":
assertEquals(4, value);
break;
case "key5":
assertNotNull(value);
assertEquals(3, ((LinkedHashMap) value).size());
assertEquals(42, ((LinkedHashMap) value).get("someNumber"));
assertEquals(Arrays.asList(1, 2, 3), ((LinkedHashMap) value).get("someArray"));
LinkedHashMap<String, String> someNestedObject = (LinkedHashMap) ((LinkedHashMap) value).get("someNestedObject");
assertEquals("value", someNestedObject.get("key"));
break;
}
}
}
private List<String> getActualKeysList(DeviceId deviceId) throws Exception {
long start = System.currentTimeMillis();
long end = System.currentTimeMillis() + 5000;
List<String> actualKeys = null;
while (start <= end) {
actualKeys = doGetAsyncTyped("/api/plugins/telemetry/DEVICE/" + deviceId + "/keys/attributes/CLIENT_SCOPE", new TypeReference<>() {
});
if (actualKeys.size() == EXPECTED_KEYS.size()) {
break;
}
Thread.sleep(100);
start += 100;
}
return actualKeys;
}
private void validateCurrentStateNotification(CoapTestCallback callback) {
assertArrayEquals(EMPTY_PAYLOAD, callback.getPayloadBytes());
}
private void validateTwoWayStateChangedNotification(CoapTestCallback callback, String actualResult) {
assertEquals(DEVICE_RESPONSE, actualResult);
assertNotNull(callback.getPayloadBytes());
}
protected class TestCoapCallbackForRPC extends CoapTestCallback {
private final CoapTestClient client;
@Getter
private boolean wasSuccessful = false;
TestCoapCallbackForRPC(CoapTestClient client) {
this.client = client;
}
@Override
public void onLoad(CoapResponse response) {
payloadBytes = response.getPayload();
responseCode = response.getCode();
observe = response.getOptions().getObserve();
wasSuccessful = client.getType().equals(response.advanced().getType());
if (observe != null) {
if (observe > 0) {
processOnLoadResponse(response, client);
}
}
}
@Override
public void onError() {
log.warn("Command Response Ack Error, No connect");
}
}
protected void processOnLoadResponse(CoapResponse response, CoapTestClient client) {
JsonNode responseJson = JacksonUtil.fromBytes(response.getPayload());
int requestId = responseJson.get("id").asInt();
client.setURI(CoapTestClient.getFeatureTokenUrl(accessToken, FeatureType.RPC, requestId));
client.postMethod(new CoapHandler() {
@Override
public void onLoad(CoapResponse response) {
log.warn("RPC {} command response ack: {}", requestId, response.getCode());
}
@Override
public void onError() {
log.warn("RPC {} command response ack error, no connect", requestId);
}
}, DEVICE_RESPONSE, MediaTypeRegistry.APPLICATION_JSON);
}
private CoapTestClient createClientForFeatureWithConfirmableParameter(FeatureType featureType, boolean confirmable) {
CoapTestClient coapTestClient = new CoapTestClient(accessToken, featureType);
if (confirmable) {
coapTestClient.useCONs();
} else {
coapTestClient.useNONs();
}
return coapTestClient;
}
private List<EntityKey> getEntityKeys(EntityKeyType scope) {
return CoapClientIntegrationTest.EXPECTED_KEYS.stream().map(key -> new EntityKey(scope, key)).collect(Collectors.toList());
}
}

25
common/cache/src/main/java/org/thingsboard/server/cache/TbTransactionalCache.java

@ -47,6 +47,18 @@ public interface TbTransactionalCache<K extends Serializable, V extends Serializ
*/ */
TbCacheTransaction<K, V> newTransactionForKeys(List<K> keys); TbCacheTransaction<K, V> newTransactionForKeys(List<K> keys);
default V getOrFetchFromDB(K key, Supplier<V> dbCall, boolean cacheNullValue, boolean putToCache) {
if (putToCache) {
return getAndPutInTransaction(key, dbCall, cacheNullValue);
} else {
TbCacheValueWrapper<V> cacheValueWrapper = get(key);
if (cacheValueWrapper != null) {
return cacheValueWrapper.get();
}
return dbCall.get();
}
}
default V getAndPutInTransaction(K key, Supplier<V> dbCall, boolean cacheNullValue) { default V getAndPutInTransaction(K key, Supplier<V> dbCall, boolean cacheNullValue) {
TbCacheValueWrapper<V> cacheValueWrapper = get(key); TbCacheValueWrapper<V> cacheValueWrapper = get(key);
if (cacheValueWrapper != null) { if (cacheValueWrapper != null) {
@ -69,6 +81,19 @@ public interface TbTransactionalCache<K extends Serializable, V extends Serializ
} }
} }
default <R> R getOrFetchFromDB(K key, Supplier<R> dbCall, Function<V, R> cacheValueToResult, Function<R, V> dbValueToCacheValue, boolean cacheNullValue, boolean putToCache) {
if (putToCache) {
return getAndPutInTransaction(key, dbCall, cacheValueToResult, dbValueToCacheValue, cacheNullValue);
} else {
TbCacheValueWrapper<V> cacheValueWrapper = get(key);
if (cacheValueWrapper != null) {
var cacheValue = cacheValueWrapper.get();
return cacheValue == null ? null : cacheValueToResult.apply(cacheValue);
}
return dbCall.get();
}
}
default <R> R getAndPutInTransaction(K key, Supplier<R> dbCall, Function<V, R> cacheValueToResult, Function<R, V> dbValueToCacheValue, boolean cacheNullValue) { default <R> R getAndPutInTransaction(K key, Supplier<R> dbCall, Function<V, R> cacheValueToResult, Function<R, V> dbValueToCacheValue, boolean cacheNullValue) {
TbCacheValueWrapper<V> cacheValueWrapper = get(key); TbCacheValueWrapper<V> cacheValueWrapper = get(key);
if (cacheValueWrapper != null) { if (cacheValueWrapper != null) {

6
common/cluster-api/src/main/proto/queue.proto

@ -255,6 +255,12 @@ message GetOrCreateDeviceFromGatewayRequestMsg {
message GetOrCreateDeviceFromGatewayResponseMsg { message GetOrCreateDeviceFromGatewayResponseMsg {
DeviceInfoProto deviceInfo = 1; DeviceInfoProto deviceInfo = 1;
bytes profileBody = 2; bytes profileBody = 2;
TransportApiRequestErrorCode error = 3;
}
enum TransportApiRequestErrorCode {
UNKNOWN_TRANSPORT_API_ERROR = 0;
ENTITY_LIMIT = 1;
} }
message GetEntityProfileRequestMsg { message GetEntityProfileRequestMsg {

4
common/dao-api/src/main/java/org/thingsboard/server/dao/asset/AssetProfileService.java

@ -27,8 +27,12 @@ public interface AssetProfileService extends EntityDaoService {
AssetProfile findAssetProfileById(TenantId tenantId, AssetProfileId assetProfileId); AssetProfile findAssetProfileById(TenantId tenantId, AssetProfileId assetProfileId);
AssetProfile findAssetProfileById(TenantId tenantId, AssetProfileId assetProfileId, boolean putInCache);
AssetProfile findAssetProfileByName(TenantId tenantId, String profileName); AssetProfile findAssetProfileByName(TenantId tenantId, String profileName);
AssetProfile findAssetProfileByName(TenantId tenantId, String profileName, boolean putInCache);
AssetProfileInfo findAssetProfileInfoById(TenantId tenantId, AssetProfileId assetProfileId); AssetProfileInfo findAssetProfileInfoById(TenantId tenantId, AssetProfileId assetProfileId);
AssetProfile saveAssetProfile(AssetProfile assetProfile, boolean doValidate); AssetProfile saveAssetProfile(AssetProfile assetProfile, boolean doValidate);

4
common/dao-api/src/main/java/org/thingsboard/server/dao/device/DeviceProfileService.java

@ -27,8 +27,12 @@ public interface DeviceProfileService extends EntityDaoService {
DeviceProfile findDeviceProfileById(TenantId tenantId, DeviceProfileId deviceProfileId); DeviceProfile findDeviceProfileById(TenantId tenantId, DeviceProfileId deviceProfileId);
DeviceProfile findDeviceProfileById(TenantId tenantId, DeviceProfileId deviceProfileId, boolean putInCache);
DeviceProfile findDeviceProfileByName(TenantId tenantId, String profileName); DeviceProfile findDeviceProfileByName(TenantId tenantId, String profileName);
DeviceProfile findDeviceProfileByName(TenantId tenantId, String profileName, boolean putInCache);
DeviceProfileInfo findDeviceProfileInfoById(TenantId tenantId, DeviceProfileId deviceProfileId); DeviceProfileInfo findDeviceProfileInfoById(TenantId tenantId, DeviceProfileId deviceProfileId);
DeviceProfile saveDeviceProfile(DeviceProfile deviceProfile, boolean doValidate); DeviceProfile saveDeviceProfile(DeviceProfile deviceProfile, boolean doValidate);

2
common/dao-api/src/main/java/org/thingsboard/server/dao/entityview/EntityViewService.java

@ -50,6 +50,8 @@ public interface EntityViewService extends EntityDaoService {
EntityView findEntityViewById(TenantId tenantId, EntityViewId entityViewId); EntityView findEntityViewById(TenantId tenantId, EntityViewId entityViewId);
EntityView findEntityViewById(TenantId tenantId, EntityViewId entityViewId, boolean putInCache);
EntityView findEntityViewByTenantIdAndName(TenantId tenantId, String name); EntityView findEntityViewByTenantIdAndName(TenantId tenantId, String name);
PageData<EntityView> findEntityViewByTenantId(TenantId tenantId, PageLink pageLink); PageData<EntityView> findEntityViewByTenantId(TenantId tenantId, PageLink pageLink);

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

@ -49,6 +49,7 @@ public class DataConstants {
public static final String MQTT_TRANSPORT_NAME = "MQTT"; public static final String MQTT_TRANSPORT_NAME = "MQTT";
public static final String HTTP_TRANSPORT_NAME = "HTTP"; public static final String HTTP_TRANSPORT_NAME = "HTTP";
public static final String SNMP_TRANSPORT_NAME = "SNMP"; public static final String SNMP_TRANSPORT_NAME = "SNMP";
public static final String MAXIMUM_NUMBER_OF_DEVICES_REACHED = "Maximum number of devices reached!";
public static final String[] allScopes() { public static final String[] allScopes() {

1
common/transport/coap/src/main/java/org/thingsboard/server/transport/coap/callback/AbstractSyncSessionCallback.java

@ -81,6 +81,7 @@ public abstract class AbstractSyncSessionCallback implements SessionMsgListener
protected void respond(Response response) { protected void respond(Response response) {
response.getOptions().setContentFormat(TbCoapContentFormatUtil.getContentFormat(exchange.getRequestOptions().getContentFormat(), state.getContentFormat())); response.getOptions().setContentFormat(TbCoapContentFormatUtil.getContentFormat(exchange.getRequestOptions().getContentFormat(), state.getContentFormat()));
response.setConfirmable(exchange.advanced().getRequest().isConfirmable());
exchange.respond(response); exchange.respond(response);
} }

4
common/transport/coap/src/main/java/org/thingsboard/server/transport/coap/callback/CoapOkCallback.java

@ -34,7 +34,9 @@ public class CoapOkCallback implements TransportServiceCallback<Void> {
@Override @Override
public void onSuccess(Void msg) { public void onSuccess(Void msg) {
exchange.respond(new Response(onSuccessResponse)); Response response = new Response(onSuccessResponse);
response.setConfirmable(isConRequest());
exchange.respond(response);
} }
@Override @Override

22
common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/session/AbstractGatewaySessionHandler.java

@ -35,6 +35,7 @@ import io.netty.handler.codec.mqtt.MqttPublishMessage;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.springframework.util.CollectionUtils; import org.springframework.util.CollectionUtils;
import org.springframework.util.ConcurrentReferenceHashMap; import org.springframework.util.ConcurrentReferenceHashMap;
import org.thingsboard.server.common.data.DataConstants;
import org.thingsboard.server.common.data.StringUtils; import org.thingsboard.server.common.data.StringUtils;
import org.thingsboard.server.common.data.id.DeviceId; import org.thingsboard.server.common.data.id.DeviceId;
import org.thingsboard.server.common.transport.TransportService; import org.thingsboard.server.common.transport.TransportService;
@ -215,8 +216,7 @@ public abstract class AbstractGatewaySessionHandler<T extends AbstractGatewayDev
@Override @Override
public void onFailure(Throwable t) { public void onFailure(Throwable t) {
log.warn("[{}][{}][{}] Failed to process device connect command: [{}]", gateway.getTenantId(), gateway.getDeviceId(), sessionId, deviceName, t); logDeviceCreationError(t, deviceName);
} }
}, context.getExecutor()); }, context.getExecutor());
} }
@ -248,7 +248,8 @@ public abstract class AbstractGatewaySessionHandler<T extends AbstractGatewayDev
return future; return future;
} }
try { try {
transportService.process(GetOrCreateDeviceFromGatewayRequestMsg.newBuilder() transportService.process(gateway.getTenantId(),
GetOrCreateDeviceFromGatewayRequestMsg.newBuilder()
.setDeviceName(deviceName) .setDeviceName(deviceName)
.setDeviceType(deviceType) .setDeviceType(deviceType)
.setGatewayIdMSB(gateway.getDeviceId().getId().getMostSignificantBits()) .setGatewayIdMSB(gateway.getDeviceId().getId().getMostSignificantBits())
@ -274,9 +275,9 @@ public abstract class AbstractGatewaySessionHandler<T extends AbstractGatewayDev
} }
@Override @Override
public void onError(Throwable e) { public void onError(Throwable t) {
log.warn("[{}][{}][{}] Failed to process device connect command at getDeviceCreationFuture: [{}]", gateway.getTenantId(), gateway.getDeviceId(), sessionId, deviceName, e); logDeviceCreationError(t, deviceName);
futureToSet.setException(e); futureToSet.setException(t);
deviceFutures.remove(deviceName); deviceFutures.remove(deviceName);
} }
}); });
@ -287,6 +288,15 @@ public abstract class AbstractGatewaySessionHandler<T extends AbstractGatewayDev
} }
} }
private void logDeviceCreationError(Throwable t, String deviceName) {
if (DataConstants.MAXIMUM_NUMBER_OF_DEVICES_REACHED.equals(t.getMessage())) {
log.info("[{}][{}][{}] Failed to process device connect command: [{}] due to [{}]", gateway.getTenantId(), gateway.getDeviceId(), sessionId, deviceName,
DataConstants.MAXIMUM_NUMBER_OF_DEVICES_REACHED);
} else {
log.warn("[{}][{}][{}] Failed to process device connect command: [{}]", gateway.getTenantId(), gateway.getDeviceId(), sessionId, deviceName, t);
}
}
protected abstract T newDeviceSessionCtx(GetOrCreateDeviceFromGatewayResponse msg); protected abstract T newDeviceSessionCtx(GetOrCreateDeviceFromGatewayResponse msg);
protected int getMsgId(MqttPublishMessage mqttMsg) { protected int getMsgId(MqttPublishMessage mqttMsg) {

2
common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/TransportService.java

@ -98,7 +98,7 @@ public interface TransportService {
void process(ValidateDeviceLwM2MCredentialsRequestMsg msg, void process(ValidateDeviceLwM2MCredentialsRequestMsg msg,
TransportServiceCallback<ValidateDeviceCredentialsResponse> callback); TransportServiceCallback<ValidateDeviceCredentialsResponse> callback);
void process(GetOrCreateDeviceFromGatewayRequestMsg msg, void process(TenantId tenantId, GetOrCreateDeviceFromGatewayRequestMsg msg,
TransportServiceCallback<GetOrCreateDeviceFromGatewayResponse> callback); TransportServiceCallback<GetOrCreateDeviceFromGatewayResponse> callback);
void process(ProvisionDeviceRequestMsg msg, void process(ProvisionDeviceRequestMsg msg,

74
common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/limits/DefaultEntityLimitsCache.java

@ -0,0 +1,74 @@
/**
* Copyright © 2016-2023 The Thingsboard Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.thingsboard.server.common.transport.limits;
import com.github.benmanes.caffeine.cache.Cache;
import com.github.benmanes.caffeine.cache.Caffeine;
import com.github.benmanes.caffeine.cache.Expiry;
import lombok.extern.slf4j.Slf4j;
import org.jetbrains.annotations.NotNull;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import org.thingsboard.server.queue.util.TbTransportComponent;
import java.util.concurrent.ThreadLocalRandom;
import java.util.concurrent.TimeUnit;
@Service
@TbTransportComponent
@Slf4j
public class DefaultEntityLimitsCache implements EntityLimitsCache {
private static final int DEVIATION = 10;
private final Cache<EntityLimitKey, Boolean> cache;
public DefaultEntityLimitsCache(@Value("${cache.entityLimits.timeToLiveInMinutes:5}") int ttl,
@Value("${cache.entityLimits.maxSize:100000}") int maxSize) {
// We use the 'random' expiration time to avoid peak loads.
long mainPart = (TimeUnit.MINUTES.toNanos(ttl) / 100) * (100 - DEVIATION);
long randomPart = (TimeUnit.MINUTES.toNanos(ttl) / 100) * DEVIATION;
cache = Caffeine.newBuilder()
.expireAfter(new Expiry<EntityLimitKey, Boolean>() {
@Override
public long expireAfterCreate(@NotNull EntityLimitKey key, @NotNull Boolean value, long currentTime) {
return mainPart + (long) (randomPart * ThreadLocalRandom.current().nextDouble());
}
@Override
public long expireAfterUpdate(@NotNull EntityLimitKey key, @NotNull Boolean value, long currentTime, long currentDuration) {
return currentDuration;
}
@Override
public long expireAfterRead(@NotNull EntityLimitKey key, @NotNull Boolean value, long currentTime, long currentDuration) {
return currentDuration;
}
})
.maximumSize(maxSize)
.build();
}
@Override
public boolean get(EntityLimitKey key) {
var result = cache.getIfPresent(key);
return result != null ? result : false;
}
@Override
public void put(EntityLimitKey key, boolean value) {
cache.put(key, value);
}
}

27
common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/limits/EntityLimitKey.java

@ -0,0 +1,27 @@
/**
* Copyright © 2016-2023 The Thingsboard Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.thingsboard.server.common.transport.limits;
import lombok.Data;
import org.thingsboard.server.common.data.id.TenantId;
@Data
public class EntityLimitKey {
private final TenantId tenantId;
private final String deviceName;
}

24
common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/limits/EntityLimitsCache.java

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

48
common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/service/DefaultTransportService.java

@ -76,6 +76,8 @@ import org.thingsboard.server.common.transport.TransportTenantProfileCache;
import org.thingsboard.server.common.transport.auth.GetOrCreateDeviceFromGatewayResponse; import org.thingsboard.server.common.transport.auth.GetOrCreateDeviceFromGatewayResponse;
import org.thingsboard.server.common.transport.auth.TransportDeviceInfo; import org.thingsboard.server.common.transport.auth.TransportDeviceInfo;
import org.thingsboard.server.common.transport.auth.ValidateDeviceCredentialsResponse; import org.thingsboard.server.common.transport.auth.ValidateDeviceCredentialsResponse;
import org.thingsboard.server.common.transport.limits.EntityLimitKey;
import org.thingsboard.server.common.transport.limits.EntityLimitsCache;
import org.thingsboard.server.common.transport.limits.TransportRateLimitService; import org.thingsboard.server.common.transport.limits.TransportRateLimitService;
import org.thingsboard.server.common.transport.util.JsonUtils; import org.thingsboard.server.common.transport.util.JsonUtils;
import org.thingsboard.server.gen.transport.TransportProtos; import org.thingsboard.server.gen.transport.TransportProtos;
@ -161,6 +163,7 @@ public class DefaultTransportService implements TransportService {
@Value("${transport.stats.enabled:false}") @Value("${transport.stats.enabled:false}")
private boolean statsEnabled; private boolean statsEnabled;
@Autowired @Autowired
@Lazy @Lazy
private TbApiUsageReportClient apiUsageClient; private TbApiUsageReportClient apiUsageClient;
@ -184,6 +187,8 @@ public class DefaultTransportService implements TransportService {
private final TransportResourceCache transportResourceCache; private final TransportResourceCache transportResourceCache;
private final NotificationRuleProcessor notificationRuleProcessor; private final NotificationRuleProcessor notificationRuleProcessor;
private final EntityLimitsCache entityLimitsCache;
protected TbQueueRequestTemplate<TbProtoQueueMsg<TransportApiRequestMsg>, TbProtoQueueMsg<TransportApiResponseMsg>> transportApiRequestTemplate; protected TbQueueRequestTemplate<TbProtoQueueMsg<TransportApiRequestMsg>, TbProtoQueueMsg<TransportApiResponseMsg>> transportApiRequestTemplate;
protected TbQueueProducer<TbProtoQueueMsg<ToRuleEngineMsg>> ruleEngineMsgProducer; protected TbQueueProducer<TbProtoQueueMsg<ToRuleEngineMsg>> ruleEngineMsgProducer;
protected TbQueueProducer<TbProtoQueueMsg<ToCoreMsg>> tbCoreMsgProducer; protected TbQueueProducer<TbProtoQueueMsg<ToCoreMsg>> tbCoreMsgProducer;
@ -212,7 +217,8 @@ public class DefaultTransportService implements TransportService {
TransportTenantProfileCache tenantProfileCache, TransportTenantProfileCache tenantProfileCache,
TransportRateLimitService rateLimitService, TransportRateLimitService rateLimitService,
DataDecodingEncodingService dataDecodingEncodingService, SchedulerComponent scheduler, TransportResourceCache transportResourceCache, DataDecodingEncodingService dataDecodingEncodingService, SchedulerComponent scheduler, TransportResourceCache transportResourceCache,
ApplicationEventPublisher eventPublisher, NotificationRuleProcessor notificationRuleProcessor) { ApplicationEventPublisher eventPublisher, NotificationRuleProcessor notificationRuleProcessor,
EntityLimitsCache entityLimitsCache) {
this.partitionService = partitionService; this.partitionService = partitionService;
this.serviceInfoProvider = serviceInfoProvider; this.serviceInfoProvider = serviceInfoProvider;
this.queueProvider = queueProvider; this.queueProvider = queueProvider;
@ -227,6 +233,7 @@ public class DefaultTransportService implements TransportService {
this.transportResourceCache = transportResourceCache; this.transportResourceCache = transportResourceCache;
this.eventPublisher = eventPublisher; this.eventPublisher = eventPublisher;
this.notificationRuleProcessor = notificationRuleProcessor; this.notificationRuleProcessor = notificationRuleProcessor;
this.entityLimitsCache = entityLimitsCache;
} }
@PostConstruct @PostConstruct
@ -249,7 +256,7 @@ public class DefaultTransportService implements TransportService {
} }
@AfterStartUp(order = AfterStartUp.TRANSPORT_SERVICE) @AfterStartUp(order = AfterStartUp.TRANSPORT_SERVICE)
private void start() { public void start() {
mainConsumerExecutor.execute(() -> { mainConsumerExecutor.execute(() -> {
while (!stopped) { while (!stopped) {
try { try {
@ -473,24 +480,33 @@ public class DefaultTransportService implements TransportService {
AsyncCallbackTemplate.withCallback(response, callback::onSuccess, callback::onError, transportCallbackExecutor); AsyncCallbackTemplate.withCallback(response, callback::onSuccess, callback::onError, transportCallbackExecutor);
} }
@Override @Override
public void process(TransportProtos.GetOrCreateDeviceFromGatewayRequestMsg requestMsg, TransportServiceCallback<GetOrCreateDeviceFromGatewayResponse> callback) { public void process(TenantId tenantId, TransportProtos.GetOrCreateDeviceFromGatewayRequestMsg requestMsg, TransportServiceCallback<GetOrCreateDeviceFromGatewayResponse> callback) {
TbProtoQueueMsg<TransportApiRequestMsg> protoMsg = new TbProtoQueueMsg<>(UUID.randomUUID(), TransportApiRequestMsg.newBuilder().setGetOrCreateDeviceRequestMsg(requestMsg).build()); TbProtoQueueMsg<TransportApiRequestMsg> protoMsg = new TbProtoQueueMsg<>(UUID.randomUUID(), TransportApiRequestMsg.newBuilder().setGetOrCreateDeviceRequestMsg(requestMsg).build());
log.trace("Processing msg: {}", requestMsg); log.trace("Processing msg: {}", requestMsg);
ListenableFuture<GetOrCreateDeviceFromGatewayResponse> response = Futures.transform(transportApiRequestTemplate.send(protoMsg), tmp -> { var key = new EntityLimitKey(tenantId, StringUtils.truncate(requestMsg.getDeviceName(), 256));
TransportProtos.GetOrCreateDeviceFromGatewayResponseMsg msg = tmp.getValue().getGetOrCreateDeviceResponseMsg(); if (entityLimitsCache.get(key)) {
GetOrCreateDeviceFromGatewayResponse.GetOrCreateDeviceFromGatewayResponseBuilder result = GetOrCreateDeviceFromGatewayResponse.builder(); transportCallbackExecutor.submit(() -> callback.onError(new RuntimeException(DataConstants.MAXIMUM_NUMBER_OF_DEVICES_REACHED)));
if (msg.hasDeviceInfo()) { } else {
TransportDeviceInfo tdi = getTransportDeviceInfo(msg.getDeviceInfo()); ListenableFuture<GetOrCreateDeviceFromGatewayResponse> response = Futures.transform(transportApiRequestTemplate.send(protoMsg), tmp -> {
result.deviceInfo(tdi); TransportProtos.GetOrCreateDeviceFromGatewayResponseMsg msg = tmp.getValue().getGetOrCreateDeviceResponseMsg();
ByteString profileBody = msg.getProfileBody(); GetOrCreateDeviceFromGatewayResponse.GetOrCreateDeviceFromGatewayResponseBuilder result = GetOrCreateDeviceFromGatewayResponse.builder();
if (profileBody != null && !profileBody.isEmpty()) { if (msg.hasDeviceInfo()) {
result.deviceProfile(deviceProfileCache.getOrCreate(tdi.getDeviceProfileId(), profileBody)); TransportDeviceInfo tdi = getTransportDeviceInfo(msg.getDeviceInfo());
result.deviceInfo(tdi);
ByteString profileBody = msg.getProfileBody();
if (!profileBody.isEmpty()) {
result.deviceProfile(deviceProfileCache.getOrCreate(tdi.getDeviceProfileId(), profileBody));
}
} else if (TransportProtos.TransportApiRequestErrorCode.ENTITY_LIMIT.equals(msg.getError())) {
entityLimitsCache.put(key, true);
throw new RuntimeException(DataConstants.MAXIMUM_NUMBER_OF_DEVICES_REACHED);
} }
} return result.build();
return result.build(); }, MoreExecutors.directExecutor());
}, MoreExecutors.directExecutor()); AsyncCallbackTemplate.withCallback(response, callback::onSuccess, callback::onError, transportCallbackExecutor);
AsyncCallbackTemplate.withCallback(response, callback::onSuccess, callback::onError, transportCallbackExecutor); }
} }
@Override @Override

24
dao/src/main/java/org/thingsboard/server/dao/asset/AssetProfileServiceImpl.java

@ -90,18 +90,28 @@ public class AssetProfileServiceImpl extends AbstractCachedEntityService<AssetPr
@Override @Override
public AssetProfile findAssetProfileById(TenantId tenantId, AssetProfileId assetProfileId) { public AssetProfile findAssetProfileById(TenantId tenantId, AssetProfileId assetProfileId) {
return findAssetProfileById(tenantId, assetProfileId, true);
}
@Override
public AssetProfile findAssetProfileById(TenantId tenantId, AssetProfileId assetProfileId, boolean putInCache) {
log.trace("Executing findAssetProfileById [{}]", assetProfileId); log.trace("Executing findAssetProfileById [{}]", assetProfileId);
Validator.validateId(assetProfileId, INCORRECT_ASSET_PROFILE_ID + assetProfileId); Validator.validateId(assetProfileId, INCORRECT_ASSET_PROFILE_ID + assetProfileId);
return cache.getAndPutInTransaction(AssetProfileCacheKey.fromId(assetProfileId), return cache.getOrFetchFromDB(AssetProfileCacheKey.fromId(assetProfileId),
() -> assetProfileDao.findById(tenantId, assetProfileId.getId()), true); () -> assetProfileDao.findById(tenantId, assetProfileId.getId()), true, putInCache);
} }
@Override @Override
public AssetProfile findAssetProfileByName(TenantId tenantId, String profileName) { public AssetProfile findAssetProfileByName(TenantId tenantId, String profileName) {
return findAssetProfileByName(tenantId, profileName, true);
}
@Override
public AssetProfile findAssetProfileByName(TenantId tenantId, String profileName, boolean putInCache) {
log.trace("Executing findAssetProfileByName [{}][{}]", tenantId, profileName); log.trace("Executing findAssetProfileByName [{}][{}]", tenantId, profileName);
Validator.validateString(profileName, INCORRECT_ASSET_PROFILE_NAME + profileName); Validator.validateString(profileName, INCORRECT_ASSET_PROFILE_NAME + profileName);
return cache.getAndPutInTransaction(AssetProfileCacheKey.fromName(tenantId, profileName), return cache.getOrFetchFromDB(AssetProfileCacheKey.fromName(tenantId, profileName),
() -> assetProfileDao.findByName(tenantId, profileName), false); () -> assetProfileDao.findByName(tenantId, profileName), false, putInCache);
} }
@Override @Override
@ -127,7 +137,7 @@ public class AssetProfileServiceImpl extends AbstractCachedEntityService<AssetPr
if (doValidate) { if (doValidate) {
oldAssetProfile = assetProfileValidator.validate(assetProfile, AssetProfile::getTenantId); oldAssetProfile = assetProfileValidator.validate(assetProfile, AssetProfile::getTenantId);
} else if (assetProfile.getId() != null) { } else if (assetProfile.getId() != null) {
oldAssetProfile = findAssetProfileById(assetProfile.getTenantId(), assetProfile.getId()); oldAssetProfile = findAssetProfileById(assetProfile.getTenantId(), assetProfile.getId(), false);
} }
AssetProfile savedAssetProfile; AssetProfile savedAssetProfile;
try { try {
@ -208,13 +218,13 @@ public class AssetProfileServiceImpl extends AbstractCachedEntityService<AssetPr
@Override @Override
public AssetProfile findOrCreateAssetProfile(TenantId tenantId, String name) { public AssetProfile findOrCreateAssetProfile(TenantId tenantId, String name) {
log.trace("Executing findOrCreateAssetProfile"); log.trace("Executing findOrCreateAssetProfile");
AssetProfile assetProfile = findAssetProfileByName(tenantId, name); AssetProfile assetProfile = findAssetProfileByName(tenantId, name, false);
if (assetProfile == null) { if (assetProfile == null) {
try { try {
assetProfile = this.doCreateDefaultAssetProfile(tenantId, name, name.equals("default")); assetProfile = this.doCreateDefaultAssetProfile(tenantId, name, name.equals("default"));
} catch (DataValidationException e) { } catch (DataValidationException e) {
if (ASSET_PROFILE_WITH_SUCH_NAME_ALREADY_EXISTS.equals(e.getMessage())) { if (ASSET_PROFILE_WITH_SUCH_NAME_ALREADY_EXISTS.equals(e.getMessage())) {
assetProfile = findAssetProfileByName(tenantId, name); assetProfile = findAssetProfileByName(tenantId, name, false);
} else { } else {
throw e; throw e;
} }

24
dao/src/main/java/org/thingsboard/server/dao/device/DeviceProfileServiceImpl.java

@ -113,18 +113,28 @@ public class DeviceProfileServiceImpl extends AbstractCachedEntityService<Device
@Override @Override
public DeviceProfile findDeviceProfileById(TenantId tenantId, DeviceProfileId deviceProfileId) { public DeviceProfile findDeviceProfileById(TenantId tenantId, DeviceProfileId deviceProfileId) {
return findDeviceProfileById(tenantId, deviceProfileId, true);
}
@Override
public DeviceProfile findDeviceProfileById(TenantId tenantId, DeviceProfileId deviceProfileId, boolean putInCache) {
log.trace("Executing findDeviceProfileById [{}]", deviceProfileId); log.trace("Executing findDeviceProfileById [{}]", deviceProfileId);
validateId(deviceProfileId, INCORRECT_DEVICE_PROFILE_ID + deviceProfileId); validateId(deviceProfileId, INCORRECT_DEVICE_PROFILE_ID + deviceProfileId);
return cache.getAndPutInTransaction(DeviceProfileCacheKey.fromId(deviceProfileId), return cache.getOrFetchFromDB(DeviceProfileCacheKey.fromId(deviceProfileId),
() -> deviceProfileDao.findById(tenantId, deviceProfileId.getId()), true); () -> deviceProfileDao.findById(tenantId, deviceProfileId.getId()), true, putInCache);
} }
@Override @Override
public DeviceProfile findDeviceProfileByName(TenantId tenantId, String profileName) { public DeviceProfile findDeviceProfileByName(TenantId tenantId, String profileName) {
return findDeviceProfileByName(tenantId, profileName, true);
}
@Override
public DeviceProfile findDeviceProfileByName(TenantId tenantId, String profileName, boolean putInCache) {
log.trace("Executing findDeviceProfileByName [{}][{}]", tenantId, profileName); log.trace("Executing findDeviceProfileByName [{}][{}]", tenantId, profileName);
validateString(profileName, INCORRECT_DEVICE_PROFILE_NAME + profileName); validateString(profileName, INCORRECT_DEVICE_PROFILE_NAME + profileName);
return cache.getAndPutInTransaction(DeviceProfileCacheKey.fromName(tenantId, profileName), return cache.getOrFetchFromDB(DeviceProfileCacheKey.fromName(tenantId, profileName),
() -> deviceProfileDao.findByName(tenantId, profileName), true); () -> deviceProfileDao.findByName(tenantId, profileName), true, putInCache);
} }
@Override @Override
@ -164,7 +174,7 @@ public class DeviceProfileServiceImpl extends AbstractCachedEntityService<Device
if (doValidate) { if (doValidate) {
oldDeviceProfile = deviceProfileValidator.validate(deviceProfile, DeviceProfile::getTenantId); oldDeviceProfile = deviceProfileValidator.validate(deviceProfile, DeviceProfile::getTenantId);
} else if (deviceProfile.getId() != null) { } else if (deviceProfile.getId() != null) {
oldDeviceProfile = findDeviceProfileById(deviceProfile.getTenantId(), deviceProfile.getId()); oldDeviceProfile = findDeviceProfileById(deviceProfile.getTenantId(), deviceProfile.getId(), false);
} }
DeviceProfile savedDeviceProfile; DeviceProfile savedDeviceProfile;
try { try {
@ -252,13 +262,13 @@ public class DeviceProfileServiceImpl extends AbstractCachedEntityService<Device
@Override @Override
public DeviceProfile findOrCreateDeviceProfile(TenantId tenantId, String name) { public DeviceProfile findOrCreateDeviceProfile(TenantId tenantId, String name) {
log.trace("Executing findOrCreateDefaultDeviceProfile"); log.trace("Executing findOrCreateDefaultDeviceProfile");
DeviceProfile deviceProfile = findDeviceProfileByName(tenantId, name); DeviceProfile deviceProfile = findDeviceProfileByName(tenantId, name, false);
if (deviceProfile == null) { if (deviceProfile == null) {
try { try {
deviceProfile = this.doCreateDefaultDeviceProfile(tenantId, name, name.equals("default")); deviceProfile = this.doCreateDefaultDeviceProfile(tenantId, name, name.equals("default"));
} catch (DataValidationException e) { } catch (DataValidationException e) {
if (DEVICE_PROFILE_WITH_SUCH_NAME_ALREADY_EXISTS.equals(e.getMessage())) { if (DEVICE_PROFILE_WITH_SUCH_NAME_ALREADY_EXISTS.equals(e.getMessage())) {
deviceProfile = findDeviceProfileByName(tenantId, name); deviceProfile = findDeviceProfileByName(tenantId, name, false);
} else { } else {
throw e; throw e;
} }

2
dao/src/main/java/org/thingsboard/server/dao/device/DeviceServiceImpl.java

@ -223,7 +223,7 @@ public class DeviceServiceImpl extends AbstractCachedEntityService<DeviceCacheKe
} }
device.setDeviceProfileId(new DeviceProfileId(deviceProfile.getId().getId())); device.setDeviceProfileId(new DeviceProfileId(deviceProfile.getId().getId()));
} else { } else {
deviceProfile = this.deviceProfileService.findDeviceProfileById(device.getTenantId(), device.getDeviceProfileId()); deviceProfile = this.deviceProfileService.findDeviceProfileById(device.getTenantId(), device.getDeviceProfileId(), false);
if (deviceProfile == null) { if (deviceProfile == null) {
throw new DataValidationException("Device is referencing non existing device profile!"); throw new DataValidationException("Device is referencing non existing device profile!");
} }

9
dao/src/main/java/org/thingsboard/server/dao/entityview/EntityViewServiceImpl.java

@ -163,11 +163,16 @@ public class EntityViewServiceImpl extends AbstractCachedEntityService<EntityVie
@Override @Override
public EntityView findEntityViewById(TenantId tenantId, EntityViewId entityViewId) { public EntityView findEntityViewById(TenantId tenantId, EntityViewId entityViewId) {
return findEntityViewById(tenantId, entityViewId, true);
}
@Override
public EntityView findEntityViewById(TenantId tenantId, EntityViewId entityViewId, boolean putInCache) {
log.trace("Executing findEntityViewById [{}]", entityViewId); log.trace("Executing findEntityViewById [{}]", entityViewId);
validateId(entityViewId, INCORRECT_ENTITY_VIEW_ID + entityViewId); validateId(entityViewId, INCORRECT_ENTITY_VIEW_ID + entityViewId);
return cache.getAndPutInTransaction(EntityViewCacheKey.byId(entityViewId), return cache.getOrFetchFromDB(EntityViewCacheKey.byId(entityViewId),
() -> entityViewDao.findById(tenantId, entityViewId.getId()) () -> entityViewDao.findById(tenantId, entityViewId.getId())
, EntityViewCacheValue::getEntityView, v -> new EntityViewCacheValue(v, null), true); , EntityViewCacheValue::getEntityView, v -> new EntityViewCacheValue(v, null), true, putInCache);
} }
@Override @Override

35
dao/src/main/java/org/thingsboard/server/dao/exception/EntitiesLimitException.java

@ -0,0 +1,35 @@
/**
* Copyright © 2016-2023 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.exception;
import lombok.Getter;
import org.thingsboard.server.common.data.EntityType;
import org.thingsboard.server.common.data.id.TenantId;
public class EntitiesLimitException extends DataValidationException {
private static final long serialVersionUID = -9211462514373279196L;
@Getter
private final TenantId tenantId;
@Getter
private final EntityType entityType;
public EntitiesLimitException(TenantId tenantId, EntityType entityType) {
super(entityType.getNormalName() + "s limit reached");
this.tenantId = tenantId;
this.entityType = entityType;
}
}

3
dao/src/main/java/org/thingsboard/server/dao/rule/BaseRuleChainService.java

@ -155,7 +155,7 @@ public class BaseRuleChainService extends AbstractEntityService implements RuleC
if (ruleChain == null) { if (ruleChain == null) {
return RuleChainUpdateResult.failed(); return RuleChainUpdateResult.failed();
} }
RuleChainDataValidator.validateMetaData(ruleChainMetaData); RuleChainDataValidator.validateMetaDataFieldsAndConnections(ruleChainMetaData);
List<RuleNode> nodes = ruleChainMetaData.getNodes(); List<RuleNode> nodes = ruleChainMetaData.getNodes();
List<RuleNode> toAddOrUpdate = new ArrayList<>(); List<RuleNode> toAddOrUpdate = new ArrayList<>();
@ -194,6 +194,7 @@ public class BaseRuleChainService extends AbstractEntityService implements RuleC
for (RuleNode node : toAddOrUpdate) { for (RuleNode node : toAddOrUpdate) {
node.setRuleChainId(ruleChainId); node.setRuleChainId(ruleChainId);
node = ruleNodeUpdater.apply(node); node = ruleNodeUpdater.apply(node);
RuleChainDataValidator.validateRuleNode(node);
RuleNode savedNode = ruleNodeDao.save(tenantId, node); RuleNode savedNode = ruleNodeDao.save(tenantId, node);
relations.add(new EntityRelation(ruleChainMetaData.getRuleChainId(), savedNode.getId(), relations.add(new EntityRelation(ruleChainMetaData.getRuleChainId(), savedNode.getId(),
EntityRelation.CONTAINS_TYPE, RelationTypeGroup.RULE_CHAIN)); EntityRelation.CONTAINS_TYPE, RelationTypeGroup.RULE_CHAIN));

3
dao/src/main/java/org/thingsboard/server/dao/service/DataValidator.java

@ -26,6 +26,7 @@ import org.thingsboard.server.common.data.id.EntityId;
import org.thingsboard.server.common.data.id.TenantId; import org.thingsboard.server.common.data.id.TenantId;
import org.thingsboard.server.dao.TenantEntityWithDataDao; import org.thingsboard.server.dao.TenantEntityWithDataDao;
import org.thingsboard.server.dao.exception.DataValidationException; import org.thingsboard.server.dao.exception.DataValidationException;
import org.thingsboard.server.dao.exception.EntitiesLimitException;
import org.thingsboard.server.dao.usagerecord.ApiLimitService; import org.thingsboard.server.dao.usagerecord.ApiLimitService;
import java.util.HashSet; import java.util.HashSet;
@ -117,7 +118,7 @@ public abstract class DataValidator<D extends BaseData<?>> {
protected void validateNumberOfEntitiesPerTenant(TenantId tenantId, protected void validateNumberOfEntitiesPerTenant(TenantId tenantId,
EntityType entityType) { EntityType entityType) {
if (!apiLimitService.checkEntitiesLimit(tenantId, entityType)) { if (!apiLimitService.checkEntitiesLimit(tenantId, entityType)) {
throw new DataValidationException(entityType.getNormalName() + "s limit reached"); throw new EntitiesLimitException(tenantId, entityType);
} }
} }

9
dao/src/main/java/org/thingsboard/server/dao/service/validator/RuleChainDataValidator.java

@ -87,15 +87,18 @@ public class RuleChainDataValidator extends DataValidator<RuleChain> {
} }
public static List<Throwable> validateMetaData(RuleChainMetaData ruleChainMetaData) { public static List<Throwable> validateMetaData(RuleChainMetaData ruleChainMetaData) {
ConstraintValidator.validateFields(ruleChainMetaData); validateMetaDataFieldsAndConnections(ruleChainMetaData);
List<Throwable> throwables = ruleChainMetaData.getNodes().stream() return ruleChainMetaData.getNodes().stream()
.map(RuleChainDataValidator::validateRuleNode) .map(RuleChainDataValidator::validateRuleNode)
.filter(Objects::nonNull) .filter(Objects::nonNull)
.collect(Collectors.toList()); .collect(Collectors.toList());
}
public static void validateMetaDataFieldsAndConnections(RuleChainMetaData ruleChainMetaData) {
ConstraintValidator.validateFields(ruleChainMetaData);
if (CollectionUtils.isNotEmpty(ruleChainMetaData.getConnections())) { if (CollectionUtils.isNotEmpty(ruleChainMetaData.getConnections())) {
validateCircles(ruleChainMetaData.getConnections()); validateCircles(ruleChainMetaData.getConnections());
} }
return throwables;
} }
public static Throwable validateRuleNode(RuleNode ruleNode) { public static Throwable validateRuleNode(RuleNode ruleNode) {

39
dao/src/test/java/org/thingsboard/server/dao/service/AssetServiceTest.java

@ -20,16 +20,25 @@ import org.junit.Assert;
import org.junit.Test; import org.junit.Test;
import org.junit.jupiter.api.Assertions; import org.junit.jupiter.api.Assertions;
import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.transaction.PlatformTransactionManager;
import org.springframework.transaction.TransactionStatus;
import org.springframework.transaction.support.DefaultTransactionDefinition;
import org.springframework.transaction.support.TransactionCallbackWithoutResult;
import org.springframework.transaction.support.TransactionTemplate;
import org.thingsboard.server.common.data.Customer; import org.thingsboard.server.common.data.Customer;
import org.thingsboard.server.common.data.EntitySubtype; import org.thingsboard.server.common.data.EntitySubtype;
import org.thingsboard.server.common.data.StringUtils; import org.thingsboard.server.common.data.StringUtils;
import org.thingsboard.server.common.data.Tenant; import org.thingsboard.server.common.data.Tenant;
import org.thingsboard.server.common.data.asset.Asset; import org.thingsboard.server.common.data.asset.Asset;
import org.thingsboard.server.common.data.asset.AssetInfo; import org.thingsboard.server.common.data.asset.AssetInfo;
import org.thingsboard.server.common.data.asset.AssetProfile;
import org.thingsboard.server.common.data.id.AssetProfileId;
import org.thingsboard.server.common.data.id.CustomerId; import org.thingsboard.server.common.data.id.CustomerId;
import org.thingsboard.server.common.data.id.TenantId; import org.thingsboard.server.common.data.id.TenantId;
import org.thingsboard.server.common.data.page.PageData; import org.thingsboard.server.common.data.page.PageData;
import org.thingsboard.server.common.data.page.PageLink; import org.thingsboard.server.common.data.page.PageLink;
import org.thingsboard.server.dao.asset.AssetDao;
import org.thingsboard.server.dao.asset.AssetProfileService;
import org.thingsboard.server.dao.asset.AssetService; import org.thingsboard.server.dao.asset.AssetService;
import org.thingsboard.server.dao.customer.CustomerService; import org.thingsboard.server.dao.customer.CustomerService;
import org.thingsboard.server.dao.exception.DataValidationException; import org.thingsboard.server.dao.exception.DataValidationException;
@ -37,6 +46,7 @@ import org.thingsboard.server.dao.exception.DataValidationException;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.Collections; import java.util.Collections;
import java.util.List; import java.util.List;
import java.util.UUID;
import static org.thingsboard.server.dao.model.ModelConstants.NULL_UUID; import static org.thingsboard.server.dao.model.ModelConstants.NULL_UUID;
@ -46,7 +56,13 @@ public class AssetServiceTest extends AbstractServiceTest {
@Autowired @Autowired
AssetService assetService; AssetService assetService;
@Autowired @Autowired
AssetDao assetDao;
@Autowired
CustomerService customerService; CustomerService customerService;
@Autowired
private AssetProfileService assetProfileService;
@Autowired
private PlatformTransactionManager platformTransactionManager;
private IdComparator<Asset> idComparator = new IdComparator<>(); private IdComparator<Asset> idComparator = new IdComparator<>();
@ -75,6 +91,29 @@ public class AssetServiceTest extends AbstractServiceTest {
assetService.deleteAsset(tenantId, savedAsset.getId()); assetService.deleteAsset(tenantId, savedAsset.getId());
} }
@Test
public void testShouldNotPutInCacheRolledbackAssetProfile() {
AssetProfile assetProfile = new AssetProfile();
assetProfile.setName(StringUtils.randomAlphabetic(10));
assetProfile.setTenantId(tenantId);
Asset asset = new Asset();
asset.setName("My asset" + StringUtils.randomAlphabetic(15));
asset.setType(assetProfile.getName());
asset.setTenantId(tenantId);
DefaultTransactionDefinition def = new DefaultTransactionDefinition();
TransactionStatus status = platformTransactionManager.getTransaction(def);
try {
assetProfileService.saveAssetProfile(assetProfile);
assetService.saveAsset(asset);
} finally {
platformTransactionManager.rollback(status);
}
AssetProfile assetProfileByName = assetProfileService.findAssetProfileByName(tenantId, assetProfile.getName());
Assert.assertNull(assetProfileByName);
}
@Test @Test
public void testSaveAssetWithEmptyName() { public void testSaveAssetWithEmptyName() {
Asset asset = new Asset(); Asset asset = new Asset();

29
dao/src/test/java/org/thingsboard/server/dao/service/DeviceServiceTest.java

@ -22,6 +22,9 @@ import org.junit.Before;
import org.junit.Test; import org.junit.Test;
import org.junit.jupiter.api.Assertions; import org.junit.jupiter.api.Assertions;
import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.transaction.PlatformTransactionManager;
import org.springframework.transaction.TransactionStatus;
import org.springframework.transaction.support.DefaultTransactionDefinition;
import org.thingsboard.server.common.data.Customer; import org.thingsboard.server.common.data.Customer;
import org.thingsboard.server.common.data.Device; import org.thingsboard.server.common.data.Device;
import org.thingsboard.server.common.data.DeviceInfo; import org.thingsboard.server.common.data.DeviceInfo;
@ -32,6 +35,8 @@ import org.thingsboard.server.common.data.OtaPackage;
import org.thingsboard.server.common.data.StringUtils; import org.thingsboard.server.common.data.StringUtils;
import org.thingsboard.server.common.data.Tenant; import org.thingsboard.server.common.data.Tenant;
import org.thingsboard.server.common.data.TenantProfile; import org.thingsboard.server.common.data.TenantProfile;
import org.thingsboard.server.common.data.asset.Asset;
import org.thingsboard.server.common.data.asset.AssetProfile;
import org.thingsboard.server.common.data.id.CustomerId; import org.thingsboard.server.common.data.id.CustomerId;
import org.thingsboard.server.common.data.id.TenantId; import org.thingsboard.server.common.data.id.TenantId;
import org.thingsboard.server.common.data.ota.ChecksumAlgorithm; import org.thingsboard.server.common.data.ota.ChecksumAlgorithm;
@ -72,6 +77,8 @@ public class DeviceServiceTest extends AbstractServiceTest {
OtaPackageService otaPackageService; OtaPackageService otaPackageService;
@Autowired @Autowired
TenantProfileService tenantProfileService; TenantProfileService tenantProfileService;
@Autowired
private PlatformTransactionManager platformTransactionManager;
private IdComparator<Device> idComparator = new IdComparator<>(); private IdComparator<Device> idComparator = new IdComparator<>();
private TenantId anotherTenantId; private TenantId anotherTenantId;
@ -305,6 +312,28 @@ public class DeviceServiceTest extends AbstractServiceTest {
}); });
} }
@Test
public void testShouldNotPutInCacheRolledbackDeviceProfile() {
DeviceProfile deviceProfile = createDeviceProfile(tenantId, "New device Profile" + StringUtils.randomAlphabetic(5));
Device device = new Device();
device.setType(deviceProfile.getName());
device.setTenantId(tenantId);
device.setName("My device"+ StringUtils.randomAlphabetic(5));
DefaultTransactionDefinition def = new DefaultTransactionDefinition();
TransactionStatus status = platformTransactionManager.getTransaction(def);
try {
deviceProfileService.saveDeviceProfile(deviceProfile);
deviceService.saveDevice(device);
} finally {
platformTransactionManager.rollback(status);
}
DeviceProfile deviceProfileByName = deviceProfileService.findDeviceProfileByName(tenantId, deviceProfile.getName());
Assert.assertNull(deviceProfileByName);
}
@Test @Test
public void testAssignDeviceToNonExistentCustomer() { public void testAssignDeviceToNonExistentCustomer() {
Device device = new Device(); Device device = new Device();

6
msa/black-box-tests/src/test/java/org/thingsboard/server/msa/ThingsBoardDbInstaller.java

@ -253,12 +253,16 @@ public class ThingsBoardDbInstaller {
.add(tbVcExecutorLogVolume) .add(tbVcExecutorLogVolume)
.add(resolveRedisComposeVolumeLog()); .add(resolveRedisComposeVolumeLog());
if (IS_HYBRID_MODE) {
rmVolumesCommand.add(cassandraDataVolume);
}
dockerCompose.withCommand(rmVolumesCommand.toString()); dockerCompose.withCommand(rmVolumesCommand.toString());
} }
private String resolveRedisComposeVolumeLog() { private String resolveRedisComposeVolumeLog() {
if (IS_REDIS_CLUSTER) { if (IS_REDIS_CLUSTER) {
return IntStream.range(0, 6).mapToObj(i -> redisClusterDataVolume + "-" + i).collect(Collectors.joining()); return IntStream.range(0, 6).mapToObj(i -> " " + redisClusterDataVolume + "-" + i).collect(Collectors.joining());
} }
if (IS_REDIS_SENTINEL) { if (IS_REDIS_SENTINEL) {
return redisSentinelDataVolume + "-" + "master " + " " + return redisSentinelDataVolume + "-" + "master " + " " +

9
msa/black-box-tests/src/test/java/org/thingsboard/server/msa/connectivity/MqttClientTest.java

@ -26,6 +26,7 @@ import io.netty.buffer.Unpooled;
import io.netty.handler.codec.mqtt.MqttQoS; import io.netty.handler.codec.mqtt.MqttQoS;
import lombok.Data; import lombok.Data;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.awaitility.Awaitility;
import org.testng.annotations.AfterMethod; import org.testng.annotations.AfterMethod;
import org.testng.annotations.BeforeMethod; import org.testng.annotations.BeforeMethod;
import org.testng.annotations.Test; import org.testng.annotations.Test;
@ -337,8 +338,12 @@ public class MqttClientTest extends AbstractContainerTest {
MqttClient mqttClient = getMqttClient(deviceCredentials, listener); MqttClient mqttClient = getMqttClient(deviceCredentials, listener);
testRestClient.deleteDeviceIfExists(device.getId()); testRestClient.deleteDeviceIfExists(device.getId());
TimeUnit.SECONDS.sleep(3 * timeoutMultiplier);
assertThat(mqttClient.isConnected()).isFalse(); Awaitility
.await()
.alias("Check device connection.")
.atMost(10, TimeUnit.SECONDS)
.until(() -> !mqttClient.isConnected());
} }
@Test @Test

4
transport/coap/src/main/resources/tb-coap-transport.yml

@ -49,6 +49,10 @@ zk:
cache: cache:
# caffeine or redis # caffeine or redis
type: "${CACHE_TYPE:redis}" type: "${CACHE_TYPE:redis}"
# Deliberately placed outside the 'specs' group above
entityLimits:
timeToLiveInMinutes: "${CACHE_SPECS_ENTITY_LIMITS_TTL:5}" # Entity limits cache TTL
maxSize: "${CACHE_SPECS_ENTITY_LIMITS_MAX_SIZE:100000}" # 0 means the cache is disabled
# Redis configuration parameters # Redis configuration parameters
redis: redis:

4
transport/http/src/main/resources/tb-http-transport.yml

@ -79,6 +79,10 @@ zk:
cache: cache:
# caffeine or redis # caffeine or redis
type: "${CACHE_TYPE:redis}" type: "${CACHE_TYPE:redis}"
# Deliberately placed outside the 'specs' group above
entityLimits:
timeToLiveInMinutes: "${CACHE_SPECS_ENTITY_LIMITS_TTL:5}" # Entity limits cache TTL
maxSize: "${CACHE_SPECS_ENTITY_LIMITS_MAX_SIZE:100000}" # 0 means the cache is disabled
# Redis configuration parameters # Redis configuration parameters
redis: redis:

4
transport/lwm2m/src/main/resources/tb-lwm2m-transport.yml

@ -49,6 +49,10 @@ zk:
cache: cache:
# caffeine or redis # caffeine or redis
type: "${CACHE_TYPE:redis}" type: "${CACHE_TYPE:redis}"
# Deliberately placed outside the 'specs' group above
entityLimits:
timeToLiveInMinutes: "${CACHE_SPECS_ENTITY_LIMITS_TTL:5}" # Entity limits cache TTL
maxSize: "${CACHE_SPECS_ENTITY_LIMITS_MAX_SIZE:100000}" # 0 means the cache is disabled
# Redis configuration parameters # Redis configuration parameters
redis: redis:

4
transport/mqtt/src/main/resources/tb-mqtt-transport.yml

@ -49,6 +49,10 @@ zk:
cache: cache:
# caffeine or redis # caffeine or redis
type: "${CACHE_TYPE:redis}" type: "${CACHE_TYPE:redis}"
# Deliberately placed outside the 'specs' group above
entityLimits:
timeToLiveInMinutes: "${CACHE_SPECS_ENTITY_LIMITS_TTL:5}" # Entity limits cache TTL
maxSize: "${CACHE_SPECS_ENTITY_LIMITS_MAX_SIZE:100000}" # 0 means the cache is disabled
# Redis configuration parameters # Redis configuration parameters
redis: redis:

4
transport/snmp/src/main/resources/tb-snmp-transport.yml

@ -49,6 +49,10 @@ zk:
cache: cache:
# caffeine or redis # caffeine or redis
type: "${CACHE_TYPE:redis}" type: "${CACHE_TYPE:redis}"
# Deliberately placed outside the 'specs' group above
entityLimits:
timeToLiveInMinutes: "${CACHE_SPECS_ENTITY_LIMITS_TTL:5}" # Entity limits cache TTL
maxSize: "${CACHE_SPECS_ENTITY_LIMITS_MAX_SIZE:100000}" # 0 means the cache is disabled
# Redis configuration parameters # Redis configuration parameters
redis: redis:

12
ui-ngx/src/app/modules/home/components/widget/config/basic/basic-widget-config.module.ts

@ -85,6 +85,9 @@ import {
import { import {
DoughnutBasicConfigComponent DoughnutBasicConfigComponent
} from '@home/components/widget/config/basic/chart/doughnut-basic-config.component'; } from '@home/components/widget/config/basic/chart/doughnut-basic-config.component';
import {
RangeChartBasicConfigComponent
} from '@home/components/widget/config/basic/chart/range-chart-basic-config.component';
@NgModule({ @NgModule({
declarations: [ declarations: [
@ -111,7 +114,8 @@ import {
ThermometerScaleGaugeBasicConfigComponent, ThermometerScaleGaugeBasicConfigComponent,
CompassGaugeBasicConfigComponent, CompassGaugeBasicConfigComponent,
LiquidLevelCardBasicConfigComponent, LiquidLevelCardBasicConfigComponent,
DoughnutBasicConfigComponent DoughnutBasicConfigComponent,
RangeChartBasicConfigComponent
], ],
imports: [ imports: [
CommonModule, CommonModule,
@ -142,7 +146,8 @@ import {
ThermometerScaleGaugeBasicConfigComponent, ThermometerScaleGaugeBasicConfigComponent,
CompassGaugeBasicConfigComponent, CompassGaugeBasicConfigComponent,
LiquidLevelCardBasicConfigComponent, LiquidLevelCardBasicConfigComponent,
DoughnutBasicConfigComponent DoughnutBasicConfigComponent,
RangeChartBasicConfigComponent
] ]
}) })
export class BasicWidgetConfigModule { export class BasicWidgetConfigModule {
@ -167,5 +172,6 @@ export const basicWidgetConfigComponentsMap: {[key: string]: Type<IBasicWidgetCo
'tb-thermometer-scale-gauge-basic-config': ThermometerScaleGaugeBasicConfigComponent, 'tb-thermometer-scale-gauge-basic-config': ThermometerScaleGaugeBasicConfigComponent,
'tb-compass-gauge-basic-config': CompassGaugeBasicConfigComponent, 'tb-compass-gauge-basic-config': CompassGaugeBasicConfigComponent,
'tb-liquid-level-card-basic-config': LiquidLevelCardBasicConfigComponent, 'tb-liquid-level-card-basic-config': LiquidLevelCardBasicConfigComponent,
'tb-doughnut-basic-config': DoughnutBasicConfigComponent 'tb-doughnut-basic-config': DoughnutBasicConfigComponent,
'tb-range-chart-basic-config': RangeChartBasicConfigComponent
}; };

1
ui-ngx/src/app/modules/home/components/widget/config/basic/cards/value-card-basic-config.component.html

@ -22,6 +22,7 @@
</tb-timewindow-config-panel> </tb-timewindow-config-panel>
<tb-datasources <tb-datasources
[configMode]="basicMode" [configMode]="basicMode"
hideDatasourceLabel
hideDataKeyLabel hideDataKeyLabel
hideDataKeyColor hideDataKeyColor
hideDataKeyUnits hideDataKeyUnits

1
ui-ngx/src/app/modules/home/components/widget/config/basic/chart/doughnut-basic-config.component.html

@ -22,6 +22,7 @@
</tb-timewindow-config-panel> </tb-timewindow-config-panel>
<tb-datasources <tb-datasources
[configMode]="basicMode" [configMode]="basicMode"
hideDatasourceLabel
hideDataKeys hideDataKeys
forceSingleDatasource forceSingleDatasource
formControlName="datasources"> formControlName="datasources">

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

@ -0,0 +1,222 @@
<!--
Copyright © 2016-2023 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.
-->
<ng-container [formGroup]="rangeChartWidgetConfigForm">
<tb-timewindow-config-panel formControlName="timewindowConfig">
</tb-timewindow-config-panel>
<tb-datasources
[configMode]="basicMode"
hideDatasourceLabel
hideDataKeyColor
hideDataKeyUnits
hideDataKeyDecimals
formControlName="datasources">
</tb-datasources>
<div class="tb-form-panel">
<div class="tb-form-panel-title" translate>widget-config.appearance</div>
<div class="tb-form-row column-xs">
<mat-slide-toggle class="mat-slide fixed-title-width" formControlName="showTitle">
{{ 'widget-config.title' | translate }}
</mat-slide-toggle>
<div fxFlex fxLayout="row" fxLayoutAlign="start center" fxLayoutGap="8px">
<mat-form-field class="flex" appearance="outline" subscriptSizing="dynamic">
<input matInput formControlName="title" placeholder="{{ 'widget-config.set' | translate }}">
</mat-form-field>
<tb-font-settings formControlName="titleFont"
clearButton
[previewText]="rangeChartWidgetConfigForm.get('title').value"
[initialPreviewStyle]="widgetConfig.config.titleStyle">
</tb-font-settings>
<tb-color-input asBoxInput
colorClearButton
formControlName="titleColor">
</tb-color-input>
</div>
</div>
<div class="tb-form-row column-xs">
<mat-slide-toggle class="mat-slide fixed-title-width" formControlName="showIcon">
{{ 'widgets.value-chart-card.icon' | translate }}
</mat-slide-toggle>
<div fxFlex fxLayout="row" fxLayoutAlign="start center" fxLayoutGap="8px">
<mat-form-field appearance="outline" class="flex number" subscriptSizing="dynamic">
<input matInput type="number" min="0" formControlName="iconSize" placeholder="{{ 'widget-config.set' | translate }}">
</mat-form-field>
<tb-css-unit-select fxFlex formControlName="iconSizeUnit"></tb-css-unit-select>
<tb-material-icon-select asBoxInput
iconClearButton
[color]="rangeChartWidgetConfigForm.get('iconColor').value"
formControlName="icon">
</tb-material-icon-select>
<tb-color-input asBoxInput
colorClearButton
formControlName="iconColor">
</tb-color-input>
</div>
</div>
</div>
<div class="tb-form-panel">
<div class="tb-form-panel-title" translate>widgets.range-chart.chart</div>
<div class="tb-form-row">
<mat-slide-toggle class="mat-slide" formControlName="dataZoom">
{{ 'widgets.range-chart.data-zoom' | translate }}
</mat-slide-toggle>
</div>
<div class="tb-form-row space-between">
<div>{{ 'widgets.range-chart.range-colors' | translate }}</div>
<tb-color-range-settings formControlName="rangeColors">
</tb-color-range-settings>
</div>
<div class="tb-form-row space-between">
<div>{{ 'widgets.range-chart.out-of-range-color' | translate }}</div>
<tb-color-input asBoxInput
colorClearButton
formControlName="outOfRangeColor">
</tb-color-input>
</div>
<div class="tb-form-row">
<mat-slide-toggle class="mat-slide" formControlName="fillArea">
{{ 'widgets.range-chart.fill-area' | translate }}
</mat-slide-toggle>
</div>
<div class="tb-form-row space-between">
<div translate>widget-config.units-short</div>
<tb-unit-input
formControlName="units">
</tb-unit-input>
</div>
<div class="tb-form-row space-between">
<div translate>widget-config.decimals-short</div>
<mat-form-field appearance="outline" class="number" subscriptSizing="dynamic">
<input matInput formControlName="decimals" type="number" min="0" max="15" step="1" placeholder="{{ 'widget-config.set' | translate }}">
</mat-form-field>
</div>
</div>
<div class="tb-form-panel tb-slide-toggle">
<mat-expansion-panel class="tb-settings" [expanded]="rangeChartWidgetConfigForm.get('showLegend').value" [disabled]="!rangeChartWidgetConfigForm.get('showLegend').value">
<mat-expansion-panel-header fxLayout="row wrap">
<mat-panel-title>
<mat-slide-toggle class="mat-slide" formControlName="showLegend" (click)="$event.stopPropagation()"
fxLayoutAlign="center">
{{ 'widget-config.legend' | translate }}
</mat-slide-toggle>
</mat-panel-title>
</mat-expansion-panel-header>
<ng-template matExpansionPanelContent>
<div class="tb-form-row space-between">
<div>{{ 'legend.position' | translate }}</div>
<mat-form-field class="medium-width" appearance="outline" subscriptSizing="dynamic">
<mat-select formControlName="legendPosition">
<mat-option *ngFor="let pos of legendPositions" [value]="pos">
{{ legendPositionTranslationMap.get(pos) | translate }}
</mat-option>
</mat-select>
</mat-form-field>
</div>
<div class="tb-form-row space-between">
<div>{{ 'widgets.range-chart.legend-label' | translate }}</div>
<div fxLayout="row" fxLayoutAlign="start center" fxLayoutGap="8px">
<tb-font-settings formControlName="legendLabelFont"
previewText="20 - 30">
</tb-font-settings>
<tb-color-input asBoxInput
colorClearButton
formControlName="legendLabelColor">
</tb-color-input>
</div>
</div>
</ng-template>
</mat-expansion-panel>
</div>
<div class="tb-form-panel tb-slide-toggle">
<mat-expansion-panel class="tb-settings" [expanded]="rangeChartWidgetConfigForm.get('showTooltip').value" [disabled]="!rangeChartWidgetConfigForm.get('showTooltip').value">
<mat-expansion-panel-header fxLayout="row wrap">
<mat-panel-title>
<mat-slide-toggle class="mat-slide" formControlName="showTooltip" (click)="$event.stopPropagation()"
fxLayoutAlign="center">
{{ 'widgets.range-chart.tooltip' | translate }}
</mat-slide-toggle>
</mat-panel-title>
</mat-expansion-panel-header>
<ng-template matExpansionPanelContent>
<div class="tb-form-row space-between">
<div>{{ 'widgets.range-chart.tooltip-value' | translate }}</div>
<div fxLayout="row" fxLayoutAlign="start center" fxLayoutGap="8px">
<tb-font-settings formControlName="tooltipValueFont"
[previewText]="tooltipValuePreviewFn">
</tb-font-settings>
<tb-color-input asBoxInput
colorClearButton
formControlName="tooltipValueColor">
</tb-color-input>
</div>
</div>
<div class="tb-form-row column-xs">
<mat-slide-toggle class="mat-slide fixed-title-width" formControlName="tooltipShowDate">
{{ 'widgets.range-chart.tooltip-date' | translate }}
</mat-slide-toggle>
<div fxFlex.gt-xs fxLayout="row" fxLayoutAlign="start center" fxLayoutGap="8px">
<tb-date-format-select fxFlex excludeLastUpdateAgo formControlName="tooltipDateFormat"></tb-date-format-select>
<tb-font-settings formControlName="tooltipDateFont"
[previewText]="tooltipDatePreviewFn">
</tb-font-settings>
<tb-color-input asBoxInput
colorClearButton
formControlName="tooltipDateColor">
</tb-color-input>
</div>
</div>
<div class="tb-form-row space-between">
<div>{{ 'widgets.range-chart.tooltip-background-color' | translate }}</div>
<tb-color-input asBoxInput
colorClearButton
formControlName="tooltipBackgroundColor">
</tb-color-input>
</div>
<div class="tb-form-row space-between">
<div>{{ 'widgets.range-chart.tooltip-background-blur' | translate }}</div>
<mat-form-field appearance="outline" class="number" subscriptSizing="dynamic">
<input matInput formControlName="tooltipBackgroundBlur" type="number" min="0" step="1" placeholder="{{ 'widget-config.set' | translate }}">
<div matSuffix>px</div>
</mat-form-field>
</div>
</ng-template>
</mat-expansion-panel>
</div>
<div class="tb-form-panel">
<div class="tb-form-panel-title" translate>widget-config.card-appearance</div>
<div class="tb-form-row space-between">
<div>{{ 'widgets.background.background' | translate }}</div>
<tb-background-settings formControlName="background">
</tb-background-settings>
</div>
<div class="tb-form-row space-between column-lt-md">
<div translate>widget-config.show-card-buttons</div>
<mat-chip-listbox multiple formControlName="cardButtons">
<mat-chip-option value="fullscreen">{{ 'fullscreen.fullscreen' | translate }}</mat-chip-option>
</mat-chip-listbox>
</div>
<div class="tb-form-row space-between">
<div>{{ 'widget-config.card-border-radius' | translate }}</div>
<mat-form-field appearance="outline" subscriptSizing="dynamic">
<input matInput formControlName="borderRadius" placeholder="{{ 'widget-config.set' | translate }}">
</mat-form-field>
</div>
</div>
<tb-widget-actions-panel
formControlName="actions">
</tb-widget-actions-panel>
</ng-container>

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

@ -0,0 +1,269 @@
///
/// Copyright © 2016-2023 The Thingsboard Authors
///
/// Licensed under the Apache License, Version 2.0 (the "License");
/// you may not use this file except in compliance with the License.
/// You may obtain a copy of the License at
///
/// http://www.apache.org/licenses/LICENSE-2.0
///
/// Unless required by applicable law or agreed to in writing, software
/// distributed under the License is distributed on an "AS IS" BASIS,
/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
/// See the License for the specific language governing permissions and
/// limitations under the License.
///
import { ChangeDetectorRef, Component, Injector } from '@angular/core';
import { UntypedFormBuilder, UntypedFormGroup, Validators } from '@angular/forms';
import { Store } from '@ngrx/store';
import { AppState } from '@core/core.state';
import { BasicWidgetConfigComponent } from '@home/components/widget/config/widget-config.component.models';
import { WidgetConfigComponentData } from '@home/models/widget-component.models';
import { DataKey, legendPositions, legendPositionTranslationMap, WidgetConfig, } from '@shared/models/widget.models';
import { WidgetConfigComponent } from '@home/components/widget/widget-config.component';
import { DataKeyType } from '@shared/models/telemetry/telemetry.models';
import {
getTimewindowConfig,
setTimewindowConfig
} from '@home/components/widget/config/timewindow-config-panel.component';
import { formatValue, isUndefined } from '@core/utils';
import {
cssSizeToStrSize,
DateFormatProcessor,
DateFormatSettings,
resolveCssSize
} from '@shared/models/widget-settings.models';
import {
rangeChartDefaultSettings,
RangeChartWidgetSettings
} from '@home/components/widget/lib/chart/range-chart-widget.models';
@Component({
selector: 'tb-range-chart-basic-config',
templateUrl: './range-chart-basic-config.component.html',
styleUrls: ['../basic-config.scss']
})
export class RangeChartBasicConfigComponent extends BasicWidgetConfigComponent {
legendPositions = legendPositions;
legendPositionTranslationMap = legendPositionTranslationMap;
rangeChartWidgetConfigForm: UntypedFormGroup;
tooltipValuePreviewFn = this._tooltipValuePreviewFn.bind(this);
tooltipDatePreviewFn = this._tooltipDatePreviewFn.bind(this);
constructor(protected store: Store<AppState>,
protected widgetConfigComponent: WidgetConfigComponent,
private $injector: Injector,
private fb: UntypedFormBuilder) {
super(store, widgetConfigComponent);
}
protected configForm(): UntypedFormGroup {
return this.rangeChartWidgetConfigForm;
}
protected defaultDataKeys(configData: WidgetConfigComponentData): DataKey[] {
return [{ name: 'temperature', label: 'Temperature', type: DataKeyType.timeseries }];
}
protected onConfigSet(configData: WidgetConfigComponentData) {
const settings: RangeChartWidgetSettings = {...rangeChartDefaultSettings, ...(configData.config.settings || {})};
const iconSize = resolveCssSize(configData.config.iconSize);
this.rangeChartWidgetConfigForm = this.fb.group({
timewindowConfig: [getTimewindowConfig(configData.config), []],
datasources: [configData.config.datasources, []],
showTitle: [configData.config.showTitle, []],
title: [configData.config.title, []],
titleFont: [configData.config.titleFont, []],
titleColor: [configData.config.titleColor, []],
showIcon: [configData.config.showTitleIcon, []],
iconSize: [iconSize[0], [Validators.min(0)]],
iconSizeUnit: [iconSize[1], []],
icon: [configData.config.titleIcon, []],
iconColor: [configData.config.iconColor, []],
dataZoom: [settings.dataZoom, []],
rangeColors: [settings.rangeColors, []],
outOfRangeColor: [settings.outOfRangeColor, []],
fillArea: [settings.fillArea, []],
units: [configData.config.units, []],
decimals: [configData.config.decimals, []],
showLegend: [settings.showLegend, []],
legendPosition: [settings.legendPosition, []],
legendLabelFont: [settings.legendLabelFont, []],
legendLabelColor: [settings.legendLabelColor, []],
showTooltip: [settings.showTooltip, []],
tooltipValueFont: [settings.tooltipValueFont, []],
tooltipValueColor: [settings.tooltipValueColor, []],
tooltipShowDate: [settings.tooltipShowDate, []],
tooltipDateFormat: [settings.tooltipDateFormat, []],
tooltipDateFont: [settings.tooltipDateFont, []],
tooltipDateColor: [settings.tooltipDateColor, []],
tooltipBackgroundColor: [settings.tooltipBackgroundColor, []],
tooltipBackgroundBlur: [settings.tooltipBackgroundBlur, []],
background: [settings.background, []],
cardButtons: [this.getCardButtons(configData.config), []],
borderRadius: [configData.config.borderRadius, []],
actions: [configData.config.actions || {}, []]
});
}
protected prepareOutputConfig(config: any): WidgetConfigComponentData {
setTimewindowConfig(this.widgetConfig.config, config.timewindowConfig);
this.widgetConfig.config.datasources = config.datasources;
this.widgetConfig.config.showTitle = config.showTitle;
this.widgetConfig.config.title = config.title;
this.widgetConfig.config.titleFont = config.titleFont;
this.widgetConfig.config.titleColor = config.titleColor;
this.widgetConfig.config.showTitleIcon = config.showIcon;
this.widgetConfig.config.iconSize = cssSizeToStrSize(config.iconSize, config.iconSizeUnit);
this.widgetConfig.config.titleIcon = config.icon;
this.widgetConfig.config.iconColor = config.iconColor;
this.widgetConfig.config.settings = this.widgetConfig.config.settings || {};
this.widgetConfig.config.settings.dataZoom = config.dataZoom;
this.widgetConfig.config.settings.rangeColors = config.rangeColors;
this.widgetConfig.config.settings.outOfRangeColor = config.outOfRangeColor;
this.widgetConfig.config.settings.fillArea = config.fillArea;
this.widgetConfig.config.units = config.units;
this.widgetConfig.config.decimals = config.decimals;
this.widgetConfig.config.settings.showLegend = config.showLegend;
this.widgetConfig.config.settings.legendPosition = config.legendPosition;
this.widgetConfig.config.settings.legendLabelFont = config.legendLabelFont;
this.widgetConfig.config.settings.legendLabelColor = config.legendLabelColor;
this.widgetConfig.config.settings.showTooltip = config.showTooltip;
this.widgetConfig.config.settings.tooltipValueFont = config.tooltipValueFont;
this.widgetConfig.config.settings.tooltipValueColor = config.tooltipValueColor;
this.widgetConfig.config.settings.tooltipShowDate = config.tooltipShowDate;
this.widgetConfig.config.settings.tooltipDateFormat = config.tooltipDateFormat;
this.widgetConfig.config.settings.tooltipDateFont = config.tooltipDateFont;
this.widgetConfig.config.settings.tooltipDateColor = config.tooltipDateColor;
this.widgetConfig.config.settings.tooltipBackgroundColor = config.tooltipBackgroundColor;
this.widgetConfig.config.settings.tooltipBackgroundBlur = config.tooltipBackgroundBlur;
this.widgetConfig.config.settings.background = config.background;
this.setCardButtons(config.cardButtons, this.widgetConfig.config);
this.widgetConfig.config.borderRadius = config.borderRadius;
this.widgetConfig.config.actions = config.actions;
return this.widgetConfig;
}
protected validatorTriggers(): string[] {
return ['showTitle', 'showIcon', 'showLegend', 'showTooltip', 'tooltipShowDate'];
}
protected updateValidators(emitEvent: boolean, trigger?: string) {
const showTitle: boolean = this.rangeChartWidgetConfigForm.get('showTitle').value;
const showIcon: boolean = this.rangeChartWidgetConfigForm.get('showIcon').value;
const showLegend: boolean = this.rangeChartWidgetConfigForm.get('showLegend').value;
const showTooltip: boolean = this.rangeChartWidgetConfigForm.get('showTooltip').value;
const tooltipShowDate: boolean = this.rangeChartWidgetConfigForm.get('tooltipShowDate').value;
if (showTitle) {
this.rangeChartWidgetConfigForm.get('title').enable();
this.rangeChartWidgetConfigForm.get('titleFont').enable();
this.rangeChartWidgetConfigForm.get('titleColor').enable();
this.rangeChartWidgetConfigForm.get('showIcon').enable({emitEvent: false});
if (showIcon) {
this.rangeChartWidgetConfigForm.get('iconSize').enable();
this.rangeChartWidgetConfigForm.get('iconSizeUnit').enable();
this.rangeChartWidgetConfigForm.get('icon').enable();
this.rangeChartWidgetConfigForm.get('iconColor').enable();
} else {
this.rangeChartWidgetConfigForm.get('iconSize').disable();
this.rangeChartWidgetConfigForm.get('iconSizeUnit').disable();
this.rangeChartWidgetConfigForm.get('icon').disable();
this.rangeChartWidgetConfigForm.get('iconColor').disable();
}
} else {
this.rangeChartWidgetConfigForm.get('title').disable();
this.rangeChartWidgetConfigForm.get('titleFont').disable();
this.rangeChartWidgetConfigForm.get('titleColor').disable();
this.rangeChartWidgetConfigForm.get('showIcon').disable({emitEvent: false});
this.rangeChartWidgetConfigForm.get('iconSize').disable();
this.rangeChartWidgetConfigForm.get('iconSizeUnit').disable();
this.rangeChartWidgetConfigForm.get('icon').disable();
this.rangeChartWidgetConfigForm.get('iconColor').disable();
}
if (showLegend) {
this.rangeChartWidgetConfigForm.get('legendPosition').enable();
this.rangeChartWidgetConfigForm.get('legendLabelFont').enable();
this.rangeChartWidgetConfigForm.get('legendLabelColor').enable();
} else {
this.rangeChartWidgetConfigForm.get('legendPosition').disable();
this.rangeChartWidgetConfigForm.get('legendLabelFont').disable();
this.rangeChartWidgetConfigForm.get('legendLabelColor').disable();
}
if (showTooltip) {
this.rangeChartWidgetConfigForm.get('tooltipValueFont').enable();
this.rangeChartWidgetConfigForm.get('tooltipValueColor').enable();
this.rangeChartWidgetConfigForm.get('tooltipShowDate').enable({emitEvent: false});
this.rangeChartWidgetConfigForm.get('tooltipBackgroundColor').enable();
this.rangeChartWidgetConfigForm.get('tooltipBackgroundBlur').enable();
if (tooltipShowDate) {
this.rangeChartWidgetConfigForm.get('tooltipDateFormat').enable();
this.rangeChartWidgetConfigForm.get('tooltipDateFont').enable();
this.rangeChartWidgetConfigForm.get('tooltipDateColor').enable();
} else {
this.rangeChartWidgetConfigForm.get('tooltipDateFormat').disable();
this.rangeChartWidgetConfigForm.get('tooltipDateFont').disable();
this.rangeChartWidgetConfigForm.get('tooltipDateColor').disable();
}
} else {
this.rangeChartWidgetConfigForm.get('tooltipValueFont').disable();
this.rangeChartWidgetConfigForm.get('tooltipValueColor').disable();
this.rangeChartWidgetConfigForm.get('tooltipShowDate').disable({emitEvent: false});
this.rangeChartWidgetConfigForm.get('tooltipDateFormat').disable();
this.rangeChartWidgetConfigForm.get('tooltipDateFont').disable();
this.rangeChartWidgetConfigForm.get('tooltipDateColor').disable();
this.rangeChartWidgetConfigForm.get('tooltipBackgroundColor').disable();
this.rangeChartWidgetConfigForm.get('tooltipBackgroundBlur').disable();
}
}
private getCardButtons(config: WidgetConfig): string[] {
const buttons: string[] = [];
if (isUndefined(config.enableFullscreen) || config.enableFullscreen) {
buttons.push('fullscreen');
}
return buttons;
}
private setCardButtons(buttons: string[], config: WidgetConfig) {
config.enableFullscreen = buttons.includes('fullscreen');
}
private _tooltipValuePreviewFn(): string {
const units: string = this.rangeChartWidgetConfigForm.get('units').value;
const decimals: number = this.rangeChartWidgetConfigForm.get('decimals').value;
return formatValue(22, decimals, units, false);
}
private _tooltipDatePreviewFn(): string {
const dateFormat: DateFormatSettings = this.rangeChartWidgetConfigForm.get('tooltipDateFormat').value;
const processor = DateFormatProcessor.fromSettings(this.$injector, dateFormat);
processor.update(Date.now());
return processor.formatted;
}
}

34
ui-ngx/src/app/modules/home/components/widget/lib/chart/range-chart-widget.component.html

@ -0,0 +1,34 @@
<!--
Copyright © 2016-2023 The Thingsboard Authors
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
-->
<div class="tb-range-chart-panel" [style]="backgroundStyle">
<div class="tb-range-chart-overlay" [style]="overlayStyle"></div>
<ng-container *ngTemplateOutlet="widgetTitlePanel"></ng-container>
<div class="tb-range-chart-content" [class]="legendClass">
<div #chartShape class="tb-range-chart-shape">
</div>
<div *ngIf="showLegend" class="tb-range-chart-legend">
<div class="tb-range-chart-legend-item" *ngFor="let rangeItem of visibleRangeItems"
(click)="toggleRangeItem(rangeItem)">
<div class="tb-range-chart-legend-item-label">
<div class="tb-range-chart-legend-item-label-circle" [style]="{background: rangeItem.enabled ? rangeItem.color : null}"></div>
<div [style]="rangeItem.enabled ? legendLabelStyle : disabledLegendLabelStyle">{{ rangeItem.label }}</div>
</div>
</div>
</div>
</div>
</div>

105
ui-ngx/src/app/modules/home/components/widget/lib/chart/range-chart-widget.component.scss

@ -0,0 +1,105 @@
/**
* Copyright © 2016-2023 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.
*/
.tb-range-chart-panel {
width: 100%;
height: 100%;
position: relative;
display: flex;
flex-direction: column;
gap: 16px;
padding: 20px 24px 24px 24px;
> div:not(.tb-range-chart-overlay) {
z-index: 1;
}
.tb-range-chart-overlay {
position: absolute;
top: 12px;
left: 12px;
bottom: 12px;
right: 12px;
}
div.tb-widget-title {
padding: 0;
}
.tb-range-chart-content {
flex: 1;
min-width: 0;
min-height: 0;
display: flex;
flex-direction: column;
gap: 16px;
&.legend-top {
flex-direction: column-reverse;
}
&.legend-right {
flex-direction: row;
}
&.legend-left {
flex-direction: row-reverse;
}
.tb-range-chart-shape {
flex: 1;
min-width: 0;
min-height: 0;
display: flex;
align-items: center;
justify-content: center;
}
.tb-range-chart-legend {
display: flex;
justify-content: center;
align-items: center;
align-self: stretch;
flex-wrap: wrap;
column-gap: 24px;
row-gap: 8px;
.tb-range-chart-legend-item {
display: flex;
flex-direction: column;
justify-content: center;
align-items: flex-start;
user-select: none;
cursor: pointer;
.tb-range-chart-legend-item-label {
display: flex;
align-items: center;
gap: 4px;
color: #ccc;
.tb-range-chart-legend-item-label-circle {
width: 8px;
height: 8px;
border-radius: 50%;
background-color: #ccc;
}
}
}
}
&.legend-right, &.legend-left {
gap: 24px;
.tb-range-chart-legend {
flex-direction: column-reverse;
justify-content: flex-end;
align-items: stretch;
.tb-range-chart-legend-item {
flex-direction: row;
justify-content: space-between;
align-items: center;
}
}
}
}
}

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

@ -0,0 +1,527 @@
///
/// Copyright © 2016-2023 The Thingsboard Authors
///
/// Licensed under the Apache License, Version 2.0 (the "License");
/// you may not use this file except in compliance with the License.
/// You may obtain a copy of the License at
///
/// http://www.apache.org/licenses/LICENSE-2.0
///
/// Unless required by applicable law or agreed to in writing, software
/// distributed under the License is distributed on an "AS IS" BASIS,
/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
/// See the License for the specific language governing permissions and
/// limitations under the License.
///
import {
AfterViewInit,
ChangeDetectorRef,
Component,
ElementRef,
Input,
OnDestroy,
OnInit,
Renderer2,
TemplateRef,
ViewChild,
ViewEncapsulation
} from '@angular/core';
import { WidgetContext } from '@home/models/widget-component.models';
import {
backgroundStyle,
ColorRange,
ComponentStyle,
DateFormatProcessor, filterIncludingColorRanges,
getDataKey,
overlayStyle,
sortedColorRange,
textStyle
} from '@shared/models/widget-settings.models';
import { ResizeObserver } from '@juggle/resize-observer';
import * as echarts from 'echarts/core';
import { formatValue, isDefinedAndNotNull, isNumber } from '@core/utils';
import {
DataZoomComponent,
DataZoomComponentOption,
GridComponent,
GridComponentOption,
MarkLineComponent,
MarkLineComponentOption,
TooltipComponent,
TooltipComponentOption,
VisualMapComponent,
VisualMapComponentOption
} from 'echarts/components';
import { LineChart, LineSeriesOption, } from 'echarts/charts';
import { CanvasRenderer } from 'echarts/renderers';
import { rangeChartDefaultSettings, RangeChartWidgetSettings } from './range-chart-widget.models';
import { DataSet } from '@shared/models/widget.models';
echarts.use([
TooltipComponent,
GridComponent,
VisualMapComponent,
DataZoomComponent,
MarkLineComponent,
LineChart,
CanvasRenderer
]);
type EChartsOption = echarts.ComposeOption<
| TooltipComponentOption
| GridComponentOption
| VisualMapComponentOption
| DataZoomComponentOption
| MarkLineComponentOption
| LineSeriesOption
>;
type ECharts = echarts.ECharts;
interface VisualPiece {
lt?: number;
gt?: number;
lte?: number;
gte?: number;
value?: number;
color?: string;
}
interface RangeItem {
index: number;
from?: number;
to?: number;
piece: VisualPiece;
color: string;
label: string;
visible: boolean;
enabled: boolean;
}
const rangeItemLabel = (from?: number, to?: number): string => {
if (isNumber(from) && isNumber(to)) {
if (from === to) {
return `${from}`;
} else {
return `${from} - ${to}`;
}
} else if (isNumber(from)) {
return `≥ ${from}`;
} else if (isNumber(to)) {
return `< ${to}`;
} else {
return null;
}
};
const toVisualPiece = (color: string, from?: number, to?: number): VisualPiece => {
const piece: VisualPiece = {
color
};
if (isNumber(from) && isNumber(to)) {
if (from === to) {
piece.value = from;
} else {
piece.gte = from;
piece.lt = to;
}
} else if (isNumber(from)) {
piece.gte = from;
} else if (isNumber(to)) {
piece.lt = to;
}
return piece;
};
const toRangeItems = (colorRanges: Array<ColorRange>): RangeItem[] => {
const rangeItems: RangeItem[] = [];
let counter = 0;
const ranges = sortedColorRange(filterIncludingColorRanges(colorRanges)).filter(r => isNumber(r.from) || isNumber(r.to));
for (let i = 0; i < ranges.length; i++) {
const range = ranges[i];
let from = range.from;
const to = range.to;
if (i > 0) {
const prevRange = ranges[i - 1];
if (isNumber(prevRange.to) && isNumber(from) && from < prevRange.to) {
from = prevRange.to;
}
}
rangeItems.push(
{
index: counter++,
color: range.color,
enabled: true,
visible: true,
from,
to,
label: rangeItemLabel(from, to),
piece: toVisualPiece(range.color, from, to)
}
);
if (!isNumber(from) || !isNumber(to)) {
const value = !isNumber(from) ? to : from;
rangeItems.push(
{
index: counter++,
color: 'transparent',
enabled: true,
visible: false,
label: '',
piece: { gt: value - 0.000000001, lt: value + 0.000000001, color: 'transparent'}
}
);
}
}
return rangeItems;
};
const toNamedData = (data: DataSet): {name: string; value: [number, any]}[] => {
if (!data?.length) {
return [];
} else {
return data.map(d => ({
name: d[0] + '',
value: d
}));
}
};
const getMarkPoints = (ranges: Array<RangeItem>): number[] => {
const points = new Set<number>();
for (const range of ranges) {
if (range.visible) {
if (isNumber(range.from)) {
points.add(range.from);
}
if (isNumber(range.to)) {
points.add(range.to);
}
}
}
return Array.from(points).sort();
};
@Component({
selector: 'tb-range-chart-widget',
templateUrl: './range-chart-widget.component.html',
styleUrls: ['./range-chart-widget.component.scss'],
encapsulation: ViewEncapsulation.None
})
export class RangeChartWidgetComponent implements OnInit, OnDestroy, AfterViewInit {
@ViewChild('chartShape', {static: false})
chartShape: ElementRef<HTMLElement>;
settings: RangeChartWidgetSettings;
@Input()
ctx: WidgetContext;
@Input()
widgetTitlePanel: TemplateRef<any>;
showLegend: boolean;
legendClass: string;
backgroundStyle: ComponentStyle = {};
overlayStyle: ComponentStyle = {};
legendLabelStyle: ComponentStyle;
disabledLegendLabelStyle: ComponentStyle;
visibleRangeItems: RangeItem[];
private rangeItems: RangeItem[];
private shapeResize$: ResizeObserver;
private decimals = 0;
private units = '';
private drawChartPending = false;
private rangeChart: ECharts;
private rangeChartOptions: EChartsOption;
private selectedRanges: {[key: number]: boolean} = {};
private tooltipDateFormat: DateFormatProcessor;
constructor(private renderer: Renderer2,
private cd: ChangeDetectorRef) {
}
ngOnInit(): void {
this.ctx.$scope.rangeChartWidget = this;
this.settings = {...rangeChartDefaultSettings, ...this.ctx.settings};
this.decimals = this.ctx.decimals;
this.units = this.ctx.units;
const dataKey = getDataKey(this.ctx.datasources);
if (isDefinedAndNotNull(dataKey?.decimals)) {
this.decimals = dataKey.decimals;
}
if (dataKey?.units) {
this.units = dataKey.units;
}
this.backgroundStyle = backgroundStyle(this.settings.background);
this.overlayStyle = overlayStyle(this.settings.background.overlay);
this.rangeItems = toRangeItems(this.settings.rangeColors);
this.visibleRangeItems = this.rangeItems.filter(item => item.visible);
for (const range of this.rangeItems) {
this.selectedRanges[range.index] = true;
}
this.showLegend = this.settings.showLegend && !!this.rangeItems.length;
if (this.showLegend) {
this.legendClass = `legend-${this.settings.legendPosition}`;
this.legendLabelStyle = textStyle(this.settings.legendLabelFont);
this.disabledLegendLabelStyle = textStyle(this.settings.legendLabelFont);
this.legendLabelStyle.color = this.settings.legendLabelColor;
}
if (this.settings.showTooltip && this.settings.tooltipShowDate) {
this.tooltipDateFormat = DateFormatProcessor.fromSettings(this.ctx.$injector, this.settings.tooltipDateFormat);
}
}
ngAfterViewInit() {
if (this.drawChartPending) {
this.drawChart();
}
}
ngOnDestroy() {
if (this.shapeResize$) {
this.shapeResize$.disconnect();
}
if (this.rangeChart) {
this.rangeChart.dispose();
}
}
public onInit() {
const borderRadius = this.ctx.$widgetElement.css('borderRadius');
this.overlayStyle = {...this.overlayStyle, ...{borderRadius}};
if (this.chartShape) {
this.drawChart();
} else {
this.drawChartPending = true;
}
this.cd.detectChanges();
}
public onDataUpdated() {
if (this.rangeChart) {
this.rangeChart.setOption({
xAxis: {
min: this.ctx.defaultSubscription.timeWindow.minTime,
max: this.ctx.defaultSubscription.timeWindow.maxTime
},
series: [
{data: this.ctx.data?.length ? toNamedData(this.ctx.data[0].data) : []}
],
visualMap: {
selected: this.selectedRanges
}
});
}
}
public toggleRangeItem(item: RangeItem) {
item.enabled = !item.enabled;
this.selectedRanges[item.index] = item.enabled;
this.rangeChart.dispatchAction({
type: 'selectDataRange',
selected: this.selectedRanges
});
}
private drawChart() {
const dataKey = getDataKey(this.ctx.datasources);
this.rangeChart = echarts.init(this.chartShape.nativeElement, null, {
renderer: 'canvas',
});
this.rangeChartOptions = {
tooltip: {
trigger: 'none'
},
grid: {
containLabel: true,
top: '30',
left: 0,
right: 0,
bottom: this.settings.dataZoom ? 60 : 0
},
xAxis: {
type: 'time',
axisTick: {
show: true
},
axisLabel: {
hideOverlap: true,
fontSize: 10
},
axisLine: {
onZero: false
},
min: this.ctx.defaultSubscription.timeWindow.minTime,
max: this.ctx.defaultSubscription.timeWindow.maxTime
},
yAxis: {
type: 'value',
axisLabel: {
formatter: value => formatValue(value, this.decimals, this.units, false)
}
},
series: [{
type: 'line',
name: dataKey?.label,
smooth: false,
showSymbol: false,
animation: true,
areaStyle: this.settings.fillArea ? {} : undefined,
data: this.ctx.data?.length ? toNamedData(this.ctx.data[0].data) : [],
markLine: this.rangeItems.length ? {
animation: true,
symbol: ['circle', 'arrow'],
symbolSize: [5, 7],
lineStyle: {
width: 1,
type: [3, 3],
color: '#37383b'
},
label: {
position: 'insideEndTop',
color: '#37383b',
backgroundColor: 'rgba(255,255,255,0.56)',
padding: [4, 5],
borderRadius: 4,
formatter: params => formatValue(params.value, this.decimals, this.units, false)
},
emphasis: {
disabled: true
},
data: getMarkPoints(this.rangeItems).map(point => ({ yAxis: point }))
} : undefined
}],
dataZoom: [
{
type: 'inside',
disabled: !this.settings.dataZoom
},
{
type: 'slider',
show: this.settings.dataZoom,
showDetail: false,
right: 10
}
],
visualMap: {
show: false,
type: 'piecewise',
selected: this.selectedRanges,
pieces: this.rangeItems.map(item => item.piece),
outOfRange: {
color: this.settings.outOfRangeColor
},
inRange: !this.rangeItems.length ? {
color: this.settings.outOfRangeColor
} : undefined
}
};
if (this.settings.showTooltip) {
this.rangeChartOptions.tooltip = {
trigger: 'axis',
formatter: (params) => {
if (!params.length || !params[0]) {
return null;
}
const seriesParams = params[0];
const value = formatValue(seriesParams.value[1], this.decimals, this.units, false);
const tooltipElement: HTMLElement = this.renderer.createElement('div');
this.renderer.setStyle(tooltipElement, 'display', 'flex');
this.renderer.setStyle(tooltipElement, 'flex-direction', 'column');
this.renderer.setStyle(tooltipElement, 'align-items', 'flex-start');
this.renderer.setStyle(tooltipElement, 'gap', '4px');
if (this.settings.tooltipShowDate) {
const dateElement: HTMLElement = this.renderer.createElement('div');
const ts = seriesParams.value[0];
this.tooltipDateFormat.update(ts);
this.renderer.appendChild(dateElement, this.renderer.createText(this.tooltipDateFormat.formatted));
this.renderer.setStyle(dateElement, 'font-family', this.settings.tooltipDateFont.family);
this.renderer.setStyle(dateElement, 'font-size', this.settings.tooltipDateFont.size + this.settings.tooltipDateFont.sizeUnit);
this.renderer.setStyle(dateElement, 'font-style', this.settings.tooltipDateFont.style);
this.renderer.setStyle(dateElement, 'font-weight', this.settings.tooltipDateFont.weight);
this.renderer.setStyle(dateElement, 'line-height', this.settings.tooltipDateFont.lineHeight);
this.renderer.setStyle(dateElement, 'color', this.settings.tooltipDateColor);
this.renderer.appendChild(tooltipElement, dateElement);
}
const labelValueElement: HTMLElement = this.renderer.createElement('div');
this.renderer.setStyle(labelValueElement, 'display', 'flex');
this.renderer.setStyle(labelValueElement, 'flex-direction', 'row');
this.renderer.setStyle(labelValueElement, 'align-items', 'center');
this.renderer.setStyle(labelValueElement, 'align-self', 'stretch');
this.renderer.setStyle(labelValueElement, 'gap', '12px');
this.renderer.appendChild(tooltipElement, labelValueElement);
const labelElement: HTMLElement = this.renderer.createElement('div');
this.renderer.setStyle(labelElement, 'display', 'flex');
this.renderer.setStyle(labelElement, 'align-items', 'center');
this.renderer.setStyle(labelElement, 'gap', '8px');
this.renderer.appendChild(labelValueElement, labelElement);
const circleElement: HTMLElement = this.renderer.createElement('div');
this.renderer.setStyle(circleElement, 'width', '8px');
this.renderer.setStyle(circleElement, 'height', '8px');
this.renderer.setStyle(circleElement, 'border-radius', '50%');
this.renderer.setStyle(circleElement, 'background', seriesParams.color);
this.renderer.appendChild(labelElement, circleElement);
const labelTextElement: HTMLElement = this.renderer.createElement('div');
this.renderer.appendChild(labelTextElement, this.renderer.createText(seriesParams.seriesName));
this.renderer.setStyle(labelTextElement, 'font-family', 'Roboto');
this.renderer.setStyle(labelTextElement, 'font-size', '12px');
this.renderer.setStyle(labelTextElement, 'font-style', 'normal');
this.renderer.setStyle(labelTextElement, 'font-weight', '400');
this.renderer.setStyle(labelTextElement, 'line-height', '16px');
this.renderer.setStyle(labelTextElement, 'letter-spacing', '0.4px');
this.renderer.setStyle(labelTextElement, 'color', 'rgba(0, 0, 0, 0.76)');
this.renderer.appendChild(labelElement, labelTextElement);
const valueElement: HTMLElement = this.renderer.createElement('div');
this.renderer.appendChild(valueElement, this.renderer.createText(value));
this.renderer.setStyle(valueElement, 'font-family', this.settings.tooltipValueFont.family);
this.renderer.setStyle(valueElement, 'font-size', this.settings.tooltipValueFont.size + this.settings.tooltipValueFont.sizeUnit);
this.renderer.setStyle(valueElement, 'font-style', this.settings.tooltipValueFont.style);
this.renderer.setStyle(valueElement, 'font-weight', this.settings.tooltipValueFont.weight);
this.renderer.setStyle(valueElement, 'line-height', this.settings.tooltipValueFont.lineHeight);
this.renderer.setStyle(valueElement, 'color', this.settings.tooltipValueColor);
this.renderer.appendChild(labelValueElement, valueElement);
return tooltipElement;
},
padding: [8, 12],
backgroundColor: this.settings.tooltipBackgroundColor,
extraCssText: `line-height: 1; backdrop-filter: blur(${this.settings.tooltipBackgroundBlur}px);`
};
}
this.rangeChart.setOption(this.rangeChartOptions);
this.shapeResize$ = new ResizeObserver(() => {
this.onResize();
});
this.shapeResize$.observe(this.chartShape.nativeElement);
this.onResize();
}
private onResize() {
const width = this.rangeChart.getWidth();
const height = this.rangeChart.getHeight();
const shapeWidth = this.chartShape.nativeElement.offsetWidth;
const shapeHeight = this.chartShape.nativeElement.offsetHeight;
if (width !== shapeWidth || height !== shapeHeight) {
this.rangeChart.resize();
}
}
}

103
ui-ngx/src/app/modules/home/components/widget/lib/chart/range-chart-widget.models.ts

@ -0,0 +1,103 @@
///
/// Copyright © 2016-2023 The Thingsboard Authors
///
/// Licensed under the Apache License, Version 2.0 (the "License");
/// you may not use this file except in compliance with the License.
/// You may obtain a copy of the License at
///
/// http://www.apache.org/licenses/LICENSE-2.0
///
/// Unless required by applicable law or agreed to in writing, software
/// distributed under the License is distributed on an "AS IS" BASIS,
/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
/// See the License for the specific language governing permissions and
/// limitations under the License.
///
import {
BackgroundSettings,
BackgroundType,
ColorRange,
DateFormatSettings,
Font, simpleDateFormat
} from '@shared/models/widget-settings.models';
import { LegendPosition } from '@shared/models/widget.models';
export interface RangeChartWidgetSettings {
dataZoom: boolean;
rangeColors: Array<ColorRange>;
outOfRangeColor: string;
fillArea: boolean;
showLegend: boolean;
legendPosition: LegendPosition;
legendLabelFont: Font;
legendLabelColor: string;
showTooltip: boolean;
tooltipValueFont: Font;
tooltipValueColor: string;
tooltipShowDate: boolean;
tooltipDateFormat: DateFormatSettings;
tooltipDateFont: Font;
tooltipDateColor: string;
tooltipBackgroundColor: string;
tooltipBackgroundBlur: number;
background: BackgroundSettings;
}
export const rangeChartDefaultSettings: RangeChartWidgetSettings = {
dataZoom: true,
rangeColors: [
{to: -20, color: '#234CC7'},
{from: -20, to: 0, color: '#305AD7'},
{from: 0, to: 10, color: '#7191EF'},
{from: 10, to: 20, color: '#FFA600'},
{from: 20, to: 30, color: '#F36900'},
{from: 30, to: 40, color: '#F04022'},
{from: 40, color: '#D81838'}
],
outOfRangeColor: '#ccc',
fillArea: true,
showLegend: true,
legendPosition: LegendPosition.top,
legendLabelFont: {
family: 'Roboto',
size: 12,
sizeUnit: 'px',
style: 'normal',
weight: '400',
lineHeight: '16px'
},
legendLabelColor: 'rgba(0, 0, 0, 0.76)',
showTooltip: true,
tooltipValueFont: {
family: 'Roboto',
size: 12,
sizeUnit: 'px',
style: 'normal',
weight: '500',
lineHeight: '16px'
},
tooltipValueColor: 'rgba(0, 0, 0, 0.76)',
tooltipShowDate: true,
tooltipDateFormat: simpleDateFormat('dd MMM yyyy HH:mm'),
tooltipDateFont: {
family: 'Roboto',
size: 11,
sizeUnit: 'px',
style: 'normal',
weight: '400',
lineHeight: '16px'
},
tooltipDateColor: 'rgba(0, 0, 0, 0.76)',
tooltipBackgroundColor: 'rgba(255, 255, 255, 0.76)',
tooltipBackgroundBlur: 4,
background: {
type: BackgroundType.color,
color: '#fff',
overlay: {
enabled: false,
color: 'rgba(255,255,255,0.72)',
blur: 3
}
}
};

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

@ -0,0 +1,140 @@
<!--
Copyright © 2016-2023 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.
-->
<ng-container [formGroup]="rangeChartWidgetSettingsForm">
<div class="tb-form-panel">
<div class="tb-form-panel-title" translate>widgets.range-chart.range-chart-card-style</div>
<div class="tb-form-row">
<mat-slide-toggle class="mat-slide" formControlName="dataZoom">
{{ 'widgets.range-chart.data-zoom' | translate }}
</mat-slide-toggle>
</div>
<div class="tb-form-row space-between">
<div>{{ 'widgets.range-chart.range-colors' | translate }}</div>
<tb-color-range-settings formControlName="rangeColors">
</tb-color-range-settings>
</div>
<div class="tb-form-row space-between">
<div>{{ 'widgets.range-chart.out-of-range-color' | translate }}</div>
<tb-color-input asBoxInput
colorClearButton
formControlName="outOfRangeColor">
</tb-color-input>
</div>
<div class="tb-form-row">
<mat-slide-toggle class="mat-slide" formControlName="fillArea">
{{ 'widgets.range-chart.fill-area' | translate }}
</mat-slide-toggle>
</div>
<div class="tb-form-panel tb-slide-toggle">
<mat-expansion-panel class="tb-settings" [expanded]="rangeChartWidgetSettingsForm.get('showLegend').value" [disabled]="!rangeChartWidgetSettingsForm.get('showLegend').value">
<mat-expansion-panel-header fxLayout="row wrap">
<mat-panel-title>
<mat-slide-toggle class="mat-slide" formControlName="showLegend" (click)="$event.stopPropagation()"
fxLayoutAlign="center">
{{ 'widget-config.legend' | translate }}
</mat-slide-toggle>
</mat-panel-title>
</mat-expansion-panel-header>
<ng-template matExpansionPanelContent>
<div class="tb-form-row space-between">
<div>{{ 'legend.position' | translate }}</div>
<mat-form-field class="medium-width" appearance="outline" subscriptSizing="dynamic">
<mat-select formControlName="legendPosition">
<mat-option *ngFor="let pos of legendPositions" [value]="pos">
{{ legendPositionTranslationMap.get(pos) | translate }}
</mat-option>
</mat-select>
</mat-form-field>
</div>
<div class="tb-form-row space-between">
<div>{{ 'widgets.range-chart.legend-label' | translate }}</div>
<div fxLayout="row" fxLayoutAlign="start center" fxLayoutGap="8px">
<tb-font-settings formControlName="legendLabelFont"
previewText="20 - 30">
</tb-font-settings>
<tb-color-input asBoxInput
colorClearButton
formControlName="legendLabelColor">
</tb-color-input>
</div>
</div>
</ng-template>
</mat-expansion-panel>
</div>
<div class="tb-form-panel tb-slide-toggle">
<mat-expansion-panel class="tb-settings" [expanded]="rangeChartWidgetSettingsForm.get('showTooltip').value" [disabled]="!rangeChartWidgetSettingsForm.get('showTooltip').value">
<mat-expansion-panel-header fxLayout="row wrap">
<mat-panel-title>
<mat-slide-toggle class="mat-slide" formControlName="showTooltip" (click)="$event.stopPropagation()"
fxLayoutAlign="center">
{{ 'widgets.range-chart.tooltip' | translate }}
</mat-slide-toggle>
</mat-panel-title>
</mat-expansion-panel-header>
<ng-template matExpansionPanelContent>
<div class="tb-form-row space-between">
<div>{{ 'widgets.range-chart.tooltip-value' | translate }}</div>
<div fxLayout="row" fxLayoutAlign="start center" fxLayoutGap="8px">
<tb-font-settings formControlName="tooltipValueFont"
[previewText]="tooltipValuePreviewFn">
</tb-font-settings>
<tb-color-input asBoxInput
colorClearButton
formControlName="tooltipValueColor">
</tb-color-input>
</div>
</div>
<div class="tb-form-row column-xs">
<mat-slide-toggle class="mat-slide fixed-title-width" formControlName="tooltipShowDate">
{{ 'widgets.range-chart.tooltip-date' | translate }}
</mat-slide-toggle>
<div fxFlex.gt-xs fxLayout="row" fxLayoutAlign="start center" fxLayoutGap="8px">
<tb-date-format-select fxFlex excludeLastUpdateAgo formControlName="tooltipDateFormat"></tb-date-format-select>
<tb-font-settings formControlName="tooltipDateFont"
[previewText]="tooltipDatePreviewFn">
</tb-font-settings>
<tb-color-input asBoxInput
colorClearButton
formControlName="tooltipDateColor">
</tb-color-input>
</div>
</div>
<div class="tb-form-row space-between">
<div>{{ 'widgets.range-chart.tooltip-background-color' | translate }}</div>
<tb-color-input asBoxInput
colorClearButton
formControlName="tooltipBackgroundColor">
</tb-color-input>
</div>
<div class="tb-form-row space-between">
<div>{{ 'widgets.range-chart.tooltip-background-blur' | translate }}</div>
<mat-form-field appearance="outline" class="number" subscriptSizing="dynamic">
<input matInput formControlName="tooltipBackgroundBlur" type="number" min="0" step="1" placeholder="{{ 'widget-config.set' | translate }}">
<div matSuffix>px</div>
</mat-form-field>
</div>
</ng-template>
</mat-expansion-panel>
</div>
<div class="tb-form-row space-between">
<div>{{ 'widgets.background.background' | translate }}</div>
<tb-background-settings formControlName="background">
</tb-background-settings>
</div>
</div>
</ng-container>

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

@ -0,0 +1,147 @@
///
/// Copyright © 2016-2023 The Thingsboard Authors
///
/// Licensed under the Apache License, Version 2.0 (the "License");
/// you may not use this file except in compliance with the License.
/// You may obtain a copy of the License at
///
/// http://www.apache.org/licenses/LICENSE-2.0
///
/// Unless required by applicable law or agreed to in writing, software
/// distributed under the License is distributed on an "AS IS" BASIS,
/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
/// See the License for the specific language governing permissions and
/// limitations under the License.
///
import { Component, Injector } from '@angular/core';
import {
legendPositions,
legendPositionTranslationMap,
WidgetSettings,
WidgetSettingsComponent
} from '@shared/models/widget.models';
import { UntypedFormBuilder, UntypedFormGroup } from '@angular/forms';
import { Store } from '@ngrx/store';
import { AppState } from '@core/core.state';
import { formatValue } from '@core/utils';
import { rangeChartDefaultSettings } from '@home/components/widget/lib/chart/range-chart-widget.models';
import { DateFormatProcessor, DateFormatSettings } from '@shared/models/widget-settings.models';
@Component({
selector: 'tb-range-chart-widget-settings',
templateUrl: './range-chart-widget-settings.component.html',
styleUrls: []
})
export class RangeChartWidgetSettingsComponent extends WidgetSettingsComponent {
legendPositions = legendPositions;
legendPositionTranslationMap = legendPositionTranslationMap;
rangeChartWidgetSettingsForm: UntypedFormGroup;
tooltipValuePreviewFn = this._tooltipValuePreviewFn.bind(this);
tooltipDatePreviewFn = this._tooltipDatePreviewFn.bind(this);
constructor(protected store: Store<AppState>,
private $injector: Injector,
private fb: UntypedFormBuilder) {
super(store);
}
protected settingsForm(): UntypedFormGroup {
return this.rangeChartWidgetSettingsForm;
}
protected defaultSettings(): WidgetSettings {
return {...rangeChartDefaultSettings};
}
protected onSettingsSet(settings: WidgetSettings) {
this.rangeChartWidgetSettingsForm = this.fb.group({
dataZoom: [settings.dataZoom, []],
rangeColors: [settings.rangeColors, []],
outOfRangeColor: [settings.outOfRangeColor, []],
fillArea: [settings.fillArea, []],
showLegend: [settings.showLegend, []],
legendPosition: [settings.legendPosition, []],
legendLabelFont: [settings.legendLabelFont, []],
legendLabelColor: [settings.legendLabelColor, []],
showTooltip: [settings.showTooltip, []],
tooltipValueFont: [settings.tooltipValueFont, []],
tooltipValueColor: [settings.tooltipValueColor, []],
tooltipShowDate: [settings.tooltipShowDate, []],
tooltipDateFormat: [settings.tooltipDateFormat, []],
tooltipDateFont: [settings.tooltipDateFont, []],
tooltipDateColor: [settings.tooltipDateColor, []],
tooltipBackgroundColor: [settings.tooltipBackgroundColor, []],
tooltipBackgroundBlur: [settings.tooltipBackgroundBlur, []],
background: [settings.background, []]
});
}
protected validatorTriggers(): string[] {
return ['showLegend', 'showTooltip', 'tooltipShowDate'];
}
protected updateValidators(emitEvent: boolean) {
const showLegend: boolean = this.rangeChartWidgetSettingsForm.get('showLegend').value;
const showTooltip: boolean = this.rangeChartWidgetSettingsForm.get('showTooltip').value;
const tooltipShowDate: boolean = this.rangeChartWidgetSettingsForm.get('tooltipShowDate').value;
if (showLegend) {
this.rangeChartWidgetSettingsForm.get('legendPosition').enable();
this.rangeChartWidgetSettingsForm.get('legendLabelFont').enable();
this.rangeChartWidgetSettingsForm.get('legendLabelColor').enable();
} else {
this.rangeChartWidgetSettingsForm.get('legendPosition').disable();
this.rangeChartWidgetSettingsForm.get('legendLabelFont').disable();
this.rangeChartWidgetSettingsForm.get('legendLabelColor').disable();
}
if (showTooltip) {
this.rangeChartWidgetSettingsForm.get('tooltipValueFont').enable();
this.rangeChartWidgetSettingsForm.get('tooltipValueColor').enable();
this.rangeChartWidgetSettingsForm.get('tooltipShowDate').enable({emitEvent: false});
this.rangeChartWidgetSettingsForm.get('tooltipBackgroundColor').enable();
this.rangeChartWidgetSettingsForm.get('tooltipBackgroundBlur').enable();
if (tooltipShowDate) {
this.rangeChartWidgetSettingsForm.get('tooltipDateFormat').enable();
this.rangeChartWidgetSettingsForm.get('tooltipDateFont').enable();
this.rangeChartWidgetSettingsForm.get('tooltipDateColor').enable();
} else {
this.rangeChartWidgetSettingsForm.get('tooltipDateFormat').disable();
this.rangeChartWidgetSettingsForm.get('tooltipDateFont').disable();
this.rangeChartWidgetSettingsForm.get('tooltipDateColor').disable();
}
} else {
this.rangeChartWidgetSettingsForm.get('tooltipValueFont').disable();
this.rangeChartWidgetSettingsForm.get('tooltipValueColor').disable();
this.rangeChartWidgetSettingsForm.get('tooltipShowDate').disable({emitEvent: false});
this.rangeChartWidgetSettingsForm.get('tooltipDateFormat').disable();
this.rangeChartWidgetSettingsForm.get('tooltipDateFont').disable();
this.rangeChartWidgetSettingsForm.get('tooltipDateColor').disable();
this.rangeChartWidgetSettingsForm.get('tooltipBackgroundColor').disable();
this.rangeChartWidgetSettingsForm.get('tooltipBackgroundBlur').disable();
}
}
private _tooltipValuePreviewFn(): string {
const units: string = this.widgetConfig.config.units;
const decimals: number = this.widgetConfig.config.decimals;
return formatValue(22, decimals, units, false);
}
private _tooltipDatePreviewFn(): string {
const dateFormat: DateFormatSettings = this.rangeChartWidgetSettingsForm.get('tooltipDateFormat').value;
const processor = DateFormatProcessor.fromSettings(this.$injector, dateFormat);
processor.update(Date.now());
return processor.formatted;
}
}

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

@ -147,7 +147,9 @@ export class ColorRangeSettingsComponent implements OnInit, ControlValueAccessor
const rangeColors = this.modelValue.slice(0, Math.min(3, this.modelValue.length)).map(r => r.color); const rangeColors = this.modelValue.slice(0, Math.min(3, this.modelValue.length)).map(r => r.color);
colors = colors.concat(rangeColors); colors = colors.concat(rangeColors);
} }
if (colors.length === 1) { if (!colors.length) {
this.colorStyle = {};
} else if (colors.length === 1) {
this.colorStyle = {backgroundColor: colors[0]}; this.colorStyle = {backgroundColor: colors[0]};
} else { } else {
const gradientValues: string[] = []; const gradientValues: string[] = [];

15
ui-ngx/src/app/modules/home/components/widget/lib/settings/common/date-format-select.component.ts

@ -16,11 +16,7 @@
import { Component, forwardRef, Input, OnInit, Renderer2, ViewChild, ViewContainerRef } from '@angular/core'; import { Component, forwardRef, Input, OnInit, Renderer2, ViewChild, ViewContainerRef } from '@angular/core';
import { ControlValueAccessor, NG_VALUE_ACCESSOR, UntypedFormControl } from '@angular/forms'; import { ControlValueAccessor, NG_VALUE_ACCESSOR, UntypedFormControl } from '@angular/forms';
import { import { compareDateFormats, dateFormats, DateFormatSettings } from '@shared/models/widget-settings.models';
compareDateFormats,
dateFormats,
DateFormatSettings
} from '@shared/models/widget-settings.models';
import { TranslateService } from '@ngx-translate/core'; import { TranslateService } from '@ngx-translate/core';
import { DatePipe } from '@angular/common'; import { DatePipe } from '@angular/common';
import { MatButton } from '@angular/material/button'; import { MatButton } from '@angular/material/button';
@ -29,6 +25,7 @@ import { deepClone } from '@core/utils';
import { import {
DateFormatSettingsPanelComponent DateFormatSettingsPanelComponent
} from '@home/components/widget/lib/settings/common/date-format-settings-panel.component'; } from '@home/components/widget/lib/settings/common/date-format-settings-panel.component';
import { coerceBoolean } from '@shared/decorators/coercion';
@Component({ @Component({
selector: 'tb-date-format-select', selector: 'tb-date-format-select',
@ -50,7 +47,11 @@ export class DateFormatSelectComponent implements OnInit, ControlValueAccessor {
@Input() @Input()
disabled: boolean; disabled: boolean;
dateFormatList = dateFormats; @Input()
@coerceBoolean()
excludeLastUpdateAgo = false;
dateFormatList: DateFormatSettings[];
dateFormatsCompare = compareDateFormats; dateFormatsCompare = compareDateFormats;
@ -69,6 +70,8 @@ export class DateFormatSelectComponent implements OnInit, ControlValueAccessor {
private viewContainerRef: ViewContainerRef) {} private viewContainerRef: ViewContainerRef) {}
ngOnInit(): void { ngOnInit(): void {
this.dateFormatList = this.excludeLastUpdateAgo ?
dateFormats.filter(format => !format.lastUpdateAgo) : dateFormats;
this.dateFormatFormControl = new UntypedFormControl(); this.dateFormatFormControl = new UntypedFormControl();
this.dateFormatFormControl.valueChanges.subscribe((value: DateFormatSettings) => { this.dateFormatFormControl.valueChanges.subscribe((value: DateFormatSettings) => {
this.updateModel(value); this.updateModel(value);

12
ui-ngx/src/app/modules/home/components/widget/lib/settings/widget-settings.module.ts

@ -305,6 +305,9 @@ import {
import { import {
DoughnutWidgetSettingsComponent DoughnutWidgetSettingsComponent
} from '@home/components/widget/lib/settings/chart/doughnut-widget-settings.component'; } from '@home/components/widget/lib/settings/chart/doughnut-widget-settings.component';
import {
RangeChartWidgetSettingsComponent
} from '@home/components/widget/lib/settings/chart/range-chart-widget-settings.component';
@NgModule({ @NgModule({
declarations: [ declarations: [
@ -416,7 +419,8 @@ import {
ValueChartCardWidgetSettingsComponent, ValueChartCardWidgetSettingsComponent,
ProgressBarWidgetSettingsComponent, ProgressBarWidgetSettingsComponent,
LiquidLevelCardWidgetSettingsComponent, LiquidLevelCardWidgetSettingsComponent,
DoughnutWidgetSettingsComponent DoughnutWidgetSettingsComponent,
RangeChartWidgetSettingsComponent
], ],
imports: [ imports: [
CommonModule, CommonModule,
@ -533,7 +537,8 @@ import {
ValueChartCardWidgetSettingsComponent, ValueChartCardWidgetSettingsComponent,
ProgressBarWidgetSettingsComponent, ProgressBarWidgetSettingsComponent,
LiquidLevelCardWidgetSettingsComponent, LiquidLevelCardWidgetSettingsComponent,
DoughnutWidgetSettingsComponent DoughnutWidgetSettingsComponent,
RangeChartWidgetSettingsComponent
] ]
}) })
export class WidgetSettingsModule { export class WidgetSettingsModule {
@ -616,5 +621,6 @@ export const widgetSettingsComponentsMap: {[key: string]: Type<IWidgetSettingsCo
'tb-value-chart-card-widget-settings': ValueChartCardWidgetSettingsComponent, 'tb-value-chart-card-widget-settings': ValueChartCardWidgetSettingsComponent,
'tb-progress-bar-widget-settings': ProgressBarWidgetSettingsComponent, 'tb-progress-bar-widget-settings': ProgressBarWidgetSettingsComponent,
'tb-liquid-level-card-widget-settings': LiquidLevelCardWidgetSettingsComponent, 'tb-liquid-level-card-widget-settings': LiquidLevelCardWidgetSettingsComponent,
'tb-doughnut-widget-settings': DoughnutWidgetSettingsComponent 'tb-doughnut-widget-settings': DoughnutWidgetSettingsComponent,
'tb-range-chart-widget-settings': RangeChartWidgetSettingsComponent
}; };

7
ui-ngx/src/app/modules/home/components/widget/widget-components.module.ts

@ -66,6 +66,7 @@ import { ValueChartCardWidgetComponent } from '@home/components/widget/lib/cards
import { ProgressBarWidgetComponent } from '@home/components/widget/lib/cards/progress-bar-widget.component'; import { ProgressBarWidgetComponent } from '@home/components/widget/lib/cards/progress-bar-widget.component';
import { LiquidLevelWidgetComponent } from '@home/components/widget/lib/indicator/liquid-level-widget.component'; import { LiquidLevelWidgetComponent } from '@home/components/widget/lib/indicator/liquid-level-widget.component';
import { DoughnutWidgetComponent } from '@home/components/widget/lib/chart/doughnut-widget.component'; import { DoughnutWidgetComponent } from '@home/components/widget/lib/chart/doughnut-widget.component';
import { RangeChartWidgetComponent } from '@home/components/widget/lib/chart/range-chart-widget.component';
@NgModule({ @NgModule({
declarations: declarations:
@ -106,7 +107,8 @@ import { DoughnutWidgetComponent } from '@home/components/widget/lib/chart/dough
ValueChartCardWidgetComponent, ValueChartCardWidgetComponent,
ProgressBarWidgetComponent, ProgressBarWidgetComponent,
LiquidLevelWidgetComponent, LiquidLevelWidgetComponent,
DoughnutWidgetComponent DoughnutWidgetComponent,
RangeChartWidgetComponent
], ],
imports: [ imports: [
CommonModule, CommonModule,
@ -151,7 +153,8 @@ import { DoughnutWidgetComponent } from '@home/components/widget/lib/chart/dough
ValueChartCardWidgetComponent, ValueChartCardWidgetComponent,
ProgressBarWidgetComponent, ProgressBarWidgetComponent,
LiquidLevelWidgetComponent, LiquidLevelWidgetComponent,
DoughnutWidgetComponent DoughnutWidgetComponent,
RangeChartWidgetComponent
], ],
providers: [ providers: [
{provide: WIDGET_COMPONENTS_MODULE_TOKEN, useValue: WidgetComponentsModule } {provide: WIDGET_COMPONENTS_MODULE_TOKEN, useValue: WidgetComponentsModule }

2
ui-ngx/src/app/shared/components/entity/entity-subtype-list.component.ts

@ -265,8 +265,8 @@ export class EntitySubTypeListComponent implements ControlValueAccessor, OnInit,
const value = (event.value || '').trim(); const value = (event.value || '').trim();
if (value) { if (value) {
this.add(value); this.add(value);
this.clear('');
} }
this.clear('');
} }
remove(entitySubtype: string) { remove(entitySubtype: string) {
const index = this.entitySubtypeList.indexOf(entitySubtype); const index = this.entitySubtypeList.indexOf(entitySubtype);

77
ui-ngx/src/app/shared/models/widget-settings.models.ts

@ -14,7 +14,7 @@
/// limitations under the License. /// limitations under the License.
/// ///
import { isDefinedAndNotNull, isNumber, isNumeric, parseFunction } from '@core/utils'; import { isDefinedAndNotNull, isNumber, isNumeric, isUndefinedOrNull, parseFunction } from '@core/utils';
import { DataKey, Datasource, DatasourceData } from '@shared/models/widget.models'; import { DataKey, Datasource, DatasourceData } from '@shared/models/widget.models';
import { Injector } from '@angular/core'; import { Injector } from '@angular/core';
import { DatePipe } from '@angular/common'; import { DatePipe } from '@angular/common';
@ -85,6 +85,79 @@ export interface ColorRange {
color: string; color: string;
} }
export const colorRangeIncludes = (range: ColorRange, toCheck: ColorRange): boolean => {
if (isNumber(range.from) && isNumber(range.to)) {
if (isNumber(toCheck.from) && isNumber(toCheck.to)) {
return toCheck.from >= range.from && toCheck.to < range.to;
} else {
return false;
}
} else if (isNumber(range.from)) {
if (isNumber(toCheck.from)) {
return toCheck.from >= range.from;
} else {
return false;
}
} else if (isNumber(range.to)) {
if (isNumber(toCheck.to)) {
return toCheck.to < range.to;
} else {
return false;
}
} else {
return false;
}
};
export const filterIncludingColorRanges = (ranges: Array<ColorRange>): Array<ColorRange> => {
const result = [...ranges];
let includes = true;
while (includes) {
let index = -1;
for (let i = 0; i < result.length; i++) {
const range = result[i];
if (result.some((value, i1) => i1 !== i && colorRangeIncludes(value, range))) {
index = i;
break;
}
}
if (index > -1) {
result.splice(index, 1);
} else {
includes = false;
}
}
return result;
};
export const sortedColorRange = (ranges: Array<ColorRange>): Array<ColorRange> => ranges ? [...ranges].sort(
(a, b) => {
if (isNumber(a.from) && isNumber(a.to) && isNumber(b.from) && isNumber(b.to)) {
if (b.from >= a.from && b.to < a.to) {
return 1;
} else if (a.from >= b.from && a.to < b.to) {
return -1;
} else {
return a.from - b.from;
}
} else if (isNumber(a.from) && isNumber(b.from)) {
return a.from - b.from;
} else if (isNumber(a.to) && isNumber(b.to)) {
return a.to - b.to;
} else if (isNumber(a.from) && isUndefinedOrNull(b.from)) {
return 1;
} else if (isUndefinedOrNull(a.from) && isNumber(b.from)) {
return -1;
} else if (isNumber(a.to) && isUndefinedOrNull(b.to)) {
return 1;
} else if (isUndefinedOrNull(a.to) && isNumber(b.to)) {
return -1;
} else {
return 0;
}
}
) : [];
export interface ColorSettings { export interface ColorSettings {
type: ColorType; type: ColorType;
color: string; color: string;
@ -208,7 +281,7 @@ class RangeColorProcessor extends ColorProcessor {
return this.settings.color; return this.settings.color;
} }
private static constantRange(range: ColorRange): boolean { public static constantRange(range: ColorRange): boolean {
return isNumber(range.from) && isNumber(range.to) && range.from === range.to; return isNumber(range.from) && isNumber(range.to) && range.from === range.to;
} }
} }

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

@ -269,6 +269,8 @@ export enum LegendPosition {
right = 'right' right = 'right'
} }
export const legendPositions = Object.keys(LegendPosition) as LegendPosition[];
export const legendPositionTranslationMap = new Map<LegendPosition, string>( export const legendPositionTranslationMap = new Map<LegendPosition, string>(
[ [
[ LegendPosition.top, 'position.top' ], [ LegendPosition.top, 'position.top' ],

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

@ -5834,6 +5834,20 @@
"no-columns-found": "No columns found", "no-columns-found": "No columns found",
"no-columns-matching": "'{{column}}' not found." "no-columns-matching": "'{{column}}' not found."
}, },
"range-chart": {
"chart": "Chart",
"data-zoom": "Data zoom",
"range-colors": "Range colors",
"out-of-range-color": "Out of range color",
"fill-area": "Fill area",
"legend-label": "Label",
"tooltip": "Tooltip",
"tooltip-value": "Value",
"tooltip-date": "Date",
"tooltip-background-color": "Background color",
"tooltip-background-blur": "Background blur",
"range-chart-card-style": "Range chart card style"
},
"rpc": { "rpc": {
"value-settings": "Value settings", "value-settings": "Value settings",
"initial-value": "Initial value", "initial-value": "Initial value",

Loading…
Cancel
Save