Browse Source

Merge remote-tracking branch 'upstream/master' into improvement/add-widget/panel-width

pull/9571/head
Vladyslav_Prykhodko 3 years ago
parent
commit
b8a46da469
  1. 6
      application/src/main/data/json/system/widget_bundles/charts.json
  2. 31
      application/src/main/data/json/system/widget_types/doughnut.json
  3. 30
      application/src/main/data/json/system/widget_types/doughnut_deprecated.json
  4. 29
      application/src/main/data/json/system/widget_types/horizontal_doughnut.json
  5. 43
      application/src/main/java/org/thingsboard/server/controller/UserController.java
  6. 2
      application/src/main/java/org/thingsboard/server/service/subscription/DefaultSubscriptionManagerService.java
  7. 131
      application/src/main/java/org/thingsboard/server/service/transport/DefaultTransportApiService.java
  8. 75
      application/src/test/java/org/thingsboard/server/controller/DeviceConnectivityControllerTest.java
  9. 50
      application/src/test/java/org/thingsboard/server/controller/DeviceConnectivityControllerWithDefaultPortTest.java
  10. 41
      application/src/test/java/org/thingsboard/server/controller/UserControllerTest.java
  11. 7
      application/src/test/java/org/thingsboard/server/controller/WidgetsBundleControllerTest.java
  12. 4
      application/src/test/java/org/thingsboard/server/service/queue/ruleengine/TbRuleEngineQueueConsumerManagerTest.java
  13. 13
      common/cache/src/main/java/org/thingsboard/server/cache/device/DeviceCacheKey.java
  14. 3
      dao/src/main/java/org/thingsboard/server/dao/alarm/BaseAlarmService.java
  15. 17
      dao/src/main/java/org/thingsboard/server/dao/device/DeviceServiceImpl.java
  16. 8
      dao/src/main/java/org/thingsboard/server/dao/util/DeviceConnectivityUtil.java
  17. 14
      msa/black-box-tests/src/test/java/org/thingsboard/server/msa/WsClient.java
  18. 1
      msa/black-box-tests/src/test/resources/logback.xml
  19. 58
      msa/black-box-tests/src/test/resources/tb-node/conf/logback.xml
  20. 57
      msa/black-box-tests/src/test/resources/tb-transports/coap/conf/logback.xml
  21. 57
      msa/black-box-tests/src/test/resources/tb-transports/http/conf/logback.xml
  22. 55
      msa/black-box-tests/src/test/resources/tb-transports/mqtt/conf/logback.xml
  23. 17
      rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/rest/TbHttpClient.java
  24. 23
      rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/rest/TbRestApiCallNode.java
  25. 4
      rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/rest/TbRestApiCallNodeConfiguration.java
  26. 43
      rule-engine/rule-engine-components/src/test/java/org/thingsboard/rule/engine/rest/TbHttpClientTest.java
  27. 23
      rule-engine/rule-engine-components/src/test/java/org/thingsboard/rule/engine/rest/TbRestApiCallNodeTest.java
  28. 1
      ui-ngx/package.json
  29. 4
      ui-ngx/src/app/core/api/widget-subscription.ts
  30. 3
      ui-ngx/src/app/modules/home/components/alarm/alarm-filter-config.component.scss
  31. 8
      ui-ngx/src/app/modules/home/components/alarm/alarm-table-config.ts
  32. 2
      ui-ngx/src/app/modules/home/components/dashboard-page/dashboard-page.component.html
  33. 2
      ui-ngx/src/app/modules/home/components/dashboard-page/dashboard-widget-select.component.ts
  34. 86
      ui-ngx/src/app/modules/home/components/event/event-filter-panel.component.html
  35. 19
      ui-ngx/src/app/modules/home/components/event/event-filter-panel.component.scss
  36. 30
      ui-ngx/src/app/modules/home/components/event/event-table-config.ts
  37. 2
      ui-ngx/src/app/modules/home/components/grid/scroll-grid.component.ts
  38. 4
      ui-ngx/src/app/modules/home/components/profile/add-device-profile-dialog.component.scss
  39. 1
      ui-ngx/src/app/modules/home/components/widget/config/basic/alarm/alarms-table-basic-config.component.html
  40. 12
      ui-ngx/src/app/modules/home/components/widget/config/basic/basic-widget-config.module.ts
  41. 5
      ui-ngx/src/app/modules/home/components/widget/config/basic/cards/progress-bar-basic-config.component.html
  42. 6
      ui-ngx/src/app/modules/home/components/widget/config/basic/cards/value-card-basic-config.component.html
  43. 5
      ui-ngx/src/app/modules/home/components/widget/config/basic/cards/value-chart-card-basic-config.component.html
  44. 244
      ui-ngx/src/app/modules/home/components/widget/config/basic/chart/doughnut-basic-config.component.html
  45. 339
      ui-ngx/src/app/modules/home/components/widget/config/basic/chart/doughnut-basic-config.component.ts
  46. 2
      ui-ngx/src/app/modules/home/components/widget/config/basic/common/data-keys-panel.component.html
  47. 8
      ui-ngx/src/app/modules/home/components/widget/config/basic/common/data-keys-panel.component.ts
  48. 2
      ui-ngx/src/app/modules/home/components/widget/config/basic/gauge/analog-gauge-basic-config.component.ts
  49. 6
      ui-ngx/src/app/modules/home/components/widget/config/basic/indicator/battery-level-basic-config.component.html
  50. 13
      ui-ngx/src/app/modules/home/components/widget/config/basic/indicator/liquid-level-card-basic-config.component.html
  51. 5
      ui-ngx/src/app/modules/home/components/widget/config/basic/indicator/signal-strength-basic-config.component.html
  52. 6
      ui-ngx/src/app/modules/home/components/widget/config/basic/weather/wind-speed-direction-basic-config.component.html
  53. 2
      ui-ngx/src/app/modules/home/components/widget/config/datasources.component.ts
  54. 113
      ui-ngx/src/app/modules/home/components/widget/lib/alarm/alarms-table-widget.component.ts
  55. 37
      ui-ngx/src/app/modules/home/components/widget/lib/chart/doughnut-widget.component.html
  56. 110
      ui-ngx/src/app/modules/home/components/widget/lib/chart/doughnut-widget.component.scss
  57. 535
      ui-ngx/src/app/modules/home/components/widget/lib/chart/doughnut-widget.component.ts
  58. 159
      ui-ngx/src/app/modules/home/components/widget/lib/chart/doughnut-widget.models.ts
  59. 2
      ui-ngx/src/app/modules/home/components/widget/lib/display-columns-panel.component.scss
  60. 49
      ui-ngx/src/app/modules/home/components/widget/lib/entity/entities-table-widget.component.ts
  61. 5
      ui-ngx/src/app/modules/home/components/widget/lib/settings/cards/progress-bar-widget-settings.component.html
  62. 6
      ui-ngx/src/app/modules/home/components/widget/lib/settings/cards/value-card-widget-settings.component.html
  63. 5
      ui-ngx/src/app/modules/home/components/widget/lib/settings/cards/value-chart-card-widget-settings.component.html
  64. 153
      ui-ngx/src/app/modules/home/components/widget/lib/settings/chart/doughnut-widget-settings.component.html
  65. 204
      ui-ngx/src/app/modules/home/components/widget/lib/settings/chart/doughnut-widget-settings.component.ts
  66. 8
      ui-ngx/src/app/modules/home/components/widget/lib/settings/common/count-widget-settings.component.html
  67. 75
      ui-ngx/src/app/modules/home/components/widget/lib/settings/common/image-cards-select.component.ts
  68. 4
      ui-ngx/src/app/modules/home/components/widget/lib/settings/gauge/analogue-compass-widget-settings.component.html
  69. 6
      ui-ngx/src/app/modules/home/components/widget/lib/settings/indicator/battery-level-widget-settings.component.html
  70. 13
      ui-ngx/src/app/modules/home/components/widget/lib/settings/indicator/liquid-level-card-widget-settings.component.html
  71. 5
      ui-ngx/src/app/modules/home/components/widget/lib/settings/indicator/signal-strength-widget-settings.component.html
  72. 6
      ui-ngx/src/app/modules/home/components/widget/lib/settings/weather/wind-speed-direction-widget-settings.component.html
  73. 8
      ui-ngx/src/app/modules/home/components/widget/lib/settings/widget-settings.module.ts
  74. 39
      ui-ngx/src/app/modules/home/components/widget/lib/timeseries-table-widget.component.ts
  75. 7
      ui-ngx/src/app/modules/home/components/widget/widget-components.module.ts
  76. 26
      ui-ngx/src/app/modules/home/models/datasource/scroll-grid-datasource.ts
  77. 8
      ui-ngx/src/app/shared/components/entity/entity-subtype-list.component.html
  78. 8
      ui-ngx/src/app/shared/components/entity/entity-subtype-list.component.ts
  79. 87
      ui-ngx/src/app/shared/components/markdown-editor.component.ts
  80. 36
      ui-ngx/src/app/shared/components/tb-error.component.ts
  81. 4
      ui-ngx/src/app/shared/models/alarm.models.ts
  82. 7
      ui-ngx/src/app/shared/models/constants.ts
  83. 4
      ui-ngx/src/app/shared/models/widget.models.ts
  84. 4
      ui-ngx/src/assets/dashboard/customer_user_home_page.json
  85. 4
      ui-ngx/src/assets/dashboard/tenant_admin_home_page.json
  86. 22
      ui-ngx/src/assets/locale/locale.constant-en_US.json
  87. 29
      ui-ngx/src/assets/widget/doughnut/default-layout.svg
  88. 29
      ui-ngx/src/assets/widget/doughnut/horizontal-default-layout.svg
  89. 28
      ui-ngx/src/assets/widget/doughnut/horizontal-with-total-layout.svg
  90. 28
      ui-ngx/src/assets/widget/doughnut/with-total-layout.svg
  91. 2
      ui-ngx/src/styles.scss
  92. 20
      ui-ngx/yarn.lock

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

@ -16,8 +16,10 @@
"charts.bars", "charts.bars",
"charts.pie", "charts.pie",
"charts.pie_chart_js", "charts.pie_chart_js",
"charts.doughnut_chart_js", "doughnut",
"horizontal_doughnut",
"charts.polar_area_chart_js", "charts.polar_area_chart_js",
"charts.radar_chart_js" "charts.radar_chart_js",
"charts.doughnut_chart_js"
] ]
} }

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

File diff suppressed because one or more lines are too long

30
application/src/main/data/json/system/widget_types/doughnut_deprecated.json

File diff suppressed because one or more lines are too long

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

File diff suppressed because one or more lines are too long

43
application/src/main/java/org/thingsboard/server/controller/UserController.java

@ -105,6 +105,7 @@ import static org.thingsboard.server.controller.ControllerConstants.USER_ID_PARA
import static org.thingsboard.server.controller.ControllerConstants.USER_SORT_PROPERTY_ALLOWABLE_VALUES; import static org.thingsboard.server.controller.ControllerConstants.USER_SORT_PROPERTY_ALLOWABLE_VALUES;
import static org.thingsboard.server.controller.ControllerConstants.USER_TEXT_SEARCH_DESCRIPTION; import static org.thingsboard.server.controller.ControllerConstants.USER_TEXT_SEARCH_DESCRIPTION;
import static org.thingsboard.server.controller.ControllerConstants.UUID_WIKI_LINK; import static org.thingsboard.server.controller.ControllerConstants.UUID_WIKI_LINK;
import static org.thingsboard.server.dao.entity.BaseEntityService.NULL_CUSTOMER_ID;
@RequiredArgsConstructor @RequiredArgsConstructor
@RestController @RestController
@ -439,32 +440,28 @@ public class UserController extends BaseController {
@RequestParam(required = false) String sortProperty, @RequestParam(required = false) String sortProperty,
@ApiParam(value = SORT_ORDER_DESCRIPTION, allowableValues = SORT_ORDER_ALLOWABLE_VALUES) @ApiParam(value = SORT_ORDER_DESCRIPTION, allowableValues = SORT_ORDER_ALLOWABLE_VALUES)
@RequestParam(required = false) String sortOrder) throws ThingsboardException { @RequestParam(required = false) String sortOrder) throws ThingsboardException {
try { checkParameter("alarmId", strAlarmId);
checkParameter("alarmId", strAlarmId); AlarmId alarmEntityId = new AlarmId(toUUID(strAlarmId));
AlarmId alarmEntityId = new AlarmId(toUUID(strAlarmId)); Alarm alarm = checkAlarmId(alarmEntityId, Operation.READ);
Alarm alarm = checkAlarmId(alarmEntityId, Operation.READ); SecurityUser currentUser = getCurrentUser();
SecurityUser currentUser = getCurrentUser(); TenantId tenantId = currentUser.getTenantId();
TenantId tenantId = currentUser.getTenantId(); CustomerId originatorCustomerId = entityService.fetchEntityCustomerId(tenantId, alarm.getOriginator()).orElse(NULL_CUSTOMER_ID);
CustomerId originatorCustomerId = entityService.fetchEntityCustomerId(tenantId, alarm.getOriginator()).get(); PageLink pageLink = createPageLink(pageSize, page, textSearch, sortProperty, sortOrder);
PageLink pageLink = createPageLink(pageSize, page, textSearch, sortProperty, sortOrder); PageData<User> pageData;
PageData<User> pageData; if (Authority.TENANT_ADMIN.equals(currentUser.getAuthority())) {
if (Authority.TENANT_ADMIN.equals(currentUser.getAuthority())) { if (alarm.getCustomerId() == null) {
if (alarm.getCustomerId() == null) { pageData = userService.findTenantAdmins(tenantId, pageLink);
pageData = userService.findTenantAdmins(tenantId, pageLink);
} else {
ArrayList<CustomerId> customerIds = new ArrayList<>(Collections.singletonList(new CustomerId(CustomerId.NULL_UUID)));
if (!CustomerId.NULL_UUID.equals(originatorCustomerId.getId())) {
customerIds.add(originatorCustomerId);
}
pageData = userService.findUsersByCustomerIds(tenantId, customerIds, pageLink);
}
} else { } else {
pageData = userService.findCustomerUsers(tenantId, alarm.getCustomerId(), pageLink); ArrayList<CustomerId> customerIds = new ArrayList<>(Collections.singletonList(NULL_CUSTOMER_ID));
if (!CustomerId.NULL_UUID.equals(originatorCustomerId.getId())) {
customerIds.add(originatorCustomerId);
}
pageData = userService.findUsersByCustomerIds(tenantId, customerIds, pageLink);
} }
return pageData.mapData(user -> new UserEmailInfo(user.getId(), user.getEmail(), user.getFirstName(), user.getLastName())); } else {
} catch (Exception e) { pageData = userService.findCustomerUsers(tenantId, alarm.getCustomerId(), pageLink);
throw handleException(e);
} }
return pageData.mapData(user -> new UserEmailInfo(user.getId(), user.getEmail(), user.getFirstName(), user.getLastName()));
} }
@ApiOperation(value = "Save user settings (saveUserSettings)", @ApiOperation(value = "Save user settings (saveUserSettings)",

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

@ -301,7 +301,7 @@ public class DefaultSubscriptionManagerService extends TbApplicationEventListene
if (subInfo != null) { if (subInfo != null) {
log.trace("[{}][{}] Handling alarm update {}: {}", tenantId, entityId, alarm, deleted); log.trace("[{}][{}] Handling alarm update {}: {}", tenantId, entityId, alarm, deleted);
for (Map.Entry<String, TbSubscriptionsInfo> entry : subInfo.getSubs().entrySet()) { for (Map.Entry<String, TbSubscriptionsInfo> entry : subInfo.getSubs().entrySet()) {
if (entry.getValue().notifications) { if (entry.getValue().alarms) {
onAlarmSubUpdate(entry.getKey(), entityId, alarm, deleted); onAlarmSubUpdate(entry.getKey(), entityId, alarm, deleted);
} }
} }

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

@ -192,7 +192,7 @@ public class DefaultTransportApiService implements TransportApiService {
final String certChain = msg.getCertificateChain(); final String certChain = msg.getCertificateChain();
result = handlerExecutor.submit(() -> validateOrCreateDeviceX509Certificate(certChain)); result = handlerExecutor.submit(() -> validateOrCreateDeviceX509Certificate(certChain));
} else if (transportApiRequestMsg.hasGetOrCreateDeviceRequestMsg()) { } else if (transportApiRequestMsg.hasGetOrCreateDeviceRequestMsg()) {
result = handle(transportApiRequestMsg.getGetOrCreateDeviceRequestMsg()); result = handlerExecutor.submit(() -> handle(transportApiRequestMsg.getGetOrCreateDeviceRequestMsg()));
} else if (transportApiRequestMsg.hasEntityProfileRequestMsg()) { } else if (transportApiRequestMsg.hasEntityProfileRequestMsg()) {
result = handle(transportApiRequestMsg.getEntityProfileRequestMsg()); result = handle(transportApiRequestMsg.getEntityProfileRequestMsg());
} else if (transportApiRequestMsg.hasLwM2MRequestMsg()) { } else if (transportApiRequestMsg.hasLwM2MRequestMsg()) {
@ -223,7 +223,6 @@ public class DefaultTransportApiService implements TransportApiService {
} }
private TransportApiResponseMsg validateCredentials(String credentialsId, DeviceCredentialsType credentialsType) { private TransportApiResponseMsg validateCredentials(String credentialsId, DeviceCredentialsType credentialsType) {
//TODO: Make async and enable caching
DeviceCredentials credentials = deviceCredentialsService.findDeviceCredentialsByCredentialsId(credentialsId); DeviceCredentials credentials = deviceCredentialsService.findDeviceCredentialsByCredentialsId(credentialsId);
if (credentials != null && credentials.getCredentialsType() == credentialsType) { if (credentials != null && credentials.getCredentialsType() == credentialsType) {
return getDeviceInfo(credentials); return getDeviceInfo(credentials);
@ -336,76 +335,74 @@ public class DefaultTransportApiService implements TransportApiService {
return VALID; return VALID;
} }
private ListenableFuture<TransportApiResponseMsg> handle(GetOrCreateDeviceFromGatewayRequestMsg requestMsg) { private TransportApiResponseMsg handle(GetOrCreateDeviceFromGatewayRequestMsg requestMsg) {
DeviceId gatewayId = new DeviceId(new UUID(requestMsg.getGatewayIdMSB(), requestMsg.getGatewayIdLSB())); DeviceId gatewayId = new DeviceId(new UUID(requestMsg.getGatewayIdMSB(), requestMsg.getGatewayIdLSB()));
ListenableFuture<Device> gatewayFuture = deviceService.findDeviceByIdAsync(TenantId.SYS_TENANT_ID, gatewayId); Device gateway = deviceService.findDeviceById(TenantId.SYS_TENANT_ID, gatewayId);
return Futures.transform(gatewayFuture, gateway -> { Lock deviceCreationLock = deviceCreationLocks.computeIfAbsent(requestMsg.getDeviceName(), id -> new ReentrantLock());
Lock deviceCreationLock = deviceCreationLocks.computeIfAbsent(requestMsg.getDeviceName(), id -> new ReentrantLock()); deviceCreationLock.lock();
deviceCreationLock.lock(); try {
try { Device device = deviceService.findDeviceByTenantIdAndName(gateway.getTenantId(), requestMsg.getDeviceName());
Device device = deviceService.findDeviceByTenantIdAndName(gateway.getTenantId(), requestMsg.getDeviceName()); if (device == null) {
if (device == null) { TenantId tenantId = gateway.getTenantId();
TenantId tenantId = gateway.getTenantId(); device = new Device();
device = new Device(); device.setTenantId(tenantId);
device.setTenantId(tenantId); device.setName(requestMsg.getDeviceName());
device.setName(requestMsg.getDeviceName()); device.setType(requestMsg.getDeviceType());
device.setType(requestMsg.getDeviceType()); device.setCustomerId(gateway.getCustomerId());
device.setCustomerId(gateway.getCustomerId()); DeviceProfile deviceProfile = deviceProfileCache.findOrCreateDeviceProfile(gateway.getTenantId(), requestMsg.getDeviceType());
DeviceProfile deviceProfile = deviceProfileCache.findOrCreateDeviceProfile(gateway.getTenantId(), requestMsg.getDeviceType());
device.setDeviceProfileId(deviceProfile.getId());
device.setDeviceProfileId(deviceProfile.getId()); ObjectNode additionalInfo = JacksonUtil.newObjectNode();
ObjectNode additionalInfo = JacksonUtil.newObjectNode(); additionalInfo.put(DataConstants.LAST_CONNECTED_GATEWAY, gatewayId.toString());
additionalInfo.put(DataConstants.LAST_CONNECTED_GATEWAY, gatewayId.toString()); device.setAdditionalInfo(additionalInfo);
device.setAdditionalInfo(additionalInfo); Device savedDevice = deviceService.saveDevice(device);
Device savedDevice = deviceService.saveDevice(device); tbClusterService.onDeviceUpdated(savedDevice, null);
tbClusterService.onDeviceUpdated(savedDevice, null); device = savedDevice;
device = savedDevice;
relationService.saveRelation(TenantId.SYS_TENANT_ID, new EntityRelation(gateway.getId(), device.getId(), "Created"));
relationService.saveRelation(TenantId.SYS_TENANT_ID, new EntityRelation(gateway.getId(), device.getId(), "Created"));
TbMsgMetaData metaData = new TbMsgMetaData();
TbMsgMetaData metaData = new TbMsgMetaData(); CustomerId customerId = gateway.getCustomerId();
CustomerId customerId = gateway.getCustomerId(); if (customerId != null && !customerId.isNullUid()) {
if (customerId != null && !customerId.isNullUid()) { metaData.putValue("customerId", customerId.toString());
metaData.putValue("customerId", customerId.toString()); }
} metaData.putValue("gatewayId", gatewayId.toString());
metaData.putValue("gatewayId", gatewayId.toString());
DeviceId deviceId = device.getId(); DeviceId deviceId = device.getId();
JsonNode entityNode = JacksonUtil.valueToTree(device); JsonNode entityNode = JacksonUtil.valueToTree(device);
TbMsg tbMsg = TbMsg.newMsg(TbMsgType.ENTITY_CREATED, deviceId, customerId, metaData, TbMsgDataType.JSON, JacksonUtil.toString(entityNode)); TbMsg tbMsg = TbMsg.newMsg(TbMsgType.ENTITY_CREATED, deviceId, customerId, metaData, TbMsgDataType.JSON, JacksonUtil.toString(entityNode));
tbClusterService.pushMsgToRuleEngine(tenantId, deviceId, tbMsg, null); tbClusterService.pushMsgToRuleEngine(tenantId, deviceId, tbMsg, null);
} else { } else {
JsonNode deviceAdditionalInfo = device.getAdditionalInfo(); JsonNode deviceAdditionalInfo = device.getAdditionalInfo();
if (deviceAdditionalInfo == null) { if (deviceAdditionalInfo == null) {
deviceAdditionalInfo = JacksonUtil.newObjectNode(); deviceAdditionalInfo = JacksonUtil.newObjectNode();
}
if (deviceAdditionalInfo.isObject() &&
(!deviceAdditionalInfo.has(DataConstants.LAST_CONNECTED_GATEWAY)
|| !gatewayId.toString().equals(deviceAdditionalInfo.get(DataConstants.LAST_CONNECTED_GATEWAY).asText()))) {
ObjectNode newDeviceAdditionalInfo = (ObjectNode) deviceAdditionalInfo;
newDeviceAdditionalInfo.put(DataConstants.LAST_CONNECTED_GATEWAY, gatewayId.toString());
Device savedDevice = deviceService.saveDevice(device);
tbClusterService.onDeviceUpdated(savedDevice, device);
}
} }
GetOrCreateDeviceFromGatewayResponseMsg.Builder builder = GetOrCreateDeviceFromGatewayResponseMsg.newBuilder() if (deviceAdditionalInfo.isObject() &&
.setDeviceInfo(getDeviceInfoProto(device)); (!deviceAdditionalInfo.has(DataConstants.LAST_CONNECTED_GATEWAY)
DeviceProfile deviceProfile = deviceProfileCache.get(device.getTenantId(), device.getDeviceProfileId()); || !gatewayId.toString().equals(deviceAdditionalInfo.get(DataConstants.LAST_CONNECTED_GATEWAY).asText()))) {
if (deviceProfile != null) { ObjectNode newDeviceAdditionalInfo = (ObjectNode) deviceAdditionalInfo;
builder.setProfileBody(ByteString.copyFrom(dataDecodingEncodingService.encode(deviceProfile))); newDeviceAdditionalInfo.put(DataConstants.LAST_CONNECTED_GATEWAY, gatewayId.toString());
} else { Device savedDevice = deviceService.saveDevice(device);
log.warn("[{}] Failed to find device profile [{}] for device. ", device.getId(), device.getDeviceProfileId()); tbClusterService.onDeviceUpdated(savedDevice, device);
} }
return TransportApiResponseMsg.newBuilder()
.setGetOrCreateDeviceResponseMsg(builder.build())
.build();
} catch (JsonProcessingException e) {
log.warn("[{}] Failed to lookup device by gateway id and name: [{}]", gatewayId, requestMsg.getDeviceName(), e);
throw new RuntimeException(e);
} finally {
deviceCreationLock.unlock();
} }
}, dbCallbackExecutorService); GetOrCreateDeviceFromGatewayResponseMsg.Builder builder = GetOrCreateDeviceFromGatewayResponseMsg.newBuilder()
.setDeviceInfo(getDeviceInfoProto(device));
DeviceProfile deviceProfile = deviceProfileCache.get(device.getTenantId(), device.getDeviceProfileId());
if (deviceProfile != null) {
builder.setProfileBody(ByteString.copyFrom(dataDecodingEncodingService.encode(deviceProfile)));
} else {
log.warn("[{}] Failed to find device profile [{}] for device. ", device.getId(), device.getDeviceProfileId());
}
return TransportApiResponseMsg.newBuilder()
.setGetOrCreateDeviceResponseMsg(builder.build())
.build();
} catch (JsonProcessingException e) {
log.warn("[{}] Failed to lookup device by gateway id and name: [{}]", gatewayId, requestMsg.getDeviceName(), e);
throw new RuntimeException(e);
} finally {
deviceCreationLock.unlock();
}
} }
private ListenableFuture<TransportApiResponseMsg> handle(ProvisionDeviceRequestMsg requestMsg) { private ListenableFuture<TransportApiResponseMsg> handle(ProvisionDeviceRequestMsg requestMsg) {

75
application/src/test/java/org/thingsboard/server/controller/DeviceConnectivityControllerTest.java

@ -121,18 +121,47 @@ public class DeviceConnectivityControllerTest extends AbstractControllerTest {
loginSysAdmin(); loginSysAdmin();
ObjectNode config = JacksonUtil.newObjectNode();
ObjectNode http = JacksonUtil.newObjectNode();
http.put("enabled", true);
http.put("host", "");
http.put("port", 8080);
config.set("http", http);
ObjectNode https = JacksonUtil.newObjectNode();
https.put("enabled", true);
https.put("host", "");
https.put("port", 444);
config.set("https", https);
ObjectNode mqtt = JacksonUtil.newObjectNode();
mqtt.put("enabled", true);
mqtt.put("host", "");
mqtt.put("port", 1883);
config.set("mqtt", mqtt);
ObjectNode mqtts = JacksonUtil.newObjectNode();
mqtts.put("enabled", true);
mqtts.put("host", "");
mqtts.put("port", 8883);
config.set("mqtts", mqtts);
ObjectNode coap = JacksonUtil.newObjectNode();
coap.put("enabled", true);
coap.put("host", "");
coap.put("port", 5683);
config.set("coap", coap);
ObjectNode coaps = JacksonUtil.newObjectNode();
coaps.put("enabled", true);
coaps.put("host", "");
coaps.put("port", 5684);
config.set("coaps", coaps);
AdminSettings adminSettings = doGet("/api/admin/settings/connectivity", AdminSettings.class); AdminSettings adminSettings = doGet("/api/admin/settings/connectivity", AdminSettings.class);
JsonNode connectivity = adminSettings.getJsonValue(); adminSettings.setJsonValue(config);
doPost("/api/admin/settings", adminSettings).andExpect(status().isOk());
((ObjectNode)connectivity.get("http")).put("port", 8080);
((ObjectNode)connectivity.get("http")).put("enabled", true);
((ObjectNode)connectivity.get("https")).put("enabled", true);
((ObjectNode)connectivity.get("https")).put("port", 444);
((ObjectNode)connectivity.get("mqtt")).put("enabled", true);
((ObjectNode)connectivity.get("mqtts")).put("enabled", true);
((ObjectNode)connectivity.get("coap")).put("enabled", true);
((ObjectNode)connectivity.get("coaps")).put("enabled", true);
doPost("/api/admin/settings", adminSettings);
Tenant tenant = new Tenant(); Tenant tenant = new Tenant();
tenant.setTitle("My tenant"); tenant.setTitle("My tenant");
@ -212,19 +241,19 @@ public class DeviceConnectivityControllerTest extends AbstractControllerTest {
JsonNode mqttCommands = commands.get(MQTT); JsonNode mqttCommands = commands.get(MQTT);
assertThat(mqttCommands.get(MQTT).asText()).isEqualTo(String.format("mosquitto_pub -d -q 1 -h localhost -p 1883 -t v1/devices/me/telemetry " + assertThat(mqttCommands.get(MQTT).asText()).isEqualTo(String.format("mosquitto_pub -d -q 1 -h localhost -p 1883 -t v1/devices/me/telemetry " +
"-u %s -m \"{temperature:25}\"", "-u \"%s\" -m \"{temperature:25}\"",
credentials.getCredentialsId())); credentials.getCredentialsId()));
assertThat(mqttCommands.get(MQTTS).get(0).asText()).isEqualTo("curl -f -S -o ca-root.pem http://localhost:80/api/device-connectivity/mqtts/certificate/download"); assertThat(mqttCommands.get(MQTTS).get(0).asText()).isEqualTo("curl -f -S -o ca-root.pem http://localhost:80/api/device-connectivity/mqtts/certificate/download");
assertThat(mqttCommands.get(MQTTS).get(1).asText()).isEqualTo(String.format("mosquitto_pub -d -q 1 --cafile ca-root.pem -h localhost -p 8883 " + assertThat(mqttCommands.get(MQTTS).get(1).asText()).isEqualTo(String.format("mosquitto_pub -d -q 1 --cafile ca-root.pem -h localhost -p 8883 " +
"-t v1/devices/me/telemetry -u %s -m \"{temperature:25}\"", credentials.getCredentialsId())); "-t v1/devices/me/telemetry -u \"%s\" -m \"{temperature:25}\"", credentials.getCredentialsId()));
JsonNode dockerMqttCommands = commands.get(MQTT).get(DOCKER); JsonNode dockerMqttCommands = commands.get(MQTT).get(DOCKER);
assertThat(dockerMqttCommands.get(MQTT).asText()).isEqualTo(String.format("docker run --rm -it thingsboard/mosquitto-clients mosquitto_pub -d -q 1 -h localhost" + assertThat(dockerMqttCommands.get(MQTT).asText()).isEqualTo(String.format("docker run --rm -it thingsboard/mosquitto-clients mosquitto_pub -d -q 1 -h localhost" +
" -p 1883 -t v1/devices/me/telemetry -u %s -m \"{temperature:25}\"", " -p 1883 -t v1/devices/me/telemetry -u \"%s\" -m \"{temperature:25}\"",
credentials.getCredentialsId())); credentials.getCredentialsId()));
assertThat(dockerMqttCommands.get(MQTTS).asText()).isEqualTo(String.format("docker run --rm -it thingsboard/mosquitto-clients " + assertThat(dockerMqttCommands.get(MQTTS).asText()).isEqualTo(String.format("docker run --rm -it thingsboard/mosquitto-clients " +
"/bin/sh -c \"curl -f -S -o ca-root.pem http://localhost:80/api/device-connectivity/mqtts/certificate/download && " + "/bin/sh -c \"curl -f -S -o ca-root.pem http://localhost:80/api/device-connectivity/mqtts/certificate/download && " +
"mosquitto_pub -d -q 1 --cafile ca-root.pem -h localhost -p 8883 -t v1/devices/me/telemetry -u %s -m \"{temperature:25}\"\"", "mosquitto_pub -d -q 1 --cafile ca-root.pem -h localhost -p 8883 -t v1/devices/me/telemetry -u \"%s\" -m \"{temperature:25}\"\"",
credentials.getCredentialsId())); credentials.getCredentialsId()));
JsonNode linuxCoapCommands = commands.get(COAP); JsonNode linuxCoapCommands = commands.get(COAP);
@ -251,18 +280,18 @@ public class DeviceConnectivityControllerTest extends AbstractControllerTest {
JsonNode mqttCommands = commands.get(MQTT); JsonNode mqttCommands = commands.get(MQTT);
assertThat(mqttCommands.get(MQTT).asText()).isEqualTo(String.format("mosquitto_pub -d -q 1 -h localhost -p 1883 -t %s " + assertThat(mqttCommands.get(MQTT).asText()).isEqualTo(String.format("mosquitto_pub -d -q 1 -h localhost -p 1883 -t %s " +
"-u %s -m \"{temperature:25}\"", DEVICE_TELEMETRY_TOPIC, credentials.getCredentialsId())); "-u \"%s\" -m \"{temperature:25}\"", DEVICE_TELEMETRY_TOPIC, credentials.getCredentialsId()));
assertThat(mqttCommands.get(MQTTS).get(0).asText()).isEqualTo("curl -f -S -o ca-root.pem http://localhost:80/api/device-connectivity/mqtts/certificate/download"); assertThat(mqttCommands.get(MQTTS).get(0).asText()).isEqualTo("curl -f -S -o ca-root.pem http://localhost:80/api/device-connectivity/mqtts/certificate/download");
assertThat(mqttCommands.get(MQTTS).get(1).asText()).isEqualTo(String.format("mosquitto_pub -d -q 1 --cafile ca-root.pem -h localhost -p 8883 " + assertThat(mqttCommands.get(MQTTS).get(1).asText()).isEqualTo(String.format("mosquitto_pub -d -q 1 --cafile ca-root.pem -h localhost -p 8883 " +
"-t %s -u %s -m \"{temperature:25}\"", DEVICE_TELEMETRY_TOPIC, credentials.getCredentialsId())); "-t %s -u \"%s\" -m \"{temperature:25}\"", DEVICE_TELEMETRY_TOPIC, credentials.getCredentialsId()));
JsonNode dockerMqttCommands = commands.get(MQTT).get(DOCKER); JsonNode dockerMqttCommands = commands.get(MQTT).get(DOCKER);
assertThat(dockerMqttCommands.get(MQTT).asText()).isEqualTo(String.format("docker run --rm -it thingsboard/mosquitto-clients mosquitto_pub -d -q 1 -h localhost" + assertThat(dockerMqttCommands.get(MQTT).asText()).isEqualTo(String.format("docker run --rm -it thingsboard/mosquitto-clients mosquitto_pub -d -q 1 -h localhost" +
" -p 1883 -t %s -u %s -m \"{temperature:25}\"", " -p 1883 -t %s -u \"%s\" -m \"{temperature:25}\"",
DEVICE_TELEMETRY_TOPIC, credentials.getCredentialsId())); DEVICE_TELEMETRY_TOPIC, credentials.getCredentialsId()));
assertThat(dockerMqttCommands.get(MQTTS).asText()).isEqualTo(String.format("docker run --rm -it thingsboard/mosquitto-clients " + assertThat(dockerMqttCommands.get(MQTTS).asText()).isEqualTo(String.format("docker run --rm -it thingsboard/mosquitto-clients " +
"/bin/sh -c \"curl -f -S -o ca-root.pem http://localhost:80/api/device-connectivity/mqtts/certificate/download && " + "/bin/sh -c \"curl -f -S -o ca-root.pem http://localhost:80/api/device-connectivity/mqtts/certificate/download && " +
"mosquitto_pub -d -q 1 --cafile ca-root.pem -h localhost -p 8883 -t %s -u %s -m \"{temperature:25}\"\"", "mosquitto_pub -d -q 1 --cafile ca-root.pem -h localhost -p 8883 -t %s -u \"%s\" -m \"{temperature:25}\"\"",
DEVICE_TELEMETRY_TOPIC, credentials.getCredentialsId())); DEVICE_TELEMETRY_TOPIC, credentials.getCredentialsId()));
} }
@ -295,18 +324,18 @@ public class DeviceConnectivityControllerTest extends AbstractControllerTest {
JsonNode mqttCommands = commands.get(MQTT); JsonNode mqttCommands = commands.get(MQTT);
assertThat(mqttCommands.get(MQTT).asText()).isEqualTo(String.format("mosquitto_pub -d -q 1 -h localhost -p 1883 -t %s " + assertThat(mqttCommands.get(MQTT).asText()).isEqualTo(String.format("mosquitto_pub -d -q 1 -h localhost -p 1883 -t %s " +
"-i %s -u %s -P %s -m \"{temperature:25}\"", DEVICE_TELEMETRY_TOPIC, clientId, userName, password)); "-i \"%s\" -u \"%s\" -P \"%s\" -m \"{temperature:25}\"", DEVICE_TELEMETRY_TOPIC, clientId, userName, password));
assertThat(mqttCommands.get(MQTTS).get(0).asText()).isEqualTo("curl -f -S -o ca-root.pem http://localhost:80/api/device-connectivity/mqtts/certificate/download"); assertThat(mqttCommands.get(MQTTS).get(0).asText()).isEqualTo("curl -f -S -o ca-root.pem http://localhost:80/api/device-connectivity/mqtts/certificate/download");
assertThat(mqttCommands.get(MQTTS).get(1).asText()).isEqualTo(String.format("mosquitto_pub -d -q 1 --cafile ca-root.pem -h localhost -p 8883 " + assertThat(mqttCommands.get(MQTTS).get(1).asText()).isEqualTo(String.format("mosquitto_pub -d -q 1 --cafile ca-root.pem -h localhost -p 8883 " +
"-t %s -i %s -u %s -P %s -m \"{temperature:25}\"", DEVICE_TELEMETRY_TOPIC, clientId, userName, password)); "-t %s -i \"%s\" -u \"%s\" -P \"%s\" -m \"{temperature:25}\"", DEVICE_TELEMETRY_TOPIC, clientId, userName, password));
JsonNode dockerMqttCommands = commands.get(MQTT).get(DOCKER); JsonNode dockerMqttCommands = commands.get(MQTT).get(DOCKER);
assertThat(dockerMqttCommands.get(MQTT).asText()).isEqualTo(String.format("docker run --rm -it thingsboard/mosquitto-clients mosquitto_pub -d -q 1 -h localhost" + assertThat(dockerMqttCommands.get(MQTT).asText()).isEqualTo(String.format("docker run --rm -it thingsboard/mosquitto-clients mosquitto_pub -d -q 1 -h localhost" +
" -p 1883 -t %s -i %s -u %s -P %s -m \"{temperature:25}\"", " -p 1883 -t %s -i \"%s\" -u \"%s\" -P \"%s\" -m \"{temperature:25}\"",
DEVICE_TELEMETRY_TOPIC, clientId, userName, password)); DEVICE_TELEMETRY_TOPIC, clientId, userName, password));
assertThat(dockerMqttCommands.get(MQTTS).asText()).isEqualTo(String.format("docker run --rm -it thingsboard/mosquitto-clients " + assertThat(dockerMqttCommands.get(MQTTS).asText()).isEqualTo(String.format("docker run --rm -it thingsboard/mosquitto-clients " +
"/bin/sh -c \"curl -f -S -o ca-root.pem http://localhost:80/api/device-connectivity/mqtts/certificate/download && " + "/bin/sh -c \"curl -f -S -o ca-root.pem http://localhost:80/api/device-connectivity/mqtts/certificate/download && " +
"mosquitto_pub -d -q 1 --cafile ca-root.pem -h localhost -p 8883 -t %s -i %s -u %s -P %s -m \"{temperature:25}\"\"", "mosquitto_pub -d -q 1 --cafile ca-root.pem -h localhost -p 8883 -t %s -i \"%s\" -u \"%s\" -P \"%s\" -m \"{temperature:25}\"\"",
DEVICE_TELEMETRY_TOPIC, clientId, userName, password)); DEVICE_TELEMETRY_TOPIC, clientId, userName, password));
} }

50
application/src/test/java/org/thingsboard/server/controller/DeviceConnectivityControllerWithDefaultPortTest.java

@ -29,6 +29,7 @@ import org.mockito.Mockito;
import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Primary; import org.springframework.context.annotation.Primary;
import org.springframework.test.context.ContextConfiguration; import org.springframework.test.context.ContextConfiguration;
import org.thingsboard.common.util.JacksonUtil;
import org.thingsboard.common.util.ThingsBoardExecutors; import org.thingsboard.common.util.ThingsBoardExecutors;
import org.thingsboard.server.common.data.AdminSettings; import org.thingsboard.server.common.data.AdminSettings;
import org.thingsboard.server.common.data.Device; import org.thingsboard.server.common.data.Device;
@ -66,16 +67,47 @@ public class DeviceConnectivityControllerWithDefaultPortTest extends AbstractCon
loginSysAdmin(); loginSysAdmin();
ObjectNode config = JacksonUtil.newObjectNode();
ObjectNode http = JacksonUtil.newObjectNode();
http.put("enabled", true);
http.put("host", "");
http.put("port", 80);
config.set("http", http);
ObjectNode https = JacksonUtil.newObjectNode();
https.put("enabled", true);
https.put("host", "");
https.put("port", 443);
config.set("https", https);
ObjectNode mqtt = JacksonUtil.newObjectNode();
mqtt.put("enabled", false);
mqtt.put("host", "");
mqtt.put("port", 1883);
config.set("mqtt", mqtt);
ObjectNode mqtts = JacksonUtil.newObjectNode();
mqtts.put("enabled", false);
mqtts.put("host", "");
mqtts.put("port", 8883);
config.set("mqtts", mqtts);
ObjectNode coap = JacksonUtil.newObjectNode();
coap.put("enabled", false);
coap.put("host", "");
coap.put("port", 5683);
config.set("coap", coap);
ObjectNode coaps = JacksonUtil.newObjectNode();
coaps.put("enabled", false);
coaps.put("host", "");
coaps.put("port", 5684);
config.set("coaps", coaps);
AdminSettings adminSettings = doGet("/api/admin/settings/connectivity", AdminSettings.class); AdminSettings adminSettings = doGet("/api/admin/settings/connectivity", AdminSettings.class);
JsonNode connectivity = adminSettings.getJsonValue(); adminSettings.setJsonValue(config);
doPost("/api/admin/settings", adminSettings).andExpect(status().isOk());
((ObjectNode) connectivity.get("http")).put("port", 80);
((ObjectNode) connectivity.get("https")).put("enabled", true);
((ObjectNode) connectivity.get("mqtt")).put("enabled", false);
((ObjectNode) connectivity.get("mqtts")).put("enabled", false);
((ObjectNode) connectivity.get("coaps")).put("enabled", false);
((ObjectNode) connectivity.get("coap")).put("enabled", false);
doPost("/api/admin/settings", adminSettings);
Tenant tenant = new Tenant(); Tenant tenant = new Tenant();
tenant.setTitle("My tenant"); tenant.setTitle("My tenant");

41
application/src/test/java/org/thingsboard/server/controller/UserControllerTest.java

@ -49,6 +49,7 @@ import org.thingsboard.server.common.data.page.PageLink;
import org.thingsboard.server.common.data.security.Authority; import org.thingsboard.server.common.data.security.Authority;
import org.thingsboard.server.common.data.settings.StarredDashboardInfo; import org.thingsboard.server.common.data.settings.StarredDashboardInfo;
import org.thingsboard.server.common.data.settings.UserDashboardsInfo; import org.thingsboard.server.common.data.settings.UserDashboardsInfo;
import org.thingsboard.server.dao.device.DeviceService;
import org.thingsboard.server.dao.exception.DataValidationException; import org.thingsboard.server.dao.exception.DataValidationException;
import org.thingsboard.server.dao.service.DaoSqlTest; import org.thingsboard.server.dao.service.DaoSqlTest;
import org.thingsboard.server.dao.user.UserDao; import org.thingsboard.server.dao.user.UserDao;
@ -81,6 +82,9 @@ public class UserControllerTest extends AbstractControllerTest {
@Autowired @Autowired
private UserDao userDao; private UserDao userDao;
@Autowired
private DeviceService deviceService;
static class Config { static class Config {
@Bean @Bean
@Primary @Primary
@ -740,6 +744,43 @@ public class UserControllerTest extends AbstractControllerTest {
Assert.assertEquals(expectedCustomerUserIds, loadedUserIds); Assert.assertEquals(expectedCustomerUserIds, loadedUserIds);
} }
@Test
public void testGetUsersForDeletedAlarmOriginator() throws Exception {
loginTenantAdmin();
String email = "testEmail1";
for (int i = 0; i < 45; i++) {
User customerUser = createCustomerUser( customerId);
customerUser.setEmail(email + StringUtils.randomAlphanumeric((int) (5 + Math.random() * 10)) + "@thingsboard.org");
doPost("/api/user", customerUser, User.class);
}
Device device = new Device();
device.setName("testDevice");
device.setCustomerId(customerId);
Device savedDevice = doPost("/api/device", device, Device.class);
Alarm alarm = createTestAlarm(savedDevice);
deviceService.deleteDevice(tenantId, savedDevice.getId());
List<UserId> loadedUserIds = new ArrayList<>();
PageLink pageLink = new PageLink(33, 0);
PageData<UserEmailInfo> pageData;
do {
pageData = doGetTypedWithPageLink("/api/users/assign/" + alarm.getId().getId().toString() + "?",
new TypeReference<>() {}, pageLink);
loadedUserIds.addAll(pageData.getData().stream().map(UserEmailInfo::getId)
.collect(Collectors.toList()));
if (pageData.hasNext()) {
pageLink = pageLink.nextPageLink();
}
} while (pageData.hasNext());
Assert.assertEquals(1, loadedUserIds.size());
Assert.assertEquals(tenantAdminUserId, loadedUserIds.get(0));
}
@Test @Test
public void testDeleteUserWithDeleteRelationsOk() throws Exception { public void testDeleteUserWithDeleteRelationsOk() throws Exception {
loginSysAdmin(); loginSysAdmin();

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

@ -268,6 +268,13 @@ public class WidgetsBundleControllerTest extends AbstractControllerTest {
Collections.sort(loadedWidgetsBundles2, idComparator); Collections.sort(loadedWidgetsBundles2, idComparator);
Assert.assertEquals(tenantWidgetsBundles, loadedWidgetsBundles2); Assert.assertEquals(tenantWidgetsBundles, loadedWidgetsBundles2);
// cleanup
loginSysAdmin();
for (WidgetsBundle sysWidgetsBundle : sysWidgetsBundles) {
doDelete("/api/widgetsBundle/" + sysWidgetsBundle.getId().getId().toString())
.andExpect(status().isOk());
}
} }
@Test @Test

4
application/src/test/java/org/thingsboard/server/service/queue/ruleengine/TbRuleEngineQueueConsumerManagerTest.java

@ -458,7 +458,7 @@ public class TbRuleEngineQueueConsumerManagerTest {
verify(consumer2, never()).unsubscribe(); verify(consumer2, never()).unsubscribe();
int msgCount = totalConsumedMsgs.get(); int msgCount = totalConsumedMsgs.get();
await().atLeast(4, TimeUnit.SECONDS) // based on topicDeletionDelayInSec await().atLeast(2, TimeUnit.SECONDS) // based on topicDeletionDelayInSec(5) = 5 - ( 3 seconds the code may execute starting consumerManager.delete() call)
.atMost(7, TimeUnit.SECONDS) .atMost(7, TimeUnit.SECONDS)
.untilAsserted(() -> { .untilAsserted(() -> {
partitions.stream() partitions.stream()
@ -498,7 +498,7 @@ public class TbRuleEngineQueueConsumerManagerTest {
verify(consumer, never()).unsubscribe(); verify(consumer, never()).unsubscribe();
int msgCount = totalConsumedMsgs.get(); int msgCount = totalConsumedMsgs.get();
await().atLeast(4, TimeUnit.SECONDS) await().atLeast(2, TimeUnit.SECONDS) // based on topicDeletionDelayInSec(5) = 5 - ( 3 seconds the code may execute starting consumerManager.delete() call)
.atMost(7, TimeUnit.SECONDS) .atMost(7, TimeUnit.SECONDS)
.untilAsserted(() -> { .untilAsserted(() -> {
partitions.stream() partitions.stream()

13
common/cache/src/main/java/org/thingsboard/server/cache/device/DeviceCacheKey.java

@ -34,6 +34,10 @@ public class DeviceCacheKey implements Serializable {
private final DeviceId deviceId; private final DeviceId deviceId;
private final String deviceName; private final String deviceName;
public DeviceCacheKey(DeviceId deviceId) {
this(null, deviceId, null);
}
public DeviceCacheKey(TenantId tenantId, DeviceId deviceId) { public DeviceCacheKey(TenantId tenantId, DeviceId deviceId) {
this(tenantId, deviceId, null); this(tenantId, deviceId, null);
} }
@ -44,11 +48,12 @@ public class DeviceCacheKey implements Serializable {
@Override @Override
public String toString() { public String toString() {
if (deviceId != null) { if (deviceId == null) {
return tenantId + "_" + deviceId;
} else {
return tenantId + "_n_" + deviceName; return tenantId + "_n_" + deviceName;
} else if (tenantId == null) {
return deviceId.toString();
} else {
return tenantId + "_" + deviceId;
} }
} }
} }

3
dao/src/main/java/org/thingsboard/server/dao/alarm/BaseAlarmService.java

@ -188,6 +188,7 @@ public class BaseAlarmService extends AbstractCachedEntityService<TenantId, Page
if (alarm == null) { if (alarm == null) {
return AlarmApiCallResult.builder().successful(false).build(); return AlarmApiCallResult.builder().successful(false).build();
} else { } else {
var propagationIds = getPropagationEntityIdsList(alarm);
deleteEntityRelations(tenantId, alarm.getId()); deleteEntityRelations(tenantId, alarm.getId());
alarmDao.removeById(tenantId, alarm.getUuidId()); alarmDao.removeById(tenantId, alarm.getUuidId());
eventPublisher.publishEvent(DeleteEntityEvent.builder().tenantId(tenantId) eventPublisher.publishEvent(DeleteEntityEvent.builder().tenantId(tenantId)
@ -195,7 +196,7 @@ public class BaseAlarmService extends AbstractCachedEntityService<TenantId, Page
if (checkAndDeleteAlarmType) { if (checkAndDeleteAlarmType) {
delAlarmTypes(tenantId, Collections.singleton(alarm.getType())); delAlarmTypes(tenantId, Collections.singleton(alarm.getType()));
} }
return AlarmApiCallResult.builder().alarm(alarm).deleted(true).successful(true).build(); return AlarmApiCallResult.builder().alarm(alarm).deleted(true).successful(true).propagatedEntitiesList(propagationIds).build();
} }
} }

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

@ -130,15 +130,13 @@ public class DeviceServiceImpl extends AbstractCachedEntityService<DeviceCacheKe
public Device findDeviceById(TenantId tenantId, DeviceId deviceId) { public Device findDeviceById(TenantId tenantId, DeviceId deviceId) {
log.trace("Executing findDeviceById [{}]", deviceId); log.trace("Executing findDeviceById [{}]", deviceId);
validateId(deviceId, INCORRECT_DEVICE_ID + deviceId); validateId(deviceId, INCORRECT_DEVICE_ID + deviceId);
return cache.getAndPutInTransaction(new DeviceCacheKey(tenantId, deviceId), if (TenantId.SYS_TENANT_ID.equals(tenantId)) {
() -> { return cache.getAndPutInTransaction(new DeviceCacheKey(deviceId),
//TODO: possible bug source since sometimes we need to clear cache by tenant id and sometimes by sys tenant id? () -> deviceDao.findById(tenantId, deviceId.getId()), true);
if (TenantId.SYS_TENANT_ID.equals(tenantId)) { } else {
return deviceDao.findById(tenantId, deviceId.getId()); return cache.getAndPutInTransaction(new DeviceCacheKey(tenantId, deviceId),
} else { () -> deviceDao.findDeviceByTenantIdAndId(tenantId, deviceId.getId()), true);
return deviceDao.findDeviceByTenantIdAndId(tenantId, deviceId.getId()); }
}
}, true);
} }
@Override @Override
@ -258,6 +256,7 @@ public class DeviceServiceImpl extends AbstractCachedEntityService<DeviceCacheKe
List<DeviceCacheKey> keys = new ArrayList<>(3); List<DeviceCacheKey> keys = new ArrayList<>(3);
keys.add(new DeviceCacheKey(event.getTenantId(), event.getNewName())); keys.add(new DeviceCacheKey(event.getTenantId(), event.getNewName()));
if (event.getDeviceId() != null) { if (event.getDeviceId() != null) {
keys.add(new DeviceCacheKey(event.getDeviceId()));
keys.add(new DeviceCacheKey(event.getTenantId(), event.getDeviceId())); keys.add(new DeviceCacheKey(event.getTenantId(), event.getDeviceId()));
} }
if (StringUtils.isNotEmpty(event.getOldName()) && !event.getOldName().equals(event.getNewName())) { if (StringUtils.isNotEmpty(event.getOldName()) && !event.getOldName().equals(event.getNewName())) {

8
dao/src/main/java/org/thingsboard/server/dao/util/DeviceConnectivityUtil.java

@ -54,20 +54,20 @@ public class DeviceConnectivityUtil {
switch (deviceCredentials.getCredentialsType()) { switch (deviceCredentials.getCredentialsType()) {
case ACCESS_TOKEN: case ACCESS_TOKEN:
command.append(" -u ").append(deviceCredentials.getCredentialsId()); command.append(" -u \"").append(deviceCredentials.getCredentialsId()).append("\"");
break; break;
case MQTT_BASIC: case MQTT_BASIC:
BasicMqttCredentials credentials = JacksonUtil.fromString(deviceCredentials.getCredentialsValue(), BasicMqttCredentials credentials = JacksonUtil.fromString(deviceCredentials.getCredentialsValue(),
BasicMqttCredentials.class); BasicMqttCredentials.class);
if (credentials != null) { if (credentials != null) {
if (credentials.getClientId() != null) { if (credentials.getClientId() != null) {
command.append(" -i ").append(credentials.getClientId()); command.append(" -i \"").append(credentials.getClientId()).append("\"");
} }
if (credentials.getUserName() != null) { if (credentials.getUserName() != null) {
command.append(" -u ").append(credentials.getUserName()); command.append(" -u \"").append(credentials.getUserName()).append("\"");
} }
if (credentials.getPassword() != null) { if (credentials.getPassword() != null) {
command.append(" -P ").append(credentials.getPassword()); command.append(" -P \"").append(credentials.getPassword()).append("\"");;
} }
} else { } else {
return null; return null;

14
msa/black-box-tests/src/test/java/org/thingsboard/server/msa/WsClient.java

@ -33,8 +33,8 @@ public class WsClient extends WebSocketClient {
private WsTelemetryResponse message; private WsTelemetryResponse message;
private volatile boolean firstReplyReceived; private volatile boolean firstReplyReceived;
private CountDownLatch firstReply = new CountDownLatch(1); private final CountDownLatch firstReply = new CountDownLatch(1);
private CountDownLatch latch = new CountDownLatch(1); private final CountDownLatch latch = new CountDownLatch(1);
private final long timeoutMultiplier; private final long timeoutMultiplier;
@ -48,7 +48,8 @@ public class WsClient extends WebSocketClient {
} }
@Override @Override
public void onMessage(String message) { public synchronized void onMessage(String message) {
log.error("WS onMessage: {}", message);
if (!firstReplyReceived) { if (!firstReplyReceived) {
firstReplyReceived = true; firstReplyReceived = true;
firstReply.countDown(); firstReply.countDown();
@ -66,12 +67,13 @@ public class WsClient extends WebSocketClient {
} }
@Override @Override
public void onClose(int code, String reason, boolean remote) { public synchronized void onClose(int code, String reason, boolean remote) {
log.info("ws is closed, due to [{}]", reason); log.error("WS onClose: [{}]", reason);
} }
@Override @Override
public void onError(Exception ex) { public synchronized void onError(Exception ex) {
log.error("WS onError: ", ex);
ex.printStackTrace(); ex.printStackTrace();
} }

1
msa/black-box-tests/src/test/resources/logback.xml

@ -25,6 +25,7 @@
</encoder> </encoder>
</appender> </appender>
<root level="INFO"> <root level="INFO">
<appender-ref ref="STDOUT"/> <appender-ref ref="STDOUT"/>
</root> </root>

58
msa/black-box-tests/src/test/resources/tb-node/conf/logback.xml

@ -0,0 +1,58 @@
<?xml version="1.0" encoding="UTF-8" ?>
<!--
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.
-->
<!DOCTYPE configuration>
<configuration scan="true" scanPeriod="10 seconds">
<appender name="fileLogAppender"
class="ch.qos.logback.core.rolling.RollingFileAppender">
<file>/var/log/thingsboard/${TB_SERVICE_ID}/thingsboard.log</file>
<rollingPolicy
class="ch.qos.logback.core.rolling.SizeAndTimeBasedRollingPolicy">
<fileNamePattern>/var/log/thingsboard/${TB_SERVICE_ID}/thingsboard.%d{yyyy-MM-dd}.%i.log</fileNamePattern>
<maxFileSize>100MB</maxFileSize>
<maxHistory>30</maxHistory>
<totalSizeCap>3GB</totalSizeCap>
</rollingPolicy>
<encoder>
<pattern>%d{ISO8601} [%thread] %-5level %logger{36} - %msg%n</pattern>
</encoder>
</appender>
<appender name="STDOUT" class="ch.qos.logback.core.ConsoleAppender">
<encoder>
<pattern>%d{ISO8601} [%thread] %-5level %logger{36} - %msg%n</pattern>
</encoder>
</appender>
<logger name="org.thingsboard.server" level="INFO" />
<logger name="com.google.common.util.concurrent.AggregateFuture" level="OFF" />
<logger name="org.apache.kafka.common.utils.AppInfoParser" level="WARN"/>
<logger name="org.apache.kafka.clients" level="WARN"/>
<logger name="com.microsoft.azure.servicebus.primitives.CoreMessageReceiver" level="OFF" />
<logger name="org.thingsboard.server.service" level="DEBUG"/>
<logger name="org.thingsboard.server.service.subscription" level="TRACE"/>
<root level="INFO">
<appender-ref ref="fileLogAppender"/>
<appender-ref ref="STDOUT"/>
</root>
</configuration>

57
msa/black-box-tests/src/test/resources/tb-transports/coap/conf/logback.xml

@ -0,0 +1,57 @@
<?xml version="1.0" encoding="UTF-8" ?>
<!--
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.
-->
<!DOCTYPE configuration>
<configuration scan="true" scanPeriod="10 seconds">
<appender name="fileLogAppender"
class="ch.qos.logback.core.rolling.RollingFileAppender">
<file>/var/log/tb-coap-transport/${TB_SERVICE_ID}/tb-coap-transport.log</file>
<rollingPolicy
class="ch.qos.logback.core.rolling.SizeAndTimeBasedRollingPolicy">
<fileNamePattern>/var/log/tb-coap-transport/${TB_SERVICE_ID}/tb-coap-transport.%d{yyyy-MM-dd}.%i.log</fileNamePattern>
<maxFileSize>100MB</maxFileSize>
<maxHistory>30</maxHistory>
<totalSizeCap>3GB</totalSizeCap>
</rollingPolicy>
<encoder>
<pattern>%d{ISO8601} [%thread] %-5level %logger{36} - %msg%n</pattern>
</encoder>
</appender>
<appender name="STDOUT" class="ch.qos.logback.core.ConsoleAppender">
<encoder>
<pattern>%d{ISO8601} [%thread] %-5level %logger{36} - %msg%n</pattern>
</encoder>
</appender>
<logger name="org.thingsboard.server" level="INFO" />
<logger name="com.microsoft.azure.servicebus.primitives.CoreMessageReceiver" level="OFF" />
<logger name="org.apache.kafka.common.utils.AppInfoParser" level="WARN"/>
<logger name="org.apache.kafka.clients" level="WARN"/>
<logger name="org.thingsboard.server.transport.coap" level="TRACE"/>
<root level="INFO">
<appender-ref ref="fileLogAppender"/>
<appender-ref ref="STDOUT"/>
</root>
</configuration>

57
msa/black-box-tests/src/test/resources/tb-transports/http/conf/logback.xml

@ -0,0 +1,57 @@
<?xml version="1.0" encoding="UTF-8" ?>
<!--
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.
-->
<!DOCTYPE configuration>
<configuration scan="true" scanPeriod="10 seconds">
<appender name="fileLogAppender"
class="ch.qos.logback.core.rolling.RollingFileAppender">
<file>/var/log/tb-http-transport/${TB_SERVICE_ID}/tb-http-transport.log</file>
<rollingPolicy
class="ch.qos.logback.core.rolling.SizeAndTimeBasedRollingPolicy">
<fileNamePattern>/var/log/tb-http-transport/${TB_SERVICE_ID}/tb-http-transport.%d{yyyy-MM-dd}.%i.log</fileNamePattern>
<maxFileSize>100MB</maxFileSize>
<maxHistory>30</maxHistory>
<totalSizeCap>3GB</totalSizeCap>
</rollingPolicy>
<encoder>
<pattern>%d{ISO8601} [%thread] %-5level %logger{36} - %msg%n</pattern>
</encoder>
</appender>
<appender name="STDOUT" class="ch.qos.logback.core.ConsoleAppender">
<encoder>
<pattern>%d{ISO8601} [%thread] %-5level %logger{36} - %msg%n</pattern>
</encoder>
</appender>
<logger name="org.thingsboard.server" level="INFO" />
<logger name="com.microsoft.azure.servicebus.primitives.CoreMessageReceiver" level="OFF" />
<logger name="org.apache.kafka.common.utils.AppInfoParser" level="WARN"/>
<logger name="org.apache.kafka.clients" level="WARN"/>
<logger name="org.thingsboard.server.transport.http" level="TRACE"/>
<root level="INFO">
<appender-ref ref="fileLogAppender"/>
<appender-ref ref="STDOUT"/>
</root>
</configuration>

55
msa/black-box-tests/src/test/resources/tb-transports/mqtt/conf/logback.xml

@ -0,0 +1,55 @@
<?xml version="1.0" encoding="UTF-8" ?>
<!--
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.
-->
<!DOCTYPE configuration>
<configuration scan="true" scanPeriod="10 seconds">
<appender name="fileLogAppender"
class="ch.qos.logback.core.rolling.RollingFileAppender">
<file>/var/log/tb-mqtt-transport/${TB_SERVICE_ID}/tb-mqtt-transport.log</file>
<rollingPolicy
class="ch.qos.logback.core.rolling.SizeAndTimeBasedRollingPolicy">
<fileNamePattern>/var/log/tb-mqtt-transport/${TB_SERVICE_ID}/tb-mqtt-transport.%d{yyyy-MM-dd}.%i.log</fileNamePattern>
<maxFileSize>100MB</maxFileSize>
<maxHistory>30</maxHistory>
<totalSizeCap>3GB</totalSizeCap>
</rollingPolicy>
<encoder>
<pattern>%d{ISO8601} [%thread] %-5level %logger{36} - %msg%n</pattern>
</encoder>
</appender>
<appender name="STDOUT" class="ch.qos.logback.core.ConsoleAppender">
<encoder>
<pattern>%d{ISO8601} [%thread] %-5level %logger{36} - %msg%n</pattern>
</encoder>
</appender>
<logger name="org.thingsboard.server" level="INFO" />
<logger name="com.microsoft.azure.servicebus.primitives.CoreMessageReceiver" level="OFF" />
<logger name="org.apache.kafka.common.utils.AppInfoParser" level="WARN"/>
<logger name="org.apache.kafka.clients" level="WARN"/>
<logger name="org.thingsboard.server.transport.mqtt" level="TRACE"/>
<root level="INFO">
<appender-ref ref="fileLogAppender"/>
<appender-ref ref="STDOUT"/>
</root>
</configuration>

17
rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/rest/TbHttpClient.java

@ -194,7 +194,7 @@ public class TbHttpClient {
config.isIgnoreRequestBody()) { config.isIgnoreRequestBody()) {
entity = new HttpEntity<>(headers); entity = new HttpEntity<>(headers);
} else { } else {
entity = new HttpEntity<>(getData(msg), headers); entity = new HttpEntity<>(getData(msg, config.isIgnoreRequestBody(), config.isParseToPlainText()), headers);
} }
URI uri = buildEncodedUri(endpointUrl); URI uri = buildEncodedUri(endpointUrl);
@ -242,12 +242,19 @@ public class TbHttpClient {
return uri; return uri;
} }
private String getData(TbMsg msg) { private String getData(TbMsg tbMsg, boolean ignoreBody, boolean parseToPlainText) {
String data = msg.getData(); if (!ignoreBody && parseToPlainText) {
return parseJsonStringToPlainText(tbMsg.getData());
}
return tbMsg.getData();
}
if (config.isTrimDoubleQuotes()) { protected String parseJsonStringToPlainText(String data) {
if (data.startsWith("\"") && data.endsWith("\"") && data.length() >= 2) {
final String dataBefore = data; final String dataBefore = data;
data = data.replaceAll("^\"|\"$", ""); try {
data = JacksonUtil.fromString(data, String.class);
} catch (Exception ignored) {}
log.trace("Trimming double quotes. Before trim: [{}], after trim: [{}]", dataBefore, data); log.trace("Trimming double quotes. Before trim: [{}], after trim: [{}]", dataBefore, data);
} }

23
rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/rest/TbRestApiCallNode.java

@ -15,6 +15,8 @@
*/ */
package org.thingsboard.rule.engine.rest; package org.thingsboard.rule.engine.rest;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.node.ObjectNode;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.thingsboard.rule.engine.api.RuleNode; import org.thingsboard.rule.engine.api.RuleNode;
import org.thingsboard.rule.engine.api.TbContext; import org.thingsboard.rule.engine.api.TbContext;
@ -23,6 +25,7 @@ import org.thingsboard.rule.engine.api.TbNodeException;
import org.thingsboard.rule.engine.api.util.TbNodeUtils; import org.thingsboard.rule.engine.api.util.TbNodeUtils;
import org.thingsboard.rule.engine.external.TbAbstractExternalNode; import org.thingsboard.rule.engine.external.TbAbstractExternalNode;
import org.thingsboard.server.common.data.plugin.ComponentType; import org.thingsboard.server.common.data.plugin.ComponentType;
import org.thingsboard.server.common.data.util.TbPair;
import org.thingsboard.server.common.msg.TbMsg; import org.thingsboard.server.common.msg.TbMsg;
@Slf4j @Slf4j
@ -30,6 +33,7 @@ import org.thingsboard.server.common.msg.TbMsg;
type = ComponentType.EXTERNAL, type = ComponentType.EXTERNAL,
name = "rest api call", name = "rest api call",
configClazz = TbRestApiCallNodeConfiguration.class, configClazz = TbRestApiCallNodeConfiguration.class,
version = 1,
nodeDescription = "Invoke REST API calls to external REST server", nodeDescription = "Invoke REST API calls to external REST server",
nodeDetails = "Will invoke REST API call <code>GET | POST | PUT | DELETE</code> to external REST server. " + nodeDetails = "Will invoke REST API call <code>GET | POST | PUT | DELETE</code> to external REST server. " +
"Message payload added into Request body. Configured attributes can be added into Headers from Message Metadata." + "Message payload added into Request body. Configured attributes can be added into Headers from Message Metadata." +
@ -45,6 +49,8 @@ import org.thingsboard.server.common.msg.TbMsg;
) )
public class TbRestApiCallNode extends TbAbstractExternalNode { public class TbRestApiCallNode extends TbAbstractExternalNode {
static final String PARSE_TO_PLAIN_TEXT = "parseToPlainText";
static final String TRIM_DOUBLE_QUOTES = "trimDoubleQuotes";
protected TbHttpClient httpClient; protected TbHttpClient httpClient;
@Override @Override
@ -72,4 +78,21 @@ public class TbRestApiCallNode extends TbAbstractExternalNode {
} }
} }
@Override
public TbPair<Boolean, JsonNode> upgrade(int fromVersion, JsonNode oldConfiguration) throws TbNodeException {
boolean hasChanges = false;
switch (fromVersion) {
case 0:
if (!oldConfiguration.has(PARSE_TO_PLAIN_TEXT) && oldConfiguration.has(TRIM_DOUBLE_QUOTES)) {
hasChanges = true;
((ObjectNode) oldConfiguration).put(PARSE_TO_PLAIN_TEXT, oldConfiguration.get(TRIM_DOUBLE_QUOTES).booleanValue());
((ObjectNode) oldConfiguration).remove(TRIM_DOUBLE_QUOTES);
}
break;
default:
break;
}
return new TbPair<>(hasChanges, oldConfiguration);
}
} }

4
rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/rest/TbRestApiCallNodeConfiguration.java

@ -37,7 +37,7 @@ public class TbRestApiCallNodeConfiguration implements NodeConfiguration<TbRestA
private int readTimeoutMs; private int readTimeoutMs;
private int maxParallelRequestsCount; private int maxParallelRequestsCount;
private boolean useRedisQueueForMsgPersistence; private boolean useRedisQueueForMsgPersistence;
private boolean trimDoubleQuotes; private boolean parseToPlainText;
private boolean enableProxy; private boolean enableProxy;
private boolean useSystemProxyProperties; private boolean useSystemProxyProperties;
private String proxyHost; private String proxyHost;
@ -58,7 +58,7 @@ public class TbRestApiCallNodeConfiguration implements NodeConfiguration<TbRestA
configuration.setReadTimeoutMs(0); configuration.setReadTimeoutMs(0);
configuration.setMaxParallelRequestsCount(0); configuration.setMaxParallelRequestsCount(0);
configuration.setUseRedisQueueForMsgPersistence(false); configuration.setUseRedisQueueForMsgPersistence(false);
configuration.setTrimDoubleQuotes(false); configuration.setParseToPlainText(false);
configuration.setEnableProxy(false); configuration.setEnableProxy(false);
configuration.setCredentials(new AnonymousCredentials()); configuration.setCredentials(new AnonymousCredentials());
configuration.setIgnoreRequestBody(false); configuration.setIgnoreRequestBody(false);

43
rule-engine/rule-engine-components/src/test/java/org/thingsboard/rule/engine/rest/TbHttpClientTest.java

@ -18,17 +18,19 @@ package org.thingsboard.rule.engine.rest;
import io.netty.channel.EventLoopGroup; import io.netty.channel.EventLoopGroup;
import io.netty.channel.nio.NioEventLoopGroup; import io.netty.channel.nio.NioEventLoopGroup;
import org.assertj.core.api.Assertions;
import org.awaitility.Awaitility; import org.awaitility.Awaitility;
import org.junit.After; import org.junit.jupiter.api.AfterEach;
import org.junit.Assert; import org.junit.jupiter.api.Assertions;
import org.junit.Before; import org.junit.jupiter.api.BeforeEach;
import org.junit.Test; import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.ValueSource;
import org.mockito.ArgumentCaptor; import org.mockito.ArgumentCaptor;
import org.mockito.Mockito; import org.mockito.Mockito;
import org.mockserver.integration.ClientAndServer; import org.mockserver.integration.ClientAndServer;
import org.springframework.util.LinkedMultiValueMap; import org.springframework.util.LinkedMultiValueMap;
import org.springframework.web.client.AsyncRestTemplate; import org.springframework.web.client.AsyncRestTemplate;
import org.thingsboard.common.util.JacksonUtil;
import org.thingsboard.rule.engine.api.TbContext; import org.thingsboard.rule.engine.api.TbContext;
import org.thingsboard.server.common.data.id.DeviceId; import org.thingsboard.server.common.data.id.DeviceId;
import org.thingsboard.server.common.data.id.EntityId; import org.thingsboard.server.common.data.id.EntityId;
@ -46,6 +48,7 @@ import static org.hamcrest.MatcherAssert.assertThat;
import static org.hamcrest.Matchers.instanceOf; import static org.hamcrest.Matchers.instanceOf;
import static org.hamcrest.Matchers.is; import static org.hamcrest.Matchers.is;
import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.ArgumentMatchers.eq; import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.BDDMockito.willCallRealMethod; import static org.mockito.BDDMockito.willCallRealMethod;
import static org.mockito.Mockito.mock; import static org.mockito.Mockito.mock;
@ -61,13 +64,13 @@ public class TbHttpClientTest {
EventLoopGroup eventLoop; EventLoopGroup eventLoop;
TbHttpClient client; TbHttpClient client;
@Before @BeforeEach
public void setUp() throws Exception { public void setUp() throws Exception {
client = mock(TbHttpClient.class); client = mock(TbHttpClient.class);
willCallRealMethod().given(client).getSharedOrCreateEventLoopGroup(any()); when(client.getSharedOrCreateEventLoopGroup(any())).thenCallRealMethod();
} }
@After @AfterEach
public void tearDown() throws Exception { public void tearDown() throws Exception {
if (eventLoop != null) { if (eventLoop != null) {
eventLoop.shutdownGracefully(); eventLoop.shutdownGracefully();
@ -91,7 +94,7 @@ public class TbHttpClientTest {
Mockito.when(client.buildEncodedUri(any())).thenCallRealMethod(); Mockito.when(client.buildEncodedUri(any())).thenCallRealMethod();
String url = "http://localhost:8080/"; String url = "http://localhost:8080/";
URI uri = client.buildEncodedUri(url); URI uri = client.buildEncodedUri(url);
Assert.assertEquals(url, uri.toString()); Assertions.assertEquals(url, uri.toString());
} }
@Test @Test
@ -114,7 +117,7 @@ public class TbHttpClientTest {
String url = "http://192.168.1.1/data?d={\"a\": 12}"; String url = "http://192.168.1.1/data?d={\"a\": 12}";
String expected = "http://192.168.1.1/data?d=%7B%22a%22:%2012%7D"; String expected = "http://192.168.1.1/data?d=%7B%22a%22:%2012%7D";
URI uri = client.buildEncodedUri(url); URI uri = client.buildEncodedUri(url);
Assert.assertEquals(expected, uri.toString()); Assertions.assertEquals(expected, uri.toString());
} }
@Test @Test
@ -183,7 +186,7 @@ public class TbHttpClientTest {
verify(ctx, times(1)).tellSuccess(any()); verify(ctx, times(1)).tellSuccess(any());
verify(ctx, times(0)).tellFailure(any(), any()); verify(ctx, times(0)).tellFailure(any(), any());
Assert.assertEquals(successResponseBody, capturedData.getValue()); Assertions.assertEquals(successResponseBody, capturedData.getValue());
} }
private ClientAndServer setUpDummyServer(String host, String path, String paramKey, String paramVal, String successResponseBody) { private ClientAndServer setUpDummyServer(String host, String path, String paramKey, String paramVal, String successResponseBody) {
@ -219,9 +222,21 @@ public class TbHttpClientTest {
Map<String, String> data = metaData.getData(); Map<String, String> data = metaData.getData();
Assertions.assertThat(data).hasSize(2); Assertions.assertEquals(2, data.size());
Assertions.assertThat(data.get("Content-Type")).isEqualTo("binary"); Assertions.assertEquals(data.get("Content-Type"), "binary");
Assertions.assertThat(data.get("Set-Cookie")).isEqualTo("[\"sap-context=sap-client=075; path=/\",\"sap-token=sap-client=075; path=/\"]"); Assertions.assertEquals(data.get("Set-Cookie"), "[\"sap-context=sap-client=075; path=/\",\"sap-token=sap-client=075; path=/\"]");
} }
@ParameterizedTest
@ValueSource(strings = { "false", "\"", "\"\"", "\"This is a string with double quotes\"", "Path: /home/developer/test.txt",
"First line\nSecond line\n\nFourth line", "Before\rAfter", "Tab\tSeparated\tValues", "Test\bbackspace", "[]",
"[1, 2, 3]", "{\"key\": \"value\"}", "{\n\"temperature\": 25.5,\n\"humidity\": 50.2\n\"}", "Expression: (a + b) * c",
"世界", "Україна", "\u1F1FA\u1F1E6", "🇺🇦"})
public void testParseJsonStringToPlainText(String original) {
Mockito.when(client.parseJsonStringToPlainText(anyString())).thenCallRealMethod();
String serialized = JacksonUtil.toString(original);
Assertions.assertNotNull(serialized);
Assertions.assertEquals(original, client.parseJsonStringToPlainText(serialized));
}
} }

23
rule-engine/rule-engine-components/src/test/java/org/thingsboard/rule/engine/rest/TbRestApiCallNodeTest.java

@ -16,6 +16,7 @@
package org.thingsboard.rule.engine.rest; package org.thingsboard.rule.engine.rest;
import com.datastax.oss.driver.api.core.uuid.Uuids; import com.datastax.oss.driver.api.core.uuid.Uuids;
import com.fasterxml.jackson.databind.JsonNode;
import org.apache.http.HttpException; import org.apache.http.HttpException;
import org.apache.http.HttpRequest; import org.apache.http.HttpRequest;
import org.apache.http.HttpResponse; import org.apache.http.HttpResponse;
@ -26,6 +27,7 @@ import org.apache.http.protocol.HttpContext;
import org.apache.http.protocol.HttpRequestHandler; import org.apache.http.protocol.HttpRequestHandler;
import org.junit.After; import org.junit.After;
import org.junit.Test; import org.junit.Test;
import org.junit.jupiter.api.Assertions;
import org.junit.runner.RunWith; import org.junit.runner.RunWith;
import org.mockito.ArgumentCaptor; import org.mockito.ArgumentCaptor;
import org.mockito.Mock; import org.mockito.Mock;
@ -39,6 +41,7 @@ import org.thingsboard.server.common.data.id.EntityId;
import org.thingsboard.server.common.data.id.RuleChainId; import org.thingsboard.server.common.data.id.RuleChainId;
import org.thingsboard.server.common.data.id.RuleNodeId; import org.thingsboard.server.common.data.id.RuleNodeId;
import org.thingsboard.server.common.data.msg.TbMsgType; import org.thingsboard.server.common.data.msg.TbMsgType;
import org.thingsboard.server.common.data.util.TbPair;
import org.thingsboard.server.common.msg.TbMsg; import org.thingsboard.server.common.msg.TbMsg;
import org.thingsboard.server.common.msg.TbMsgDataType; import org.thingsboard.server.common.msg.TbMsgDataType;
import org.thingsboard.server.common.msg.TbMsgMetaData; import org.thingsboard.server.common.msg.TbMsgMetaData;
@ -91,7 +94,9 @@ public class TbRestApiCallNodeTest {
@After @After
public void teardown() { public void teardown() {
server.stop(); if (server != null) {
server.stop();
}
} }
@Test @Test
@ -211,4 +216,20 @@ public class TbRestApiCallNodeTest {
assertEquals(TbMsg.EMPTY_JSON_OBJECT, dataCaptor.getValue()); assertEquals(TbMsg.EMPTY_JSON_OBJECT, dataCaptor.getValue());
} }
@Test
public void givenOldConfig_whenUpgrade_thenShouldReturnTrueResultWithNewConfig() throws Exception {
var defaultConfig = new TbRestApiCallNodeConfiguration().defaultConfiguration();
var node = new TbRestApiCallNode();
String oldConfig = "{\"restEndpointUrlPattern\":\"http://localhost/api\",\"requestMethod\":\"POST\"," +
"\"useSimpleClientHttpFactory\":false,\"ignoreRequestBody\":false,\"enableProxy\":false," +
"\"useSystemProxyProperties\":false,\"proxyScheme\":null,\"proxyHost\":null,\"proxyPort\":0," +
"\"proxyUser\":null,\"proxyPassword\":null,\"readTimeoutMs\":0,\"maxParallelRequestsCount\":0," +
"\"headers\":{\"Content-Type\":\"application/json\"},\"useRedisQueueForMsgPersistence\":false," +
"\"trimQueue\":null,\"maxQueueSize\":null,\"credentials\":{\"type\":\"anonymous\"},\"trimDoubleQuotes\":true}";
JsonNode configJson = JacksonUtil.toJsonNode(oldConfig);
TbPair<Boolean, JsonNode> upgrade = node.upgrade(0, configJson);
Assertions.assertTrue(upgrade.getFirst());
Assertions.assertTrue(JacksonUtil.treeToValue(upgrade.getSecond(), defaultConfig.getClass()).isParseToPlainText());
}
} }

1
ui-ngx/package.json

@ -54,6 +54,7 @@
"core-js": "^3.29.1", "core-js": "^3.29.1",
"date-fns": "2.0.0-alpha.27", "date-fns": "2.0.0-alpha.27",
"dayjs": "1.11.4", "dayjs": "1.11.4",
"echarts": "^5.4.3",
"flot": "https://github.com/thingsboard/flot.git#0.9-work", "flot": "https://github.com/thingsboard/flot.git#0.9-work",
"flot.curvedlines": "https://github.com/MichaelZinsmaier/CurvedLines.git#master", "flot.curvedlines": "https://github.com/MichaelZinsmaier/CurvedLines.git#master",
"font-awesome": "^4.7.0", "font-awesome": "^4.7.0",

4
ui-ngx/src/app/core/api/widget-subscription.ts

@ -1537,7 +1537,7 @@ export class WidgetSubscription implements IWidgetSubscription {
} }
if (this.type === widgetType.latest) { if (this.type === widgetType.latest) {
const prevData = currentData.data; const prevData = currentData.data;
if (!data.data.length) { if (!data.data.length && !prevData.length) {
update = false; update = false;
} else if (prevData && prevData[0] && prevData[0].length > 1 && data.data.length > 0) { } else if (prevData && prevData[0] && prevData[0].length > 1 && data.data.length > 0) {
const prevTs = prevData[0][0]; const prevTs = prevData[0][0];
@ -1560,6 +1560,8 @@ export class WidgetSubscription implements IWidgetSubscription {
} }
this.notifyDataLoaded(); this.notifyDataLoaded();
this.onDataUpdated(detectChanges); this.onDataUpdated(detectChanges);
} else if (this.loadingData) {
this.notifyDataLoaded();
} }
} }

3
ui-ngx/src/app/modules/home/components/alarm/alarm-filter-config.component.scss

@ -40,6 +40,9 @@
tb-entity-subtype-list { tb-entity-subtype-list {
flex: 1; flex: 1;
width: 180px; width: 180px;
.mdc-evolution-chip-set__chips {
width: 100%;
}
} }
.mat-mdc-chip { .mat-mdc-chip {

8
ui-ngx/src/app/modules/home/components/alarm/alarm-table-config.ts

@ -319,9 +319,7 @@ export class AlarmTableConfig extends EntityTableConfig<AlarmInfo, TimePageLink>
if ($event) { if ($event) {
$event.stopPropagation(); $event.stopPropagation();
} }
const unacknowledgedAlarms = alarms.filter(alarm => { const unacknowledgedAlarms = alarms.filter(alarm => !alarm.acknowledged);
return alarm.status === AlarmStatus.CLEARED_UNACK || alarm.status === AlarmStatus.ACTIVE_UNACK;
})
let title = ''; let title = '';
let content = ''; let content = '';
if (!unacknowledgedAlarms.length) { if (!unacknowledgedAlarms.length) {
@ -356,9 +354,7 @@ export class AlarmTableConfig extends EntityTableConfig<AlarmInfo, TimePageLink>
if ($event) { if ($event) {
$event.stopPropagation(); $event.stopPropagation();
} }
const activeAlarms = alarms.filter(alarm => { const activeAlarms = alarms.filter(alarm => !alarm.cleared);
return alarm.status === AlarmStatus.ACTIVE_ACK || alarm.status === AlarmStatus.ACTIVE_UNACK;
})
let title = ''; let title = '';
let content = ''; let content = '';
if (!activeAlarms.length) { if (!activeAlarms.length) {

2
ui-ngx/src/app/modules/home/components/dashboard-page/dashboard-page.component.html

@ -15,7 +15,7 @@
limitations under the License. limitations under the License.
--> -->
<div class="tb-dashboard-page mat-content" [ngClass]="{'mobile-app': isMobileApp && !isEdit}" <div class="tb-dashboard-page mat-content" [ngClass]="{'mobile-app': isMobileApp && !isEdit}" tb-toast
fxFlex tb-fullscreen [fullscreenElement]="elRef.nativeElement" [fullscreen]="widgetEditMode || iframeMode || forceFullscreen || isFullscreen"> fxFlex tb-fullscreen [fullscreenElement]="elRef.nativeElement" [fullscreen]="widgetEditMode || iframeMode || forceFullscreen || isFullscreen">
<tb-hotkeys-cheatsheet #cheatSheetComponent></tb-hotkeys-cheatsheet> <tb-hotkeys-cheatsheet #cheatSheetComponent></tb-hotkeys-cheatsheet>
<section class="tb-dashboard-toolbar" <section class="tb-dashboard-toolbar"

2
ui-ngx/src/app/modules/home/components/dashboard-page/dashboard-widget-select.component.ts

@ -142,6 +142,8 @@ export class DashboardWidgetSelectComponent implements OnInit {
columns: 2, columns: 2,
breakpoints: { breakpoints: {
'screen and (min-width: 2000px)': 5, 'screen and (min-width: 2000px)': 5,
'screen and (min-width: 1097px)': 4,
'gt-sm': 3,
'screen and (min-width: 721px)': 4, 'screen and (min-width: 721px)': 4,
'screen and (min-width: 485px)': 3 'screen and (min-width: 485px)': 3
} }

86
ui-ngx/src/app/modules/home/components/event/event-filter-panel.component.html

@ -15,50 +15,52 @@
limitations under the License. limitations under the License.
--> -->
<form fxLayout="column" class="mat-content mat-padding" [formGroup]="eventFilterFormGroup" (ngSubmit)="update()"> <form fxLayout="column" class="tb-filter" [formGroup]="eventFilterFormGroup" (ngSubmit)="update()">
<ng-container *ngFor="let column of showColumns"> <div fxLayout="column" class="tb-filter-container mat-padding">
<ng-container [ngSwitch]="column.key"> <ng-container *ngFor="let column of showColumns">
<ng-template [ngSwitchCase]="isSelector(column.key)"> <ng-container [ngSwitch]="column.key">
<mat-form-field> <ng-template [ngSwitchCase]="isSelector(column.key)">
<mat-label>{{ column.title | translate}}</mat-label> <mat-form-field>
<mat-select [formControlName]="column.key"> <mat-label>{{ column.title | translate}}</mat-label>
<mat-option [value]="">{{ 'event.all-events' | translate}}</mat-option> <mat-select [formControlName]="column.key">
<mat-option *ngFor="let value of selectorValues(column.key)" [value]="value"> <mat-option [value]="">{{ 'event.all-events' | translate}}</mat-option>
{{ value }} <mat-option *ngFor="let value of selectorValues(column.key)" [value]="value">
</mat-option> {{ value }}
</mat-select> </mat-option>
</mat-form-field> </mat-select>
</ng-template> </mat-form-field>
<ng-template [ngSwitchCase]="isNumberFields(column.key)"> </ng-template>
<mat-form-field> <ng-template [ngSwitchCase]="isNumberFields(column.key)">
<mat-label>{{ column.title | translate}}</mat-label> <mat-form-field>
<input matInput type="number" min="0" [name]="column.key" [formControlName]="column.key"> <mat-label>{{ column.title | translate}}</mat-label>
<mat-error *ngIf="eventFilterFormGroup.get(column.key).hasError('min')"> <input matInput type="number" min="0" [name]="column.key" [formControlName]="column.key">
{{ 'event.min-value' | translate }} <mat-error *ngIf="eventFilterFormGroup.get(column.key).hasError('min')">
</mat-error> {{ 'event.min-value' | translate }}
</mat-form-field> </mat-error>
</ng-template> </mat-form-field>
<ng-template [ngSwitchCase]="'isError'"> </ng-template>
<tb-checkbox formControlName="isError" [falseValue]="''" <ng-template [ngSwitchCase]="'isError'">
(ngModelChange)="changeIsError($event)"> <tb-checkbox formControlName="isError" [falseValue]="''"
{{ 'event.has-error' | translate }} (ngModelChange)="changeIsError($event)">
</tb-checkbox> {{ 'event.has-error' | translate }}
</ng-template> </tb-checkbox>
<ng-template [ngSwitchCase]="'errorStr'"> </ng-template>
<mat-form-field fxHide [fxShow]="showErrorMsgFields()"> <ng-template [ngSwitchCase]="'errorStr'">
<mat-label>{{ column.title | translate}}</mat-label> <mat-form-field fxHide [fxShow]="showErrorMsgFields()">
<input matInput type="text" name="errorSearchText" formControlName="errorStr"> <mat-label>{{ column.title | translate}}</mat-label>
</mat-form-field> <input matInput type="text" name="errorSearchText" formControlName="errorStr">
</ng-template> </mat-form-field>
<ng-container *ngSwitchDefault> </ng-template>
<mat-form-field> <ng-container *ngSwitchDefault>
<mat-label>{{ column.title | translate}}</mat-label> <mat-form-field>
<input matInput type="text" [name]="column.key" [formControlName]="column.key"> <mat-label>{{ column.title | translate}}</mat-label>
</mat-form-field> <input matInput type="text" [name]="column.key" [formControlName]="column.key">
</mat-form-field>
</ng-container>
</ng-container> </ng-container>
</ng-container> </ng-container>
</ng-container> </div>
<div fxLayout="row" class="tb-panel-actions" fxLayoutAlign="end center"> <div fxLayout="row" class="tb-panel-actions mat-padding" fxLayoutAlign="end center">
<button type="button" <button type="button"
mat-button mat-button
(click)="cancel()"> (click)="cancel()">

19
ui-ngx/src/app/modules/home/components/event/event-filter-panel.component.scss

@ -16,20 +16,11 @@
:host { :host {
width: 100%; width: 100%;
min-width: 300px; min-width: 300px;
overflow: auto;
background: #fff;
border-radius: 4px;
box-shadow:
0 7px 8px -4px rgba(0, 0, 0, .2),
0 13px 19px 2px rgba(0, 0, 0, .14),
0 5px 24px 4px rgba(0, 0, 0, .12);
.mat-content { .tb-filter {
overflow: hidden; height: 100%;
background-color: #fff; &-container {
} overflow: auto;
}
.mat-padding {
padding: 16px;
} }
} }

30
ui-ngx/src/app/modules/home/components/event/event-table-config.ts

@ -30,7 +30,7 @@ import { EntityId } from '@shared/models/id/entity-id';
import { EventService } from '@app/core/http/event.service'; import { EventService } from '@app/core/http/event.service';
import { EventTableHeaderComponent } from '@home/components/event/event-table-header.component'; import { EventTableHeaderComponent } from '@home/components/event/event-table-header.component';
import { EntityTypeResource } from '@shared/models/entity-type.models'; import { EntityTypeResource } from '@shared/models/entity-type.models';
import { Observable } from 'rxjs'; import { fromEvent, Observable } from 'rxjs';
import { PageData } from '@shared/models/page/page-data'; import { PageData } from '@shared/models/page/page-data';
import { Direction } from '@shared/models/page/sort-order'; import { Direction } from '@shared/models/page/sort-order';
import { DialogService } from '@core/services/dialog.service'; import { DialogService } from '@core/services/dialog.service';
@ -50,6 +50,7 @@ import {
EventFilterPanelData, EventFilterPanelData,
FilterEntityColumn FilterEntityColumn
} from '@home/components/event/event-filter-panel.component'; } from '@home/components/event/event-filter-panel.component';
import { DEFAULT_OVERLAY_POSITIONS } from '@shared/models/overlay.models';
export class EventTableConfig extends EntityTableConfig<Event, TimePageLink> { export class EventTableConfig extends EntityTableConfig<Event, TimePageLink> {
@ -457,19 +458,16 @@ export class EventTableConfig extends EntityTableConfig<Event, TimePageLink> {
$event.stopPropagation(); $event.stopPropagation();
} }
const target = $event.target || $event.srcElement || $event.currentTarget; const target = $event.target || $event.srcElement || $event.currentTarget;
const config = new OverlayConfig(); const config = new OverlayConfig({
config.backdropClass = 'cdk-overlay-transparent-backdrop'; panelClass: 'tb-panel-container',
config.hasBackdrop = true; backdropClass: 'cdk-overlay-transparent-backdrop',
const connectedPosition: ConnectedPosition = { hasBackdrop: true,
originX: 'end', height: 'fit-content',
originY: 'bottom', maxHeight: '65vh'
overlayX: 'end', });
overlayY: 'top' config.positionStrategy = this.overlay.position()
}; .flexibleConnectedTo(target as HTMLElement)
config.positionStrategy = this.overlay.position().flexibleConnectedTo(target as HTMLElement) .withPositions(DEFAULT_OVERLAY_POSITIONS);
.withPositions([connectedPosition]);
config.maxHeight = '70vh';
config.height = 'min-content';
const overlayRef = this.overlay.create(config); const overlayRef = this.overlay.create(config);
overlayRef.backdropClick().subscribe(() => { overlayRef.backdropClick().subscribe(() => {
@ -491,7 +489,11 @@ export class EventTableConfig extends EntityTableConfig<Event, TimePageLink> {
const injector = Injector.create({parent: this.viewContainerRef.injector, providers}); const injector = Injector.create({parent: this.viewContainerRef.injector, providers});
const componentRef = overlayRef.attach(new ComponentPortal(EventFilterPanelComponent, const componentRef = overlayRef.attach(new ComponentPortal(EventFilterPanelComponent,
this.viewContainerRef, injector)); this.viewContainerRef, injector));
const resizeWindows$ = fromEvent(window, 'resize').subscribe(() => {
overlayRef.updatePosition();
});
componentRef.onDestroy(() => { componentRef.onDestroy(() => {
resizeWindows$.unsubscribe();
if (componentRef.instance.result && !isEqual(this.filterParams, componentRef.instance.result.filterParams)) { if (componentRef.instance.result && !isEqual(this.filterParams, componentRef.instance.result.filterParams)) {
this.filterParams = componentRef.instance.result.filterParams; this.filterParams = componentRef.instance.result.filterParams;
this.getTable().paginator.pageIndex = 0; this.getTable().paginator.pageIndex = 0;

2
ui-ngx/src/app/modules/home/components/grid/scroll-grid.component.ts

@ -47,7 +47,7 @@ export class ScrollGridComponent<T, F> implements OnInit, AfterViewInit, OnChang
viewport: CdkVirtualScrollViewport; viewport: CdkVirtualScrollViewport;
@Input() @Input()
columns: ScrollGridColumns = {columns: 1}; columns: ScrollGridColumns | number = 1;
@Input() @Input()
fetchFunction: GridEntitiesFetchFunction<T, F>; fetchFunction: GridEntitiesFetchFunction<T, F>;

4
ui-ngx/src/app/modules/home/components/profile/add-device-profile-dialog.component.scss

@ -40,12 +40,10 @@
padding: 0 !important; padding: 0 !important;
.mat-stepper-horizontal { .mat-stepper-horizontal {
display: flex;
height: 100%;
overflow: hidden; overflow: hidden;
.mat-horizontal-stepper-wrapper { .mat-horizontal-stepper-wrapper {
flex: 1 1 100%; max-height: 100%;
} }
.mat-horizontal-content-container { .mat-horizontal-content-container {

1
ui-ngx/src/app/modules/home/components/widget/config/basic/alarm/alarms-table-basic-config.component.html

@ -40,6 +40,7 @@
keySettingsTitle="{{ 'widgets.table.column-settings' | translate }}" keySettingsTitle="{{ 'widgets.table.column-settings' | translate }}"
removeKeyTitle="{{ 'widgets.table.remove-column' | translate }}" removeKeyTitle="{{ 'widgets.table.remove-column' | translate }}"
noKeysText="{{ 'widgets.table.no-columns' | translate }}" noKeysText="{{ 'widgets.table.no-columns' | translate }}"
requiredKeysText="{{ 'widgets.table.alarm-column-error' | translate }}"
hideDataKeyColor hideDataKeyColor
hideUnits hideUnits
hideDecimals hideDecimals

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

@ -82,6 +82,9 @@ import {
import { import {
LiquidLevelCardBasicConfigComponent LiquidLevelCardBasicConfigComponent
} from '@home/components/widget/config/basic/indicator/liquid-level-card-basic-config.component'; } from '@home/components/widget/config/basic/indicator/liquid-level-card-basic-config.component';
import {
DoughnutBasicConfigComponent
} from '@home/components/widget/config/basic/chart/doughnut-basic-config.component';
@NgModule({ @NgModule({
declarations: [ declarations: [
@ -107,7 +110,8 @@ import {
RadialGaugeBasicConfigComponent, RadialGaugeBasicConfigComponent,
ThermometerScaleGaugeBasicConfigComponent, ThermometerScaleGaugeBasicConfigComponent,
CompassGaugeBasicConfigComponent, CompassGaugeBasicConfigComponent,
LiquidLevelCardBasicConfigComponent LiquidLevelCardBasicConfigComponent,
DoughnutBasicConfigComponent
], ],
imports: [ imports: [
CommonModule, CommonModule,
@ -137,7 +141,8 @@ import {
RadialGaugeBasicConfigComponent, RadialGaugeBasicConfigComponent,
ThermometerScaleGaugeBasicConfigComponent, ThermometerScaleGaugeBasicConfigComponent,
CompassGaugeBasicConfigComponent, CompassGaugeBasicConfigComponent,
LiquidLevelCardBasicConfigComponent LiquidLevelCardBasicConfigComponent,
DoughnutBasicConfigComponent
] ]
}) })
export class BasicWidgetConfigModule { export class BasicWidgetConfigModule {
@ -161,5 +166,6 @@ export const basicWidgetConfigComponentsMap: {[key: string]: Type<IBasicWidgetCo
'tb-radial-gauge-basic-config': RadialGaugeBasicConfigComponent, 'tb-radial-gauge-basic-config': RadialGaugeBasicConfigComponent,
'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
}; };

5
ui-ngx/src/app/modules/home/components/widget/config/basic/cards/progress-bar-basic-config.component.html

@ -31,7 +31,10 @@
<div class="tb-form-panel"> <div class="tb-form-panel">
<div class="tb-form-panel-title" translate>widget-config.appearance</div> <div class="tb-form-panel-title" translate>widget-config.appearance</div>
<tb-image-cards-select rowHeight="2:1" <tb-image-cards-select rowHeight="2:1"
cols="2" [cols]="{columns: 2,
breakpoints: {
'lt-sm': 1
}}"
label="{{ 'widgets.progress-bar.layout' | translate }}" formControlName="layout"> label="{{ 'widgets.progress-bar.layout' | translate }}" formControlName="layout">
<tb-image-cards-select-option *ngFor="let layout of progressBarLayouts" <tb-image-cards-select-option *ngFor="let layout of progressBarLayouts"
[value]="layout" [value]="layout"

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

@ -31,8 +31,10 @@
<div class="tb-form-panel"> <div class="tb-form-panel">
<div class="tb-form-panel-title" translate>widget-config.appearance</div> <div class="tb-form-panel-title" translate>widget-config.appearance</div>
<tb-image-cards-select rowHeight="{{ horizontal ? '3:1' : '7:5' }}" <tb-image-cards-select rowHeight="{{ horizontal ? '3:1' : '7:5' }}"
[cols]="horizontal ? 2 : 4" [cols]="{columns: horizontal ? 2 : 4,
[colsLtMd]="horizontal ? 1 : 2" breakpoints: {
'lt-md': horizontal ? 1 : 2
}}"
label="{{ 'widgets.value-card.layout' | translate }}" formControlName="layout"> label="{{ 'widgets.value-card.layout' | translate }}" formControlName="layout">
<tb-image-cards-select-option *ngFor="let layout of valueCardLayouts" <tb-image-cards-select-option *ngFor="let layout of valueCardLayouts"
[value]="layout" [value]="layout"

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

@ -30,7 +30,10 @@
<div class="tb-form-panel"> <div class="tb-form-panel">
<div class="tb-form-panel-title" translate>widget-config.appearance</div> <div class="tb-form-panel-title" translate>widget-config.appearance</div>
<tb-image-cards-select rowHeight="2:1" <tb-image-cards-select rowHeight="2:1"
cols="2" [cols]="{columns: 2,
breakpoints: {
'lt-sm': 1
}}"
label="{{ 'widgets.value-chart-card.layout' | translate }}" formControlName="layout"> label="{{ 'widgets.value-chart-card.layout' | translate }}" formControlName="layout">
<tb-image-cards-select-option *ngFor="let layout of valueChartCardLayouts" <tb-image-cards-select-option *ngFor="let layout of valueChartCardLayouts"
[value]="layout" [value]="layout"

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

@ -0,0 +1,244 @@
<!--
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]="doughnutWidgetConfigForm">
<tb-timewindow-config-panel *ngIf="displayTimewindowConfig"
[onlyHistoryTimewindow]="onlyHistoryTimewindow()"
formControlName="timewindowConfig">
</tb-timewindow-config-panel>
<tb-datasources
[configMode]="basicMode"
hideDataKeys
forceSingleDatasource
formControlName="datasources">
</tb-datasources>
<tb-data-keys-panel
panelTitle="{{ 'widgets.chart.series' | translate }}"
addKeyTitle="{{ 'widgets.chart.add-series' | translate }}"
keySettingsTitle="{{ 'widgets.chart.series-settings' | translate }}"
removeKeyTitle="{{ 'widgets.chart.remove-series' | translate }}"
noKeysText="{{ 'widgets.chart.no-series' | translate }}"
requiredKeysText="{{ 'widgets.chart.no-series-error' | translate }}"
hideUnits
hideDecimals
hideDataKeyUnits
hideDataKeyDecimals
[datasourceType]="datasource?.type"
[deviceId]="datasource?.deviceId"
[entityAliasId]="datasource?.entityAliasId"
formControlName="series">
</tb-data-keys-panel>
<div class="tb-form-panel">
<div class="tb-form-panel-title" translate>widget-config.card-appearance</div>
<tb-image-cards-select rowHeight="{{ horizontal ? '8:5' : '5:4' }}"
[cols]="{columns: 2,
breakpoints: {
'lt-sm': 1
}}"
label="{{ 'widgets.doughnut.layout' | translate }}" formControlName="layout">
<tb-image-cards-select-option *ngFor="let layout of doughnutLayouts"
[value]="layout"
[image]="doughnutLayoutImageMap.get(layout)">
{{ doughnutLayoutTranslationMap.get(layout) | translate }}
</tb-image-cards-select-option>
</tb-image-cards-select>
<div class="tb-form-row">
<mat-slide-toggle class="mat-slide" formControlName="autoScale">
{{ 'widgets.value-card.auto-scale' | translate }}
</mat-slide-toggle>
</div>
<div class="tb-form-row column-xs">
<mat-slide-toggle class="mat-slide fixed-title-width" formControlName="showTitle">
{{ 'widget-config.card-title' | translate }}
</mat-slide-toggle>
<div fxFlex fxLayout="row" fxLayoutAlign="start center" fxLayoutGap="8px">
<mat-form-field fxFlex appearance="outline" subscriptSizing="dynamic">
<input matInput formControlName="title" placeholder="{{ 'widget-config.set' | translate }}">
</mat-form-field>
<tb-font-settings formControlName="titleFont"
clearButton
[previewText]="doughnutWidgetConfigForm.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 space-between">
<mat-slide-toggle class="mat-slide" formControlName="showTitleIcon">
{{ 'widget-config.card-icon' | translate }}
</mat-slide-toggle>
<div fxLayout="row" fxLayoutAlign="start center" fxLayoutGap="8px">
<tb-material-icon-select asBoxInput
iconClearButton
[color]="doughnutWidgetConfigForm.get('iconColor').value"
formControlName="titleIcon">
</tb-material-icon-select>
<tb-color-input asBoxInput
colorClearButton
formControlName="iconColor">
</tb-color-input>
</div>
</div>
<div [fxShow]="totalEnabled" class="tb-form-row space-between">
<div>{{ 'widgets.doughnut.central-total-value' | translate }}</div>
<div fxLayout="row" fxLayoutAlign="start center" fxLayoutGap="8px">
<tb-font-settings formControlName="totalValueFont"
[previewText]="valuePreviewFn">
</tb-font-settings>
<tb-color-settings formControlName="totalValueColor" settingsKey="{{'widgets.doughnut.central-total-value' | translate }}">
</tb-color-settings>
</div>
</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]="doughnutWidgetConfigForm.get('showLegend').value" [disabled]="!doughnutWidgetConfigForm.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 doughnutLegendPositions" [value]="pos">
{{ doughnutLegendPositionTranslationMap.get(pos) | translate }}
</mat-option>
</mat-select>
</mat-form-field>
</div>
<div class="tb-form-row space-between">
<div>{{ 'widgets.doughnut.legend-label' | translate }}</div>
<div fxLayout="row" fxLayoutAlign="start center" fxLayoutGap="8px">
<tb-font-settings formControlName="legendLabelFont"
previewText="Wind power">
</tb-font-settings>
<tb-color-input asBoxInput
colorClearButton
formControlName="legendLabelColor">
</tb-color-input>
</div>
</div>
<div class="tb-form-row space-between">
<div>{{ 'widgets.doughnut.legend-value' | translate }}</div>
<div fxLayout="row" fxLayoutAlign="start center" fxLayoutGap="8px">
<tb-font-settings formControlName="legendValueFont"
[previewText]="valuePreviewFn">
</tb-font-settings>
<tb-color-input asBoxInput
colorClearButton
formControlName="legendValueColor">
</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]="doughnutWidgetConfigForm.get('showTooltip').value" [disabled]="!doughnutWidgetConfigForm.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.doughnut.tooltip' | translate }}
</mat-slide-toggle>
</mat-panel-title>
</mat-expansion-panel-header>
<ng-template matExpansionPanelContent>
<div class="tb-form-row space-between column-xs">
<div>{{ 'widgets.doughnut.tooltip-value' | translate }}</div>
<div fxLayout="row" fxLayoutAlign="start center" fxLayoutGap="8px">
<mat-form-field class="medium-width" appearance="outline" subscriptSizing="dynamic">
<mat-select formControlName="tooltipValueType">
<mat-option *ngFor="let type of doughnutTooltipValueTypes" [value]="type">
{{ doughnutTooltipValueTypeTranslationMap.get(type) | translate }}
</mat-option>
</mat-select>
</mat-form-field>
<mat-form-field appearance="outline" class="number" subscriptSizing="dynamic">
<input matInput formControlName="tooltipValueDecimals" type="number" min="0" max="15" step="1" placeholder="{{ 'widget-config.set' | translate }}">
<div matSuffix fxHide.lt-md translate>widget-config.decimals-suffix</div>
</mat-form-field>
<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 space-between">
<div>{{ 'widgets.doughnut.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.doughnut.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>

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

@ -0,0 +1,339 @@
///
/// 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 } from '@angular/core';
import { UntypedFormBuilder, UntypedFormGroup } 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,
Datasource,
datasourcesHasAggregation,
datasourcesHasOnlyComparisonAggregation,
WidgetConfig
} from '@shared/models/widget.models';
import { WidgetConfigComponent } from '@home/components/widget/widget-config.component';
import { DataKeyType } from '@shared/models/telemetry/telemetry.models';
import { formatValue, isDefinedAndNotNull, isUndefined } from '@core/utils';
import {
getTimewindowConfig,
setTimewindowConfig
} from '@home/components/widget/config/timewindow-config-panel.component';
import {
doughnutDefaultSettings,
DoughnutLayout,
doughnutLayoutImages,
doughnutLayouts,
doughnutLayoutTranslations,
DoughnutLegendPosition,
doughnutLegendPositionTranslations,
DoughnutTooltipValueType,
doughnutTooltipValueTypes,
doughnutTooltipValueTypeTranslations,
DoughnutWidgetSettings,
horizontalDoughnutLayoutImages
} from '@home/components/widget/lib/chart/doughnut-widget.models';
@Component({
selector: 'tb-doughnut-basic-config',
templateUrl: './doughnut-basic-config.component.html',
styleUrls: ['../basic-config.scss']
})
export class DoughnutBasicConfigComponent extends BasicWidgetConfigComponent {
public get datasource(): Datasource {
const datasources: Datasource[] = this.doughnutWidgetConfigForm.get('datasources').value;
if (datasources && datasources.length) {
return datasources[0];
} else {
return null;
}
}
public get displayTimewindowConfig(): boolean {
const datasources = this.doughnutWidgetConfigForm.get('datasources').value;
return datasourcesHasAggregation(datasources);
}
public onlyHistoryTimewindow(): boolean {
const datasources = this.doughnutWidgetConfigForm.get('datasources').value;
return datasourcesHasOnlyComparisonAggregation(datasources);
}
doughnutLayouts = doughnutLayouts;
doughnutLayoutTranslationMap = doughnutLayoutTranslations;
horizontal = false;
doughnutLayoutImageMap: Map<DoughnutLayout, string>;
doughnutLegendPositions: DoughnutLegendPosition[];
doughnutLegendPositionTranslationMap = doughnutLegendPositionTranslations;
doughnutTooltipValueTypes = doughnutTooltipValueTypes;
doughnutTooltipValueTypeTranslationMap = doughnutTooltipValueTypeTranslations;
doughnutWidgetConfigForm: UntypedFormGroup;
valuePreviewFn = this._valuePreviewFn.bind(this);
tooltipValuePreviewFn = this._tooltipValuePreviewFn.bind(this);
get totalEnabled(): boolean {
const layout: DoughnutLayout = this.doughnutWidgetConfigForm.get('layout').value;
return layout === DoughnutLayout.with_total;
}
constructor(protected store: Store<AppState>,
protected widgetConfigComponent: WidgetConfigComponent,
private fb: UntypedFormBuilder) {
super(store, widgetConfigComponent);
}
protected configForm(): UntypedFormGroup {
return this.doughnutWidgetConfigForm;
}
protected setupConfig(widgetConfig: WidgetConfigComponentData) {
const params = widgetConfig.typeParameters as any;
this.horizontal = isDefinedAndNotNull(params.horizontal) ? params.horizontal : false;
this.doughnutLayoutImageMap = this.horizontal ? horizontalDoughnutLayoutImages : doughnutLayoutImages;
this.doughnutLegendPositions = this.horizontal ? [DoughnutLegendPosition.left, DoughnutLegendPosition.right] :
[DoughnutLegendPosition.top, DoughnutLegendPosition.bottom];
super.setupConfig(widgetConfig);
}
protected defaultDataKeys(configData: WidgetConfigComponentData): DataKey[] {
return [{ name: 'windPower', label: 'Wind power', type: DataKeyType.timeseries, units: '', decimals: 0, color: '#08872B' },
{ name: 'solarPower', label: 'Solar power', type: DataKeyType.timeseries, units: '', decimals: 0, color: '#FF4D5A' }];
}
protected onConfigSet(configData: WidgetConfigComponentData) {
const settings: DoughnutWidgetSettings = {...doughnutDefaultSettings(this.horizontal), ...(configData.config.settings || {})};
this.doughnutWidgetConfigForm = this.fb.group({
timewindowConfig: [getTimewindowConfig(configData.config), []],
datasources: [configData.config.datasources, []],
series: [this.getSeries(configData.config.datasources), []],
layout: [settings.layout, []],
autoScale: [settings.autoScale, []],
showTitle: [configData.config.showTitle, []],
title: [configData.config.title, []],
titleFont: [configData.config.titleFont, []],
titleColor: [configData.config.titleColor, []],
showTitleIcon: [configData.config.showTitleIcon, []],
titleIcon: [configData.config.titleIcon, []],
iconColor: [configData.config.iconColor, []],
totalValueFont: [settings.totalValueFont, []],
totalValueColor: [settings.totalValueColor, []],
units: [configData.config.units, []],
decimals: [configData.config.decimals, []],
showLegend: [settings.showLegend, []],
legendPosition: [settings.legendPosition, []],
legendLabelFont: [settings.legendLabelFont, []],
legendLabelColor: [settings.legendLabelColor, []],
legendValueFont: [settings.legendValueFont, []],
legendValueColor: [settings.legendValueColor, []],
showTooltip: [settings.showTooltip, []],
tooltipValueType: [settings.tooltipValueType, []],
tooltipValueDecimals: [settings.tooltipValueDecimals, []],
tooltipValueFont: [settings.tooltipValueFont, []],
tooltipValueColor: [settings.tooltipValueColor, []],
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.setSeries(config.series, this.widgetConfig.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.showTitleIcon;
this.widgetConfig.config.titleIcon = config.titleIcon;
this.widgetConfig.config.iconColor = config.iconColor;
this.widgetConfig.config.settings = this.widgetConfig.config.settings || {};
this.widgetConfig.config.settings.layout = config.layout;
this.widgetConfig.config.settings.autoScale = config.autoScale;
this.widgetConfig.config.settings.totalValueFont = config.totalValueFont;
this.widgetConfig.config.settings.totalValueColor = config.totalValueColor;
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.legendValueFont = config.legendValueFont;
this.widgetConfig.config.settings.legendValueColor = config.legendValueColor;
this.widgetConfig.config.settings.showTooltip = config.showTooltip;
this.widgetConfig.config.settings.tooltipValueType = config.tooltipValueType;
this.widgetConfig.config.settings.tooltipValueDecimals = config.tooltipValueDecimals;
this.widgetConfig.config.settings.tooltipValueFont = config.tooltipValueFont;
this.widgetConfig.config.settings.tooltipValueColor = config.tooltipValueColor;
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 ['layout', 'showTitle', 'showTitleIcon', 'showLegend', 'showTooltip'];
}
protected updateValidators(emitEvent: boolean, trigger?: string) {
const layout: DoughnutLayout = this.doughnutWidgetConfigForm.get('layout').value;
const showTitle: boolean = this.doughnutWidgetConfigForm.get('showTitle').value;
const showTitleIcon: boolean = this.doughnutWidgetConfigForm.get('showTitleIcon').value;
const showLegend: boolean = this.doughnutWidgetConfigForm.get('showLegend').value;
const showTooltip: boolean = this.doughnutWidgetConfigForm.get('showTooltip').value;
const totalEnabled = layout === DoughnutLayout.with_total;
if (showTitle) {
this.doughnutWidgetConfigForm.get('title').enable();
this.doughnutWidgetConfigForm.get('titleFont').enable();
this.doughnutWidgetConfigForm.get('titleColor').enable();
this.doughnutWidgetConfigForm.get('showTitleIcon').enable({emitEvent: false});
if (showTitleIcon) {
this.doughnutWidgetConfigForm.get('titleIcon').enable();
this.doughnutWidgetConfigForm.get('iconColor').enable();
} else {
this.doughnutWidgetConfigForm.get('titleIcon').disable();
this.doughnutWidgetConfigForm.get('iconColor').disable();
}
} else {
this.doughnutWidgetConfigForm.get('title').disable();
this.doughnutWidgetConfigForm.get('titleFont').disable();
this.doughnutWidgetConfigForm.get('titleColor').disable();
this.doughnutWidgetConfigForm.get('showTitleIcon').disable({emitEvent: false});
this.doughnutWidgetConfigForm.get('titleIcon').disable();
this.doughnutWidgetConfigForm.get('iconColor').disable();
}
if (showLegend) {
this.doughnutWidgetConfigForm.get('legendPosition').enable();
this.doughnutWidgetConfigForm.get('legendLabelFont').enable();
this.doughnutWidgetConfigForm.get('legendLabelColor').enable();
this.doughnutWidgetConfigForm.get('legendValueFont').enable();
this.doughnutWidgetConfigForm.get('legendValueColor').enable();
} else {
this.doughnutWidgetConfigForm.get('legendPosition').disable();
this.doughnutWidgetConfigForm.get('legendLabelFont').disable();
this.doughnutWidgetConfigForm.get('legendLabelColor').disable();
this.doughnutWidgetConfigForm.get('legendValueFont').disable();
this.doughnutWidgetConfigForm.get('legendValueColor').disable();
}
if (showTooltip) {
this.doughnutWidgetConfigForm.get('tooltipValueType').enable();
this.doughnutWidgetConfigForm.get('tooltipValueDecimals').enable();
this.doughnutWidgetConfigForm.get('tooltipValueFont').enable();
this.doughnutWidgetConfigForm.get('tooltipValueColor').enable();
this.doughnutWidgetConfigForm.get('tooltipBackgroundColor').enable();
this.doughnutWidgetConfigForm.get('tooltipBackgroundBlur').enable();
} else {
this.doughnutWidgetConfigForm.get('tooltipValueType').disable();
this.doughnutWidgetConfigForm.get('tooltipValueDecimals').disable();
this.doughnutWidgetConfigForm.get('tooltipValueFont').disable();
this.doughnutWidgetConfigForm.get('tooltipValueColor').disable();
this.doughnutWidgetConfigForm.get('tooltipBackgroundColor').disable();
this.doughnutWidgetConfigForm.get('tooltipBackgroundBlur').disable();
}
if (totalEnabled) {
this.doughnutWidgetConfigForm.get('totalValueFont').enable();
this.doughnutWidgetConfigForm.get('totalValueColor').enable();
} else {
this.doughnutWidgetConfigForm.get('totalValueFont').disable();
this.doughnutWidgetConfigForm.get('totalValueColor').disable();
}
}
private getSeries(datasources?: Datasource[]): DataKey[] {
if (datasources && datasources.length) {
return datasources[0].dataKeys || [];
}
return [];
}
private setSeries(series: DataKey[], datasources?: Datasource[]) {
if (datasources && datasources.length) {
datasources[0].dataKeys = series;
}
}
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 _valuePreviewFn(): string {
const units: string = this.doughnutWidgetConfigForm.get('units').value;
const decimals: number = this.doughnutWidgetConfigForm.get('decimals').value;
return formatValue(110, decimals, units, false);
}
private _tooltipValuePreviewFn(): string {
const tooltipValueType: DoughnutTooltipValueType = this.doughnutWidgetConfigForm.get('tooltipValueType').value;
const decimals: number = this.doughnutWidgetConfigForm.get('tooltipValueDecimals').value;
if (tooltipValueType === DoughnutTooltipValueType.percentage) {
return formatValue(35, decimals, '%', false);
} else {
const units: string = this.doughnutWidgetConfigForm.get('units').value;
return formatValue(110, decimals, units, false);
}
}
}

2
ui-ngx/src/app/modules/home/components/widget/config/basic/common/data-keys-panel.component.html

@ -44,6 +44,8 @@
[hideDataKeyColor]="hideDataKeyColor" [hideDataKeyColor]="hideDataKeyColor"
[hideDecimals]="hideDecimals" [hideDecimals]="hideDecimals"
[hideUnits]="hideUnits" [hideUnits]="hideUnits"
[hideDataKeyDecimals]="hideDataKeyDecimals"
[hideDataKeyUnits]="hideDataKeyUnits"
[dataKeyType]="dataKeyType" [dataKeyType]="dataKeyType"
[singleRow]="false" [singleRow]="false"
[keySettingsTitle]="keySettingsTitle" [keySettingsTitle]="keySettingsTitle"

8
ui-ngx/src/app/modules/home/components/widget/config/basic/common/data-keys-panel.component.ts

@ -107,6 +107,14 @@ export class DataKeysPanelComponent implements ControlValueAccessor, OnInit, OnC
@coerceBoolean() @coerceBoolean()
hideDecimals = false; hideDecimals = false;
@Input()
@coerceBoolean()
hideDataKeyUnits = false;
@Input()
@coerceBoolean()
hideDataKeyDecimals = false;
@Input() @Input()
@coerceBoolean() @coerceBoolean()
hideSourceSelection = false; hideSourceSelection = false;

2
ui-ngx/src/app/modules/home/components/widget/config/basic/gauge/analog-gauge-basic-config.component.ts

@ -131,7 +131,7 @@ export class GaugeBasicConfigComponent extends BasicWidgetConfigComponent {
this.widgetConfig.config.settings.defaultColor = config.defaultColor; this.widgetConfig.config.settings.defaultColor = config.defaultColor;
this.widgetConfig.config.settings.colorPlate = config.colorPlate; this.widgetConfig.config.settings.colorPlate = config.colorPlate;
this.widgetConfig.config.settings.highlights = config.highlights.rangeList; this.widgetConfig.config.settings.highlights = config.highlights;
return this.widgetConfig; return this.widgetConfig;
} }

6
ui-ngx/src/app/modules/home/components/widget/config/basic/indicator/battery-level-basic-config.component.html

@ -31,8 +31,10 @@
<div class="tb-form-panel"> <div class="tb-form-panel">
<div class="tb-form-panel-title" translate>widget-config.appearance</div> <div class="tb-form-panel-title" translate>widget-config.appearance</div>
<tb-image-cards-select rowHeight="1:1" <tb-image-cards-select rowHeight="1:1"
cols="4" [cols]="{columns: 4,
colsLtMd="2" breakpoints: {
'lt-md': 2
}}"
label="{{ 'widgets.battery-level.layout' | translate }}" formControlName="layout"> label="{{ 'widgets.battery-level.layout' | translate }}" formControlName="layout">
<tb-image-cards-select-option *ngFor="let layout of batteryLevelLayouts" <tb-image-cards-select-option *ngFor="let layout of batteryLevelLayouts"
[value]="layout" [value]="layout"

13
ui-ngx/src/app/modules/home/components/widget/config/basic/indicator/liquid-level-card-basic-config.component.html

@ -74,9 +74,11 @@
</div> </div>
<tb-image-cards-select #shapesImageCardsSelect <tb-image-cards-select #shapesImageCardsSelect
[fxShow]="levelCardWidgetConfigForm.get('tankSelectionType').value === DataSourceType.static" [fxShow]="levelCardWidgetConfigForm.get('tankSelectionType').value === DataSourceType.static"
rowHeight="{{ '1:1' }}" rowHeight="1:1"
[cols]="5" [cols]="{columns: 5,
[colsLtMd]="2" breakpoints: {
'lt-md': 2
}}"
style="width: 100%;" style="width: 100%;"
label="{{ 'widgets.liquid-level-card.shape-type' | translate }}" formControlName="selectedShape"> label="{{ 'widgets.liquid-level-card.shape-type' | translate }}" formControlName="selectedShape">
<tb-image-cards-select-option *ngFor="let shape of shapes" <tb-image-cards-select-option *ngFor="let shape of shapes"
@ -106,9 +108,8 @@
</div> </div>
</ng-container> </ng-container>
</div> </div>
<tb-image-cards-select #layoutsImageCardsSelect rowHeight="{{ '1:1' }}" <tb-image-cards-select #layoutsImageCardsSelect rowHeight="1:1"
[cols]="3" cols="3"
[colsLtMd]="3"
label="{{ 'widgets.liquid-level-card.layout' | translate }}" formControlName="layout"> label="{{ 'widgets.liquid-level-card.layout' | translate }}" formControlName="layout">
<tb-image-cards-select-option <tb-image-cards-select-option
*ngFor="let layout of LevelCardLayouts" *ngFor="let layout of LevelCardLayouts"

5
ui-ngx/src/app/modules/home/components/widget/config/basic/indicator/signal-strength-basic-config.component.html

@ -31,7 +31,10 @@
<div class="tb-form-panel"> <div class="tb-form-panel">
<div class="tb-form-panel-title" translate>widget-config.appearance</div> <div class="tb-form-panel-title" translate>widget-config.appearance</div>
<tb-image-cards-select rowHeight="2:1" <tb-image-cards-select rowHeight="2:1"
cols="2" [cols]="{columns: 2,
breakpoints: {
'lt-sm': 1
}}"
label="{{ 'widgets.signal-strength.layout' | translate }}" formControlName="layout"> label="{{ 'widgets.signal-strength.layout' | translate }}" formControlName="layout">
<tb-image-cards-select-option *ngFor="let layout of signalStrengthLayouts" <tb-image-cards-select-option *ngFor="let layout of signalStrengthLayouts"
[value]="layout" [value]="layout"

6
ui-ngx/src/app/modules/home/components/widget/config/basic/weather/wind-speed-direction-basic-config.component.html

@ -79,8 +79,10 @@
<div class="tb-form-panel"> <div class="tb-form-panel">
<div class="tb-form-panel-title" translate>widget-config.appearance</div> <div class="tb-form-panel-title" translate>widget-config.appearance</div>
<tb-image-cards-select rowHeight="1:1" <tb-image-cards-select rowHeight="1:1"
cols="3" [cols]="{columns: 3,
colsLtMd="2" breakpoints: {
'lt-md': 2
}}"
label="{{ 'widgets.wind-speed-direction.layout' | translate }}" formControlName="layout"> label="{{ 'widgets.wind-speed-direction.layout' | translate }}" formControlName="layout">
<tb-image-cards-select-option *ngFor="let layout of windSpeedDirectionLayouts" <tb-image-cards-select-option *ngFor="let layout of windSpeedDirectionLayouts"
[value]="layout" [value]="layout"

2
ui-ngx/src/app/modules/home/components/widget/config/datasources.component.ts

@ -243,7 +243,7 @@ export class DatasourcesComponent implements ControlValueAccessor, OnInit, Valid
} }
}; };
} }
if (this.hasAdditionalLatestDataKeys) { if (this.hasAdditionalLatestDataKeys && !this.basicMode) {
let valid = datasources.filter(datasource => datasource?.dataKeys?.length).length > 0; let valid = datasources.filter(datasource => datasource?.dataKeys?.length).length > 0;
if (!valid) { if (!valid) {
this.timeseriesKeyError = true; this.timeseriesKeyError = true;

113
ui-ngx/src/app/modules/home/components/widget/lib/alarm/alarms-table-widget.component.ts

@ -503,17 +503,16 @@ export class AlarmsTableWidgetComponent extends PageComponent implements OnInit,
$event.stopPropagation(); $event.stopPropagation();
} }
const target = $event.target || $event.srcElement || $event.currentTarget; const target = $event.target || $event.srcElement || $event.currentTarget;
const config = new OverlayConfig(); const config = new OverlayConfig({
config.backdropClass = 'cdk-overlay-transparent-backdrop'; panelClass: 'tb-panel-container',
config.hasBackdrop = true; backdropClass: 'cdk-overlay-transparent-backdrop',
const connectedPosition: ConnectedPosition = { hasBackdrop: true,
originX: 'end', height: 'fit-content',
originY: 'bottom', maxHeight: '75vh'
overlayX: 'end', });
overlayY: 'top' config.positionStrategy = this.overlay.position()
}; .flexibleConnectedTo(target as HTMLElement)
config.positionStrategy = this.overlay.position().flexibleConnectedTo(target as HTMLElement) .withPositions(DEFAULT_OVERLAY_POSITIONS);
.withPositions([connectedPosition]);
const overlayRef = this.overlay.create(config); const overlayRef = this.overlay.create(config);
overlayRef.backdropClick().subscribe(() => { overlayRef.backdropClick().subscribe(() => {
@ -549,9 +548,18 @@ export class AlarmsTableWidgetComponent extends PageComponent implements OnInit,
useValue: overlayRef useValue: overlayRef
} }
]; ];
const injector = Injector.create({parent: this.viewContainerRef.injector, providers}); const injector = Injector.create({parent: this.viewContainerRef.injector, providers});
overlayRef.attach(new ComponentPortal(DisplayColumnsPanelComponent, const componentRef = overlayRef.attach(new ComponentPortal(DisplayColumnsPanelComponent,
this.viewContainerRef, injector)); this.viewContainerRef, injector));
const resizeWindows$ = fromEvent(window, 'resize').subscribe(() => {
overlayRef.updatePosition();
});
componentRef.onDestroy(() => {
resizeWindows$.unsubscribe();
});
this.ctx.detectChanges(); this.ctx.detectChanges();
} }
@ -888,12 +896,21 @@ export class AlarmsTableWidgetComponent extends PageComponent implements OnInit,
$event.stopPropagation(); $event.stopPropagation();
} }
if (this.alarmsDatasource.selection.hasValue()) { if (this.alarmsDatasource.selection.hasValue()) {
const alarmIds = this.alarmsDatasource.selection.selected.filter( const unacknowledgedAlarms = this.alarmsDatasource.selection.selected.filter(
(alarmId) => alarmId !== NULL_UUID alarm => alarm.id.id !== NULL_UUID && !alarm.acknowledged
); );
if (alarmIds.length) { let title = '';
const title = this.translate.instant('alarm.aknowledge-alarms-title', {count: alarmIds.length}); let content = '';
const content = this.translate.instant('alarm.aknowledge-alarms-text', {count: alarmIds.length}); if (!unacknowledgedAlarms.length) {
title = this.translate.instant('alarm.selected-alarms', {count: unacknowledgedAlarms.length});
content = this.translate.instant('alarm.selected-alarms-are-acknowledged');
this.dialogService.alert(
title,
content
).subscribe();
} else {
title = this.translate.instant('alarm.aknowledge-alarms-title', {count: unacknowledgedAlarms.length});
content = this.translate.instant('alarm.aknowledge-alarms-text', {count: unacknowledgedAlarms.length});
this.dialogService.confirm( this.dialogService.confirm(
title, title,
content, content,
@ -901,16 +918,14 @@ export class AlarmsTableWidgetComponent extends PageComponent implements OnInit,
this.translate.instant('action.yes') this.translate.instant('action.yes')
).subscribe((res) => { ).subscribe((res) => {
if (res) { if (res) {
if (res) { const tasks: Observable<AlarmInfo>[] = [];
const tasks: Observable<AlarmInfo>[] = []; for (const alarm of unacknowledgedAlarms) {
for (const alarmId of alarmIds) { tasks.push(this.alarmService.ackAlarm(alarm.id.id));
tasks.push(this.alarmService.ackAlarm(alarmId));
}
forkJoin(tasks).subscribe(() => {
this.alarmsDatasource.clearSelection();
this.subscription.update();
});
} }
forkJoin(tasks).subscribe(() => {
this.alarmsDatasource.clearSelection();
this.subscription.update();
});
} }
}); });
} }
@ -944,12 +959,21 @@ export class AlarmsTableWidgetComponent extends PageComponent implements OnInit,
$event.stopPropagation(); $event.stopPropagation();
} }
if (this.alarmsDatasource.selection.hasValue()) { if (this.alarmsDatasource.selection.hasValue()) {
const alarmIds = this.alarmsDatasource.selection.selected.filter( const activeAlarms = this.alarmsDatasource.selection.selected.filter(
(alarmId) => alarmId !== NULL_UUID alarm => alarm.id.id !== NULL_UUID && !alarm.cleared
); );
if (alarmIds.length) { let title = '';
const title = this.translate.instant('alarm.clear-alarms-title', {count: alarmIds.length}); let content = '';
const content = this.translate.instant('alarm.clear-alarms-text', {count: alarmIds.length}); if (!activeAlarms.length) {
title = this.translate.instant('alarm.selected-alarms', {count: activeAlarms.length});
content = this.translate.instant('alarm.selected-alarms-are-cleared');
this.dialogService.alert(
title,
content
).subscribe();
} else {
title = this.translate.instant('alarm.clear-alarms-title', {count: activeAlarms.length});
content = this.translate.instant('alarm.clear-alarms-text', {count: activeAlarms.length});
this.dialogService.confirm( this.dialogService.confirm(
title, title,
content, content,
@ -957,16 +981,14 @@ export class AlarmsTableWidgetComponent extends PageComponent implements OnInit,
this.translate.instant('action.yes') this.translate.instant('action.yes')
).subscribe((res) => { ).subscribe((res) => {
if (res) { if (res) {
if (res) { const tasks: Observable<AlarmInfo>[] = [];
const tasks: Observable<AlarmInfo>[] = []; for (const alarm of activeAlarms) {
for (const alarmId of alarmIds) { tasks.push(this.alarmService.clearAlarm(alarm.id.id));
tasks.push(this.alarmService.clearAlarm(alarmId));
}
forkJoin(tasks).subscribe(() => {
this.alarmsDatasource.clearSelection();
this.subscription.update();
});
} }
forkJoin(tasks).subscribe(() => {
this.alarmsDatasource.clearSelection();
this.subscription.update();
});
} }
}); });
} }
@ -1124,7 +1146,8 @@ class AlarmsDatasource implements DataSource<AlarmDataInfo> {
private alarmsSubject = new BehaviorSubject<AlarmDataInfo[]>([]); private alarmsSubject = new BehaviorSubject<AlarmDataInfo[]>([]);
private pageDataSubject = new BehaviorSubject<PageData<AlarmDataInfo>>(emptyPageData<AlarmDataInfo>()); private pageDataSubject = new BehaviorSubject<PageData<AlarmDataInfo>>(emptyPageData<AlarmDataInfo>());
public selection = new SelectionModel<string>(true, [], false); public selection = new SelectionModel<AlarmDataInfo>(true, [], false,
(alarm1: AlarmDataInfo, alarm2: AlarmDataInfo) => alarm1.id.id === alarm2.id.id);
private selectionModeChanged = new EventEmitter<boolean>(); private selectionModeChanged = new EventEmitter<boolean>();
@ -1202,7 +1225,7 @@ class AlarmsDatasource implements DataSource<AlarmDataInfo> {
} }
if (this.selection.hasValue()) { if (this.selection.hasValue()) {
const alarmIds = alarms.map((alarm) => alarm.id.id); const alarmIds = alarms.map((alarm) => alarm.id.id);
const toRemove = this.selection.selected.filter(alarmId => alarmIds.indexOf(alarmId) === -1); const toRemove = this.selection.selected.filter(alarm => alarmIds.indexOf(alarm.id.id) === -1);
this.selection.deselect(...toRemove); this.selection.deselect(...toRemove);
if (this.selection.isEmpty()) { if (this.selection.isEmpty()) {
isEmptySelection = true; isEmptySelection = true;
@ -1276,14 +1299,14 @@ class AlarmsDatasource implements DataSource<AlarmDataInfo> {
toggleSelection(alarm: AlarmDataInfo) { toggleSelection(alarm: AlarmDataInfo) {
const hasValue = this.selection.hasValue(); const hasValue = this.selection.hasValue();
this.selection.toggle(alarm.id.id); this.selection.toggle(alarm);
if (hasValue !== this.selection.hasValue()) { if (hasValue !== this.selection.hasValue()) {
this.onSelectionModeChanged(this.selection.hasValue()); this.onSelectionModeChanged(this.selection.hasValue());
} }
} }
isSelected(alarm: AlarmDataInfo): boolean { isSelected(alarm: AlarmDataInfo): boolean {
return this.selection.isSelected(alarm.id.id); return this.selection.isSelected(alarm);
} }
clearSelection() { clearSelection() {
@ -1304,7 +1327,7 @@ class AlarmsDatasource implements DataSource<AlarmDataInfo> {
} }
} else { } else {
alarms.forEach(row => { alarms.forEach(row => {
this.selection.select(row.id.id); this.selection.select(row);
}); });
if (numSelected === 0) { if (numSelected === 0) {
this.onSelectionModeChanged(true); this.onSelectionModeChanged(true);

37
ui-ngx/src/app/modules/home/components/widget/lib/chart/doughnut-widget.component.html

@ -0,0 +1,37 @@
<!--
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-doughnut-panel" [style]="backgroundStyle">
<div class="tb-doughnut-overlay" [style]="overlayStyle"></div>
<ng-container *ngTemplateOutlet="widgetTitlePanel"></ng-container>
<div #doughnutContent class="tb-doughnut-content" [class]="legendClass">
<div #doughnutShape class="tb-doughnut-shape">
</div>
<div *ngIf="showLegend" #doughnutLegend class="tb-doughnut-legend">
<div class="tb-doughnut-legend-item" *ngFor="let legendItem of legendItems" [class]="{'pointer': !legendItem.total && legendItem.hasValue}"
(mouseenter)="onLegendItemEnter(legendItem)"
(mouseleave)="onLegendItemLeave(legendItem)"
(click)="toggleLegendItem(legendItem)">
<div class="tb-doughnut-legend-item-label">
<div class="tb-doughnut-legend-item-label-circle" [style]="{background: (legendItem.enabled && legendItem.hasValue) ? legendItem.color : null}"></div>
<div [style]="(legendItem.enabled && legendItem.hasValue) ? legendLabelStyle : disabledLegendLabelStyle">{{ legendItem.label }}</div>
</div>
<div [style]="(legendItem.enabled && legendItem.hasValue) ? legendValueStyle : disabledLegendValueStyle" class="tb-doughnut-legend-item-value">{{ legendItem.value }}</div>
</div>
</div>
</div>
</div>

110
ui-ngx/src/app/modules/home/components/widget/lib/chart/doughnut-widget.component.scss

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

535
ui-ngx/src/app/modules/home/components/widget/lib/chart/doughnut-widget.component.ts

@ -0,0 +1,535 @@
///
/// 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 {
doughnutDefaultSettings,
DoughnutLayout,
DoughnutLegendPosition,
DoughnutTooltipValueType,
DoughnutWidgetSettings
} from '@home/components/widget/lib/chart/doughnut-widget.models';
import { WidgetContext } from '@home/models/widget-component.models';
import {
backgroundStyle,
ColorProcessor,
ComponentStyle,
overlayStyle,
textStyle
} from '@shared/models/widget-settings.models';
import { ResizeObserver } from '@juggle/resize-observer';
import { WidgetComponent } from '@home/components/widget/widget.component';
import * as echarts from 'echarts/core';
import { TranslateService } from '@ngx-translate/core';
import { PieDataItemOption } from 'echarts/types/src/chart/pie/PieSeries';
import { formatValue, isDefinedAndNotNull, isNumeric } from '@core/utils';
import { SVG, Svg, Text } from '@svgdotjs/svg.js';
import { DataKey } from '@shared/models/widget.models';
import { TooltipComponent, TooltipComponentOption } from 'echarts/components';
import { PieChart, PieSeriesOption } from 'echarts/charts';
import { SVGRenderer } from 'echarts/renderers';
echarts.use([
TooltipComponent,
PieChart,
SVGRenderer
]);
type EChartsOption = echarts.ComposeOption<
| TooltipComponentOption
| PieSeriesOption
>;
type ECharts = echarts.ECharts;
const shapeSize = 134;
const shapeSegmentWidth = 13.4;
interface DoughnutDataItem {
id: number;
dataKey: DataKey;
value: number;
hasValue: boolean;
enabled: boolean;
}
interface DoughnutLegendItem {
id: number;
color: string;
label: string;
value: string;
hasValue: boolean;
enabled: boolean;
total?: boolean;
}
@Component({
selector: 'tb-doughnut-widget',
templateUrl: './doughnut-widget.component.html',
styleUrls: ['./doughnut-widget.component.scss'],
encapsulation: ViewEncapsulation.None
})
export class DoughnutWidgetComponent implements OnInit, OnDestroy, AfterViewInit {
@ViewChild('doughnutContent', {static: false})
doughnutContent: ElementRef<HTMLElement>;
@ViewChild('doughnutShape', {static: false})
doughnutShape: ElementRef<HTMLElement>;
@ViewChild('doughnutLegend', {static: false})
doughnutLegend: ElementRef<HTMLElement>;
settings: DoughnutWidgetSettings;
@Input()
ctx: WidgetContext;
@Input()
widgetTitlePanel: TemplateRef<any>;
showLegend: boolean;
legendClass: string;
totalValueColor: ColorProcessor;
backgroundStyle: ComponentStyle = {};
overlayStyle: ComponentStyle = {};
legendItems: DoughnutLegendItem[];
legendLabelStyle: ComponentStyle;
legendValueStyle: ComponentStyle;
disabledLegendLabelStyle: ComponentStyle;
disabledLegendValueStyle: ComponentStyle;
private shapeResize$: ResizeObserver;
private legendHorizontal: boolean;
private decimals = 0;
private units = '';
private total = 0;
private totalText = 'N/A';
private scale = 1;
private dataItems: DoughnutDataItem[] = [];
private drawDoughnutPending = false;
private showTotal = false;
private doughnutChart: ECharts;
private doughnutOptions: EChartsOption;
private svgShape: Svg;
private totalTextNode: Text;
constructor(private widgetComponent: WidgetComponent,
private renderer: Renderer2,
private translate: TranslateService,
private cd: ChangeDetectorRef) {
}
ngOnInit(): void {
const params = this.widgetComponent.typeParameters as any;
const horizontal = isDefinedAndNotNull(params.horizontal) ? params.horizontal : false;
this.ctx.$scope.doughnutWidget = this;
this.settings = {...doughnutDefaultSettings(horizontal), ...this.ctx.settings};
this.decimals = this.ctx.decimals;
this.units = this.ctx.units;
this.showLegend = this.settings.showLegend;
this.showTotal = this.settings.layout === DoughnutLayout.with_total;
if (this.showTotal) {
this.totalValueColor = ColorProcessor.fromSettings(this.settings.totalValueColor);
}
this.backgroundStyle = backgroundStyle(this.settings.background);
this.overlayStyle = overlayStyle(this.settings.background.overlay);
if (this.showLegend) {
this.legendItems = [];
this.legendClass = `legend-${this.settings.legendPosition}`;
this.legendHorizontal = [DoughnutLegendPosition.left, DoughnutLegendPosition.right].includes(this.settings.legendPosition);
this.legendLabelStyle = textStyle(this.settings.legendLabelFont);
this.disabledLegendLabelStyle = textStyle(this.settings.legendLabelFont);
this.legendLabelStyle.color = this.settings.legendLabelColor;
this.legendValueStyle = textStyle(this.settings.legendValueFont);
this.disabledLegendValueStyle = textStyle(this.settings.legendValueFont);
this.legendValueStyle.color = this.settings.legendValueColor;
}
let counter = 0;
if (this.ctx.datasources.length) {
for (const datasource of this.ctx.datasources) {
const dataKeys = datasource.dataKeys;
for (const dataKey of dataKeys) {
const id = counter++;
this.dataItems.push({
id,
dataKey,
value: 0,
hasValue: false,
enabled: true
});
if (this.showLegend) {
this.legendItems.push(
{
id,
value: '--',
label: dataKey.label,
color: dataKey.color,
enabled: true,
hasValue: false
}
);
}
}
}
}
if (!this.showTotal && this.showLegend) {
this.legendItems.push(
{
id: null,
value: '--',
label: this.translate.instant('widgets.doughnut.total'),
color: 'rgba(0, 0, 0, 0.06)',
enabled: true,
hasValue: false,
total: true
}
);
}
}
ngAfterViewInit() {
if (this.drawDoughnutPending) {
this.drawDoughnut();
}
}
ngOnDestroy() {
if (this.shapeResize$) {
this.shapeResize$.disconnect();
}
if (this.doughnutChart) {
this.doughnutChart.dispose();
}
}
public onInit() {
const borderRadius = this.ctx.$widgetElement.css('borderRadius');
this.overlayStyle = {...this.overlayStyle, ...{borderRadius}};
if (this.doughnutShape) {
this.drawDoughnut();
} else {
this.drawDoughnutPending = true;
}
this.cd.detectChanges();
}
public onDataUpdated() {
for (let i=0; i < this.ctx.data.length; i++) {
const dsData = this.ctx.data[i];
let value = 0;
const tsValue = dsData.data[0];
if (tsValue && isDefinedAndNotNull(tsValue[1]) && isNumeric(tsValue[1])) {
value = tsValue[1];
this.dataItems[i].hasValue = true;
this.dataItems[i].value = value;
} else {
this.dataItems[i].hasValue = false;
this.dataItems[i].value = 0;
}
}
this.updateSeriesData();
if (this.showLegend) {
this.cd.detectChanges();
if (this.legendHorizontal) {
setTimeout(() => {
this.onResize();
});
}
}
}
private updateSeriesData(renderTotal = true) {
this.total = 0;
this.totalText = 'N/A';
let hasValue = false;
const seriesData: PieDataItemOption[] = [];
const enabledDataItems = this.dataItems.filter(item => item.enabled && item.hasValue);
for (let i=0; i < this.dataItems.length; i++) {
const dataItem = this.dataItems[i];
if (dataItem.enabled && dataItem.hasValue) {
hasValue = true;
this.total += dataItem.value;
seriesData.push(
{id: dataItem.id, value: dataItem.value, name: dataItem.dataKey.label, itemStyle: {color: dataItem.dataKey.color}}
);
if (enabledDataItems.length > 1) {
seriesData.push({
value: 0, name: '', itemStyle: {color: 'transparent'}, emphasis: {disabled: true}
});
}
}
if (this.showLegend) {
if (dataItem.hasValue) {
this.legendItems[i].hasValue = true;
this.legendItems[i].value = formatValue(dataItem.value, this.decimals, this.units, false);
} else {
this.legendItems[i].hasValue = false;
this.legendItems[i].value = '--';
}
}
}
for (let i= 1; i < seriesData.length; i+=2) {
seriesData[i].value = this.total / 100;
}
if (this.showTotal || this.showLegend) {
if (hasValue) {
this.totalText = formatValue(this.total, this.decimals, this.units, false);
if (this.showLegend && !this.showTotal) {
this.legendItems[this.legendItems.length - 1].hasValue = true;
this.legendItems[this.legendItems.length - 1].value = this.totalText;
}
} else if (this.showLegend && !this.showTotal) {
this.legendItems[this.legendItems.length - 1].hasValue = false;
this.legendItems[this.legendItems.length - 1].value = '--';
}
}
this.doughnutOptions.series[0].data = seriesData;
this.doughnutChart.setOption(this.doughnutOptions);
if (this.showTotal) {
this.totalValueColor.update(this.total);
if (renderTotal) {
this.renderTotal();
}
}
}
public onLegendItemEnter(item: DoughnutLegendItem) {
if (!item.total && item.enabled && item.hasValue) {
const dataIndex = this.doughnutOptions.series[0].data.findIndex(d => d.id === item.id);
if (dataIndex > -1) {
this.doughnutChart.dispatchAction({
type: 'highlight',
dataIndex
});
}
}
}
public onLegendItemLeave(item: DoughnutLegendItem) {
if (!item.total && item.enabled && item.hasValue) {
const dataIndex = this.doughnutOptions.series[0].data.findIndex(d => d.id === item.id);
if (dataIndex > -1) {
this.doughnutChart.dispatchAction({
type: 'downplay',
dataIndex
});
}
}
}
public toggleLegendItem(item: DoughnutLegendItem) {
if (!item.total && item.hasValue) {
const enable = !item.enabled;
const dataItem = this.dataItems.find(d => d.id === item.id);
if (dataItem) {
dataItem.enabled = enable;
this.updateSeriesData();
item.enabled = enable;
if (enable) {
const dataIndex = this.doughnutOptions.series[0].data.findIndex(d => d.id === item.id);
if (dataIndex > -1) {
this.doughnutChart.dispatchAction({
type: 'highlight',
dataIndex
});
}
}
}
}
}
private drawDoughnut() {
const shapeWidth = this.doughnutShape.nativeElement.getBoundingClientRect().width;
const shapeHeight = this.doughnutShape.nativeElement.getBoundingClientRect().height;
const size = this.settings.autoScale ? shapeSize : Math.min(shapeWidth, shapeHeight);
const innerRadius = size / 2 - shapeSegmentWidth;
const outerRadius = size / 2;
this.doughnutChart = echarts.init(this.doughnutShape.nativeElement, null, {
renderer: 'svg',
width: this.settings.autoScale ? shapeSize : undefined,
height: this.settings.autoScale ? shapeSize : undefined,
});
this.doughnutOptions = {
tooltip: {
trigger: this.settings.showTooltip ? 'item' : 'none',
confine: false,
appendToBody: true
},
series: [
{
type: 'pie',
clockwise: false,
radius: [innerRadius, outerRadius],
avoidLabelOverlap: false,
itemStyle: {
borderRadius: '50%',
borderWidth: 0,
borderColor: '#fff'
},
label: {
show: false
},
emphasis: {
scale: false,
itemStyle: {
borderColor: '#fff',
borderWidth: 2,
shadowColor: 'rgba(0, 0, 0, 0.24)',
shadowBlur: 8
},
label: {
show: false
}
}
}
]
};
if (this.settings.showTooltip) {
this.doughnutOptions.series[0].tooltip = {
formatter: (params) => {
let value: string;
if (this.settings.tooltipValueType === DoughnutTooltipValueType.percentage) {
const percents = params.value / this.total * 100;
value = formatValue(percents, this.settings.tooltipValueDecimals, '%', false);
} else {
value = formatValue(params.value, this.settings.tooltipValueDecimals, this.units, false);
}
const textElement: HTMLElement = this.renderer.createElement('div');
this.renderer.setStyle(textElement, 'display', 'inline-flex');
this.renderer.setStyle(textElement, 'align-items', 'center');
this.renderer.setStyle(textElement, 'gap', '8px');
const labelElement: HTMLElement = this.renderer.createElement('div');
this.renderer.appendChild(labelElement, this.renderer.createText(params.name));
this.renderer.setStyle(labelElement, 'font-family', 'Roboto');
this.renderer.setStyle(labelElement, 'font-size', '11px');
this.renderer.setStyle(labelElement, 'font-style', 'normal');
this.renderer.setStyle(labelElement, 'font-weight', '400');
this.renderer.setStyle(labelElement, 'line-height', '16px');
this.renderer.setStyle(labelElement, 'letter-spacing', '0.25px');
this.renderer.setStyle(labelElement, 'color', 'rgba(0, 0, 0, 0.38)');
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(textElement, labelElement);
this.renderer.appendChild(textElement, valueElement);
return textElement;
},
padding: [4, 8],
backgroundColor: this.settings.tooltipBackgroundColor,
extraCssText: `line-height: 1; backdrop-filter: blur(${this.settings.tooltipBackgroundBlur}px);`
};
this.doughnutOptions.series[0].tooltip.position = (pos) => [pos[0] + 10, pos[1] + 10];
}
this.updateSeriesData(false);
this.renderer.setStyle(this.doughnutChart.getDom().firstChild, 'overflow', 'visible');
if (this.settings.autoScale) {
this.renderer.setStyle(this.doughnutChart.getDom().firstChild, 'position', 'absolute');
}
this.renderer.setStyle(this.doughnutChart.getDom().firstChild.firstChild, 'overflow', 'visible');
this.svgShape = SVG(this.doughnutChart.getDom().firstChild.firstChild).toRoot();
if (this.showTotal) {
this.totalTextNode = this.svgShape.text('').font({
family: 'Roboto',
leading: 1
}).attr({'text-anchor': 'middle'});
this.renderTotal();
}
this.shapeResize$ = new ResizeObserver(() => {
this.onResize();
});
this.shapeResize$.observe(this.doughnutContent.nativeElement);
this.onResize();
}
private renderTotal() {
this.totalTextNode.text(add => {
add.tspan(this.translate.instant('widgets.doughnut.total')).font({size: '12px', weight: 400}).fill('rgba(0, 0, 0, 0.38)');
add.tspan('').newLine().font({size: '4px'});
add.tspan(this.totalText).newLine().font(
{family: this.settings.totalValueFont.family,
size: this.settings.totalValueFont.size + this.settings.totalValueFont.sizeUnit,
weight: this.settings.totalValueFont.weight,
style: this.settings.totalValueFont.style}
).fill(this.totalValueColor.color);
}).center(this.svgShape.bbox().width / 2, this.svgShape.bbox().height / 2);
}
private onResize() {
if (this.legendHorizontal) {
this.renderer.setStyle(this.doughnutShape.nativeElement, 'max-width', null);
this.renderer.setStyle(this.doughnutShape.nativeElement, 'min-width', null);
this.renderer.setStyle(this.doughnutLegend.nativeElement, 'flex', null);
}
const shapeWidth = this.doughnutShape.nativeElement.getBoundingClientRect().width;
const shapeHeight = this.doughnutShape.nativeElement.getBoundingClientRect().height;
const size = Math.min(shapeWidth, shapeHeight);
if (this.legendHorizontal) {
this.renderer.setStyle(this.doughnutShape.nativeElement, 'max-width', `${size}px`);
this.renderer.setStyle(this.doughnutShape.nativeElement, 'min-width', `${size}px`);
this.renderer.setStyle(this.doughnutLegend.nativeElement, 'flex', '1');
}
if (!this.settings.autoScale) {
const innerRadius = size / 2 - shapeSegmentWidth;
const outerRadius = size / 2;
this.doughnutOptions.series[0].radius = [innerRadius, outerRadius];
this.doughnutChart.setOption(this.doughnutOptions);
} else {
this.scale = size / shapeSize;
this.renderer.setStyle(this.doughnutChart.getDom().firstChild, 'transform', `scale(${this.scale})`);
}
if (!this.settings.autoScale) {
this.doughnutChart.resize();
}
if (this.showTotal) {
this.totalTextNode.center((this.settings.autoScale ? shapeSize : shapeWidth) / 2,
(this.settings.autoScale ? shapeSize : shapeHeight) / 2);
}
}
}

159
ui-ngx/src/app/modules/home/components/widget/lib/chart/doughnut-widget.models.ts

@ -0,0 +1,159 @@
///
/// 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,
ColorSettings,
constantColor,
Font
} from '@shared/models/widget-settings.models';
export enum DoughnutLayout {
default = 'default',
with_total = 'with_total'
}
export const doughnutLayouts = Object.keys(DoughnutLayout) as DoughnutLayout[];
export const doughnutLayoutTranslations = new Map<DoughnutLayout, string>(
[
[DoughnutLayout.default, 'widgets.doughnut.layout-default'],
[DoughnutLayout.with_total, 'widgets.doughnut.layout-with-total']
]
);
export const doughnutLayoutImages = new Map<DoughnutLayout, string>(
[
[DoughnutLayout.default, 'assets/widget/doughnut/default-layout.svg'],
[DoughnutLayout.with_total, 'assets/widget/doughnut/with-total-layout.svg']
]
);
export const horizontalDoughnutLayoutImages = new Map<DoughnutLayout, string>(
[
[DoughnutLayout.default, 'assets/widget/doughnut/horizontal-default-layout.svg'],
[DoughnutLayout.with_total, 'assets/widget/doughnut/horizontal-with-total-layout.svg']
]
);
export enum DoughnutLegendPosition {
top = 'top',
bottom = 'bottom',
left = 'left',
right = 'right'
}
export const doughnutLegendPositionTranslations = new Map<DoughnutLegendPosition, string>(
[
[DoughnutLegendPosition.top, 'widgets.doughnut.legend-position-top'],
[DoughnutLegendPosition.bottom, 'widgets.doughnut.legend-position-bottom'],
[DoughnutLegendPosition.left, 'widgets.doughnut.legend-position-left'],
[DoughnutLegendPosition.right, 'widgets.doughnut.legend-position-right']
]
);
export enum DoughnutTooltipValueType {
absolute = 'absolute',
percentage = 'percentage'
}
export const doughnutTooltipValueTypes = Object.keys(DoughnutTooltipValueType) as DoughnutTooltipValueType[];
export const doughnutTooltipValueTypeTranslations = new Map<DoughnutTooltipValueType, string>(
[
[DoughnutTooltipValueType.absolute, 'widgets.doughnut.tooltip-value-type-absolute'],
[DoughnutTooltipValueType.percentage, 'widgets.doughnut.tooltip-value-type-percentage']
]
);
export interface DoughnutWidgetSettings {
layout: DoughnutLayout;
autoScale: boolean;
totalValueFont: Font;
totalValueColor: ColorSettings;
showLegend: boolean;
legendPosition: DoughnutLegendPosition;
legendLabelFont: Font;
legendLabelColor: string;
legendValueFont: Font;
legendValueColor: string;
showTooltip: boolean;
tooltipValueType: DoughnutTooltipValueType;
tooltipValueDecimals: number;
tooltipValueFont: Font;
tooltipValueColor: string;
tooltipBackgroundColor: string;
tooltipBackgroundBlur: number;
background: BackgroundSettings;
}
export const doughnutDefaultSettings = (horizontal: boolean): DoughnutWidgetSettings => ({
layout: DoughnutLayout.default,
autoScale: true,
totalValueFont: {
family: 'Roboto',
size: 24,
sizeUnit: 'px',
style: 'normal',
weight: '500',
lineHeight: '1'
},
totalValueColor: constantColor('rgba(0, 0, 0, 0.87)'),
showLegend: true,
legendPosition: horizontal ? DoughnutLegendPosition.right : DoughnutLegendPosition.bottom,
legendLabelFont: {
family: 'Roboto',
size: 12,
sizeUnit: 'px',
style: 'normal',
weight: '400',
lineHeight: '16px'
},
legendLabelColor: 'rgba(0, 0, 0, 0.38)',
legendValueFont: {
family: 'Roboto',
size: 14,
sizeUnit: 'px',
style: 'normal',
weight: '500',
lineHeight: '20px'
},
legendValueColor: 'rgba(0, 0, 0, 0.87)',
showTooltip: true,
tooltipValueType: DoughnutTooltipValueType.percentage,
tooltipValueDecimals: 0,
tooltipValueFont: {
family: 'Roboto',
size: 13,
sizeUnit: 'px',
style: 'normal',
weight: '500',
lineHeight: '16px'
},
tooltipValueColor: '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
}
}
});

2
ui-ngx/src/app/modules/home/components/widget/lib/display-columns-panel.component.scss

@ -17,7 +17,7 @@
width: 100%; width: 100%;
height: 100%; height: 100%;
min-width: 300px; min-width: 300px;
overflow: hidden; overflow: auto;
background: #fff; background: #fff;
border-radius: 4px; border-radius: 4px;
box-shadow: box-shadow:

49
ui-ngx/src/app/modules/home/components/widget/lib/entity/entities-table-widget.component.ts

@ -46,7 +46,7 @@ import { deepClone, hashCode, isDefined, isNumber, isObject, isUndefined } from
import cssjs from '@core/css/css'; import cssjs from '@core/css/css';
import { CollectionViewer, DataSource } from '@angular/cdk/collections'; import { CollectionViewer, DataSource } from '@angular/cdk/collections';
import { DataKeyType } from '@shared/models/telemetry/telemetry.models'; import { DataKeyType } from '@shared/models/telemetry/telemetry.models';
import { BehaviorSubject, merge, Observable, Subject } from 'rxjs'; import { BehaviorSubject, fromEvent, merge, Observable, Subject } from 'rxjs';
import { emptyPageData, PageData } from '@shared/models/page/page-data'; import { emptyPageData, PageData } from '@shared/models/page/page-data';
import { EntityId } from '@shared/models/id/entity-id'; import { EntityId } from '@shared/models/id/entity-id';
import { entityTypeTranslations } from '@shared/models/entity-type.models'; import { entityTypeTranslations } from '@shared/models/entity-type.models';
@ -83,7 +83,7 @@ import {
TableWidgetSettings, TableWidgetSettings,
widthStyle widthStyle
} from '@home/components/widget/lib/table-widget.models'; } from '@home/components/widget/lib/table-widget.models';
import { ConnectedPosition, Overlay, OverlayConfig, OverlayRef } from '@angular/cdk/overlay'; import { Overlay, OverlayConfig, OverlayRef } from '@angular/cdk/overlay';
import { ComponentPortal } from '@angular/cdk/portal'; import { ComponentPortal } from '@angular/cdk/portal';
import { import {
DISPLAY_COLUMNS_PANEL_DATA, DISPLAY_COLUMNS_PANEL_DATA,
@ -106,6 +106,7 @@ import { ResizeObserver } from '@juggle/resize-observer';
import { hidePageSizePixelValue } from '@shared/models/constants'; import { hidePageSizePixelValue } from '@shared/models/constants';
import { AggregationType } from '@shared/models/time/time.models'; import { AggregationType } from '@shared/models/time/time.models';
import { FormBuilder } from '@angular/forms'; import { FormBuilder } from '@angular/forms';
import { DEFAULT_OVERLAY_POSITIONS } from '@shared/models/overlay.models';
interface EntitiesTableWidgetSettings extends TableWidgetSettings { interface EntitiesTableWidgetSettings extends TableWidgetSettings {
entitiesTitle: string; entitiesTitle: string;
@ -462,18 +463,17 @@ export class EntitiesTableWidgetComponent extends PageComponent implements OnIni
if ($event) { if ($event) {
$event.stopPropagation(); $event.stopPropagation();
} }
const target = $event.target || $event.currentTarget; const target = $event.target || $event.srcElement || $event.currentTarget;
const config = new OverlayConfig(); const config = new OverlayConfig({
config.backdropClass = 'cdk-overlay-transparent-backdrop'; panelClass: 'tb-panel-container',
config.hasBackdrop = true; backdropClass: 'cdk-overlay-transparent-backdrop',
const connectedPosition: ConnectedPosition = { hasBackdrop: true,
originX: 'end', height: 'fit-content',
originY: 'bottom', maxHeight: '75vh'
overlayX: 'end', });
overlayY: 'top' config.positionStrategy = this.overlay.position()
}; .flexibleConnectedTo(target as HTMLElement)
config.positionStrategy = this.overlay.position().flexibleConnectedTo(target as HTMLElement) .withPositions(DEFAULT_OVERLAY_POSITIONS);
.withPositions([connectedPosition]);
const overlayRef = this.overlay.create(config); const overlayRef = this.overlay.create(config);
overlayRef.backdropClick().subscribe(() => { overlayRef.backdropClick().subscribe(() => {
@ -481,11 +481,11 @@ export class EntitiesTableWidgetComponent extends PageComponent implements OnIni
}); });
const columns: DisplayColumn[] = this.columns.map(column => ({ const columns: DisplayColumn[] = this.columns.map(column => ({
title: column.title, title: column.title,
def: column.def, def: column.def,
display: this.displayedColumns.indexOf(column.def) > -1, display: this.displayedColumns.indexOf(column.def) > -1,
selectable: this.columnSelectionAvailability[column.def] selectable: this.columnSelectionAvailability[column.def]
})); }));
const providers: StaticProvider[] = [ const providers: StaticProvider[] = [
{ {
@ -506,9 +506,18 @@ export class EntitiesTableWidgetComponent extends PageComponent implements OnIni
useValue: overlayRef useValue: overlayRef
} }
]; ];
const injector = Injector.create({parent: this.viewContainerRef.injector, providers}); const injector = Injector.create({parent: this.viewContainerRef.injector, providers});
overlayRef.attach(new ComponentPortal(DisplayColumnsPanelComponent, const componentRef = overlayRef.attach(new ComponentPortal(DisplayColumnsPanelComponent,
this.viewContainerRef, injector)); this.viewContainerRef, injector));
const resizeWindows$ = fromEvent(window, 'resize').subscribe(() => {
overlayRef.updatePosition();
});
componentRef.onDestroy(() => {
resizeWindows$.unsubscribe();
});
this.ctx.detectChanges(); this.ctx.detectChanges();
} }

5
ui-ngx/src/app/modules/home/components/widget/lib/settings/cards/progress-bar-widget-settings.component.html

@ -19,7 +19,10 @@
<div class="tb-form-panel"> <div class="tb-form-panel">
<div class="tb-form-panel-title" translate>widgets.progress-bar.progress-bar-card-style</div> <div class="tb-form-panel-title" translate>widgets.progress-bar.progress-bar-card-style</div>
<tb-image-cards-select rowHeight="2:1" <tb-image-cards-select rowHeight="2:1"
cols="2" [cols]="{columns: 2,
breakpoints: {
'lt-sm': 1
}}"
label="{{ 'widgets.progress-bar.layout' | translate }}" formControlName="layout"> label="{{ 'widgets.progress-bar.layout' | translate }}" formControlName="layout">
<tb-image-cards-select-option *ngFor="let layout of progressBarLayouts" <tb-image-cards-select-option *ngFor="let layout of progressBarLayouts"
[value]="layout" [value]="layout"

6
ui-ngx/src/app/modules/home/components/widget/lib/settings/cards/value-card-widget-settings.component.html

@ -19,8 +19,10 @@
<div class="tb-form-panel"> <div class="tb-form-panel">
<div class="tb-form-panel-title" translate>widgets.value-card.value-card-style</div> <div class="tb-form-panel-title" translate>widgets.value-card.value-card-style</div>
<tb-image-cards-select rowHeight="{{ horizontal ? '3:1' : '7:5' }}" <tb-image-cards-select rowHeight="{{ horizontal ? '3:1' : '7:5' }}"
[cols]="horizontal ? 2 : 4" [cols]="{columns: horizontal ? 2 : 4,
[colsLtMd]="horizontal ? 1 : 2" breakpoints: {
'lt-md': horizontal ? 1 : 2
}}"
label="{{ 'widgets.value-card.layout' | translate }}" formControlName="layout"> label="{{ 'widgets.value-card.layout' | translate }}" formControlName="layout">
<tb-image-cards-select-option *ngFor="let layout of valueCardLayouts" <tb-image-cards-select-option *ngFor="let layout of valueCardLayouts"
[value]="layout" [value]="layout"

5
ui-ngx/src/app/modules/home/components/widget/lib/settings/cards/value-chart-card-widget-settings.component.html

@ -19,7 +19,10 @@
<div class="tb-form-panel"> <div class="tb-form-panel">
<div class="tb-form-panel-title" translate>widgets.value-chart-card.value-chart-card-style</div> <div class="tb-form-panel-title" translate>widgets.value-chart-card.value-chart-card-style</div>
<tb-image-cards-select rowHeight="2:1" <tb-image-cards-select rowHeight="2:1"
cols="2" [cols]="{columns: 2,
breakpoints: {
'lt-sm': 1
}}"
label="{{ 'widgets.value-chart-card.layout' | translate }}" formControlName="layout"> label="{{ 'widgets.value-chart-card.layout' | translate }}" formControlName="layout">
<tb-image-cards-select-option *ngFor="let layout of valueChartCardLayouts" <tb-image-cards-select-option *ngFor="let layout of valueChartCardLayouts"
[value]="layout" [value]="layout"

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

@ -0,0 +1,153 @@
<!--
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]="doughnutWidgetSettingsForm">
<div class="tb-form-panel">
<div class="tb-form-panel-title" translate>widgets.doughnut.doughnut-card-style</div>
<tb-image-cards-select rowHeight="{{ horizontal ? '8:5' : '5:4' }}"
[cols]="{columns: 2,
breakpoints: {
'lt-sm': 1
}}"
label="{{ 'widgets.doughnut.layout' | translate }}" formControlName="layout">
<tb-image-cards-select-option *ngFor="let layout of doughnutLayouts"
[value]="layout"
[image]="doughnutLayoutImageMap.get(layout)">
{{ doughnutLayoutTranslationMap.get(layout) | translate }}
</tb-image-cards-select-option>
</tb-image-cards-select>
<div class="tb-form-row">
<mat-slide-toggle class="mat-slide" formControlName="autoScale">
{{ 'widgets.value-card.auto-scale' | translate }}
</mat-slide-toggle>
</div>
<div [fxShow]="totalEnabled" class="tb-form-row space-between">
<div>{{ 'widgets.doughnut.central-total-value' | translate }}</div>
<div fxLayout="row" fxLayoutAlign="start center" fxLayoutGap="8px">
<tb-font-settings formControlName="totalValueFont"
[previewText]="valuePreviewFn">
</tb-font-settings>
<tb-color-settings formControlName="totalValueColor" settingsKey="{{'widgets.doughnut.central-total-value' | translate }}">
</tb-color-settings>
</div>
</div>
<div class="tb-form-panel tb-slide-toggle">
<mat-expansion-panel class="tb-settings" [expanded]="doughnutWidgetSettingsForm.get('showLegend').value" [disabled]="!doughnutWidgetSettingsForm.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 doughnutLegendPositions" [value]="pos">
{{ doughnutLegendPositionTranslationMap.get(pos) | translate }}
</mat-option>
</mat-select>
</mat-form-field>
</div>
<div class="tb-form-row space-between">
<div>{{ 'widgets.doughnut.legend-label' | translate }}</div>
<div fxLayout="row" fxLayoutAlign="start center" fxLayoutGap="8px">
<tb-font-settings formControlName="legendLabelFont"
previewText="Wind power">
</tb-font-settings>
<tb-color-input asBoxInput
colorClearButton
formControlName="legendLabelColor">
</tb-color-input>
</div>
</div>
<div class="tb-form-row space-between">
<div>{{ 'widgets.doughnut.legend-value' | translate }}</div>
<div fxLayout="row" fxLayoutAlign="start center" fxLayoutGap="8px">
<tb-font-settings formControlName="legendValueFont"
[previewText]="valuePreviewFn">
</tb-font-settings>
<tb-color-input asBoxInput
colorClearButton
formControlName="legendValueColor">
</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]="doughnutWidgetSettingsForm.get('showTooltip').value" [disabled]="!doughnutWidgetSettingsForm.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.doughnut.tooltip' | translate }}
</mat-slide-toggle>
</mat-panel-title>
</mat-expansion-panel-header>
<ng-template matExpansionPanelContent>
<div class="tb-form-row space-between column-xs">
<div>{{ 'widgets.doughnut.tooltip-value' | translate }}</div>
<div fxLayout="row" fxLayoutAlign="start center" fxLayoutGap="8px">
<mat-form-field class="medium-width" appearance="outline" subscriptSizing="dynamic">
<mat-select formControlName="tooltipValueType">
<mat-option *ngFor="let type of doughnutTooltipValueTypes" [value]="type">
{{ doughnutTooltipValueTypeTranslationMap.get(type) | translate }}
</mat-option>
</mat-select>
</mat-form-field>
<mat-form-field appearance="outline" class="number" subscriptSizing="dynamic">
<input matInput formControlName="tooltipValueDecimals" type="number" min="0" max="15" step="1" placeholder="{{ 'widget-config.set' | translate }}">
<div matSuffix fxHide.lt-md translate>widget-config.decimals-suffix</div>
</mat-form-field>
<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 space-between">
<div>{{ 'widgets.doughnut.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.doughnut.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>

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

@ -0,0 +1,204 @@
///
/// 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 { 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, isDefinedAndNotNull } from '@core/utils';
import { getDataKey } from '@shared/models/widget-settings.models';
import {
doughnutDefaultSettings,
DoughnutLayout,
doughnutLayoutImages,
doughnutLayouts,
doughnutLayoutTranslations,
DoughnutLegendPosition,
doughnutLegendPositionTranslations,
DoughnutTooltipValueType,
doughnutTooltipValueTypes,
doughnutTooltipValueTypeTranslations,
horizontalDoughnutLayoutImages
} from '@home/components/widget/lib/chart/doughnut-widget.models';
import { WidgetConfigComponentData } from '@home/models/widget-component.models';
@Component({
selector: 'tb-doughnut-widget-settings',
templateUrl: './doughnut-widget-settings.component.html',
styleUrls: []
})
export class DoughnutWidgetSettingsComponent extends WidgetSettingsComponent {
get totalEnabled(): boolean {
const layout: DoughnutLayout = this.doughnutWidgetSettingsForm.get('layout').value;
return layout === DoughnutLayout.with_total;
}
doughnutLayouts = doughnutLayouts;
doughnutLayoutTranslationMap = doughnutLayoutTranslations;
horizontal = false;
doughnutLayoutImageMap: Map<DoughnutLayout, string>;
doughnutLegendPositions: DoughnutLegendPosition[];
doughnutLegendPositionTranslationMap = doughnutLegendPositionTranslations;
doughnutTooltipValueTypes = doughnutTooltipValueTypes;
doughnutTooltipValueTypeTranslationMap = doughnutTooltipValueTypeTranslations;
doughnutWidgetSettingsForm: UntypedFormGroup;
valuePreviewFn = this._valuePreviewFn.bind(this);
tooltipValuePreviewFn = this._tooltipValuePreviewFn.bind(this);
constructor(protected store: Store<AppState>,
private $injector: Injector,
private fb: UntypedFormBuilder) {
super(store);
}
protected settingsForm(): UntypedFormGroup {
return this.doughnutWidgetSettingsForm;
}
protected onWidgetConfigSet(widgetConfig: WidgetConfigComponentData) {
const params = widgetConfig.typeParameters as any;
this.horizontal = isDefinedAndNotNull(params.horizontal) ? params.horizontal : false;
this.doughnutLayoutImageMap = this.horizontal ? horizontalDoughnutLayoutImages : doughnutLayoutImages;
this.doughnutLegendPositions = this.horizontal ? [DoughnutLegendPosition.left, DoughnutLegendPosition.right] :
[DoughnutLegendPosition.top, DoughnutLegendPosition.bottom];
}
protected defaultSettings(): WidgetSettings {
return doughnutDefaultSettings(this.horizontal);
}
protected onSettingsSet(settings: WidgetSettings) {
this.doughnutWidgetSettingsForm = this.fb.group({
layout: [settings.layout, []],
autoScale: [settings.autoScale, []],
totalValueFont: [settings.totalValueFont, []],
totalValueColor: [settings.totalValueColor, []],
showLegend: [settings.showLegend, []],
legendPosition: [settings.legendPosition, []],
legendLabelFont: [settings.legendLabelFont, []],
legendLabelColor: [settings.legendLabelColor, []],
legendValueFont: [settings.legendValueFont, []],
legendValueColor: [settings.legendValueColor, []],
showTooltip: [settings.showTooltip, []],
tooltipValueType: [settings.tooltipValueType, []],
tooltipValueDecimals: [settings.tooltipValueDecimals, []],
tooltipValueFont: [settings.tooltipValueFont, []],
tooltipValueColor: [settings.tooltipValueColor, []],
tooltipBackgroundColor: [settings.tooltipBackgroundColor, []],
tooltipBackgroundBlur: [settings.tooltipBackgroundBlur, []],
background: [settings.background, []]
});
}
protected validatorTriggers(): string[] {
return ['layout', 'showLegend', 'showTooltip'];
}
protected updateValidators(emitEvent: boolean) {
const layout: DoughnutLayout = this.doughnutWidgetSettingsForm.get('layout').value;
const showLegend: boolean = this.doughnutWidgetSettingsForm.get('showLegend').value;
const showTooltip: boolean = this.doughnutWidgetSettingsForm.get('showTooltip').value;
const totalEnabled = layout === DoughnutLayout.with_total;
if (showLegend) {
this.doughnutWidgetSettingsForm.get('legendPosition').enable();
this.doughnutWidgetSettingsForm.get('legendLabelFont').enable();
this.doughnutWidgetSettingsForm.get('legendLabelColor').enable();
this.doughnutWidgetSettingsForm.get('legendValueFont').enable();
this.doughnutWidgetSettingsForm.get('legendValueColor').enable();
} else {
this.doughnutWidgetSettingsForm.get('legendPosition').disable();
this.doughnutWidgetSettingsForm.get('legendLabelFont').disable();
this.doughnutWidgetSettingsForm.get('legendLabelColor').disable();
this.doughnutWidgetSettingsForm.get('legendValueFont').disable();
this.doughnutWidgetSettingsForm.get('legendValueColor').disable();
}
if (showTooltip) {
this.doughnutWidgetSettingsForm.get('tooltipValueType').enable();
this.doughnutWidgetSettingsForm.get('tooltipValueDecimals').enable();
this.doughnutWidgetSettingsForm.get('tooltipValueFont').enable();
this.doughnutWidgetSettingsForm.get('tooltipValueColor').enable();
this.doughnutWidgetSettingsForm.get('tooltipBackgroundColor').enable();
this.doughnutWidgetSettingsForm.get('tooltipBackgroundBlur').enable();
} else {
this.doughnutWidgetSettingsForm.get('tooltipValueType').disable();
this.doughnutWidgetSettingsForm.get('tooltipValueDecimals').disable();
this.doughnutWidgetSettingsForm.get('tooltipValueFont').disable();
this.doughnutWidgetSettingsForm.get('tooltipValueColor').disable();
this.doughnutWidgetSettingsForm.get('tooltipBackgroundColor').disable();
this.doughnutWidgetSettingsForm.get('tooltipBackgroundBlur').disable();
}
if (totalEnabled) {
this.doughnutWidgetSettingsForm.get('totalValueFont').enable();
this.doughnutWidgetSettingsForm.get('totalValueColor').enable();
} else {
this.doughnutWidgetSettingsForm.get('totalValueFont').disable();
this.doughnutWidgetSettingsForm.get('totalValueColor').disable();
}
}
private _centerValuePreviewFn(): string {
const centerValueDataKey = getDataKey(this.widgetConfig.config.datasources, 1);
if (centerValueDataKey) {
let units: string = this.widgetConfig.config.units;
let decimals: number = this.widgetConfig.config.decimals;
if (isDefinedAndNotNull(centerValueDataKey?.decimals)) {
decimals = centerValueDataKey.decimals;
}
if (centerValueDataKey?.units) {
units = centerValueDataKey.units;
}
return formatValue(25, decimals, units, true);
} else {
return '225°';
}
}
private _valuePreviewFn(): string {
const units: string = this.widgetConfig.config.units;
const decimals: number = this.widgetConfig.config.decimals;
return formatValue(110, decimals, units, false);
}
private _tooltipValuePreviewFn(): string {
const tooltipValueType: DoughnutTooltipValueType = this.doughnutWidgetSettingsForm.get('tooltipValueType').value;
const decimals: number = this.doughnutWidgetSettingsForm.get('tooltipValueDecimals').value;
if (tooltipValueType === DoughnutTooltipValueType.percentage) {
return formatValue(35, decimals, '%', false);
} else {
const units: string = this.widgetConfig.config.units;
return formatValue(110, decimals, units, false);
}
}
}

8
ui-ngx/src/app/modules/home/components/widget/lib/settings/common/count-widget-settings.component.html

@ -16,9 +16,11 @@
--> -->
<ng-container [formGroup]="countWidgetConfigForm"> <ng-container [formGroup]="countWidgetConfigForm">
<tb-image-cards-select rowHeight="{{ '3:1' }}" <tb-image-cards-select rowHeight="3:1"
[cols]="2" [cols]="{columns: 2,
[colsLtMd]="1" breakpoints: {
'lt-md': 1
}}"
label="{{ 'widgets.count.layout' | translate }}" formControlName="layout"> label="{{ 'widgets.count.layout' | translate }}" formControlName="layout">
<tb-image-cards-select-option *ngFor="let layout of countCardLayouts" <tb-image-cards-select-option *ngFor="let layout of countCardLayouts"
[value]="layout" [value]="layout"

75
ui-ngx/src/app/modules/home/components/widget/lib/settings/common/image-cards-select.component.ts

@ -21,18 +21,21 @@ import {
Directive, Directive,
ElementRef, ElementRef,
forwardRef, forwardRef,
Input, OnChanges, Input,
OnDestroy, OnInit, OnChanges,
QueryList, SimpleChanges, OnDestroy,
OnInit,
QueryList,
SimpleChanges,
ViewEncapsulation ViewEncapsulation
} from '@angular/core'; } from '@angular/core';
import { ControlValueAccessor, NG_VALUE_ACCESSOR, UntypedFormControl } from '@angular/forms'; import { ControlValueAccessor, NG_VALUE_ACCESSOR, UntypedFormControl } from '@angular/forms';
import { coerceBoolean } from '@shared/decorators/coercion'; import { coerceBoolean } from '@shared/decorators/coercion';
import { BehaviorSubject, combineLatest, Observable, Subject } from 'rxjs'; import { EMPTY, Observable, Subject } from 'rxjs';
import { map, share, startWith, takeUntil } from 'rxjs/operators'; import { map, share, startWith, takeUntil } from 'rxjs/operators';
import { BreakpointObserver } from '@angular/cdk/layout'; import { BreakpointObserver, BreakpointState } from '@angular/cdk/layout';
import { MediaBreakpoints } from '@shared/models/constants';
import { SafeUrl } from '@angular/platform-browser'; import { SafeUrl } from '@angular/platform-browser';
import { resolveBreakpoint } from '@shared/models/constants';
export interface ImageCardsSelectOption { export interface ImageCardsSelectOption {
name: string; name: string;
@ -40,6 +43,11 @@ export interface ImageCardsSelectOption {
image: string | SafeUrl; image: string | SafeUrl;
} }
export interface ImageCardsColumns {
columns: number;
breakpoints?: {[breakpoint: string]: number};
}
@Directive( @Directive(
{ {
// eslint-disable-next-line @angular-eslint/directive-selector // eslint-disable-next-line @angular-eslint/directive-selector
@ -83,10 +91,7 @@ export class ImageCardsSelectComponent implements ControlValueAccessor, OnInit,
disabled: boolean; disabled: boolean;
@Input() @Input()
cols = 4; cols: ImageCardsColumns | number = 4;
@Input()
colsLtMd = 2;
@Input() @Input()
rowHeight = '9:5'; rowHeight = '9:5';
@ -108,28 +113,20 @@ export class ImageCardsSelectComponent implements ControlValueAccessor, OnInit,
private _destroyed = new Subject<void>(); private _destroyed = new Subject<void>();
private _colsChanged = new BehaviorSubject<void>(null);
constructor(private breakpointObserver: BreakpointObserver) { constructor(private breakpointObserver: BreakpointObserver) {
this.valueFormControl = new UntypedFormControl(''); this.valueFormControl = new UntypedFormControl('');
} }
ngOnInit(): void { ngOnInit(): void {
const gridColumns = this.breakpointObserver.isMatched(MediaBreakpoints['lt-md']) ? this.colsLtMd : this.cols; this._initCols();
this.cols$ = combineLatest({state: this.breakpointObserver
.observe(MediaBreakpoints['lt-md']), colsChanged: this._colsChanged.asObservable()}).pipe(
map((data) => data.state.matches ? this.colsLtMd : this.cols),
startWith(gridColumns),
share()
);
} }
ngOnChanges(changes: SimpleChanges): void { ngOnChanges(changes: SimpleChanges): void {
for (const propName of Object.keys(changes)) { for (const propName of Object.keys(changes)) {
const change = changes[propName]; const change = changes[propName];
if (!change.firstChange && change.currentValue !== change.previousValue) { if (!change.firstChange && change.currentValue !== change.previousValue) {
if (['cols', 'colsLtMd'].includes(propName)) { if (['cols'].includes(propName)) {
this._colsChanged.next(null); this._initCols();
} }
} }
} }
@ -146,6 +143,40 @@ export class ImageCardsSelectComponent implements ControlValueAccessor, OnInit,
this._destroyed.complete(); this._destroyed.complete();
} }
private _initCols() {
const gridColumns = this._detectColumns();
let state: Observable<BreakpointState>;
if (typeof this.cols === 'object' && this.cols.breakpoints) {
const breakpoints = Object.keys(this.cols.breakpoints);
state = this.breakpointObserver.observe(breakpoints.map(breakpoint => resolveBreakpoint(breakpoint)));
} else {
state = EMPTY;
}
this.cols$ = state.pipe(
map(() => this._detectColumns()),
startWith(gridColumns),
share()
);
}
private _detectColumns(): number {
if (typeof this.cols !== 'object') {
return this.cols;
} else {
let columns = this.cols.columns;
if (this.cols.breakpoints) {
for (const breakpoint of Object.keys(this.cols.breakpoints)) {
const breakpointValue = resolveBreakpoint(breakpoint);
if (this.breakpointObserver.isMatched(breakpointValue)) {
columns = this.cols.breakpoints[breakpoint];
break;
}
}
}
return columns;
}
}
private syncImageCardsSelectOptions() { private syncImageCardsSelectOptions() {
if (this.imageCardsSelectOptions?.length) { if (this.imageCardsSelectOptions?.length) {
this.options.length = 0; this.options.length = 0;
@ -165,7 +196,7 @@ export class ImageCardsSelectComponent implements ControlValueAccessor, OnInit,
this.propagateChange = fn; this.propagateChange = fn;
} }
registerOnTouched(fn: any): void { registerOnTouched(_fn: any): void {
} }
setDisabledState(isDisabled: boolean): void { setDisabledState(isDisabled: boolean): void {

4
ui-ngx/src/app/modules/home/components/widget/lib/settings/gauge/analogue-compass-widget-settings.component.html

@ -86,7 +86,7 @@
</mat-slide-toggle> </mat-slide-toggle>
<div fxLayout="row" fxLayoutAlign="start center" fxLayoutGap="8px"> <div fxLayout="row" fxLayoutAlign="start center" fxLayoutGap="8px">
<mat-form-field class="number" appearance="outline" subscriptSizing="dynamic"> <mat-form-field class="number" appearance="outline" subscriptSizing="dynamic">
<input matInput min="0" formControlName="borderOuterWidth" placeholder="{{ 'widget-config.set' | translate }}"> <input matInput type="number" min="0" formControlName="borderOuterWidth" placeholder="{{ 'widget-config.set' | translate }}">
</mat-form-field> </mat-form-field>
<tb-color-input asBoxInput <tb-color-input asBoxInput
colorClearButton colorClearButton
@ -102,7 +102,7 @@
<div translate>widgets.gauge.needle-circle</div> <div translate>widgets.gauge.needle-circle</div>
<div fxLayout="row" fxLayoutAlign="start center" fxLayoutGap="8px"> <div fxLayout="row" fxLayoutAlign="start center" fxLayoutGap="8px">
<mat-form-field class="number" appearance="outline" subscriptSizing="dynamic"> <mat-form-field class="number" appearance="outline" subscriptSizing="dynamic">
<input matInput min="0" formControlName="needleCircleSize" placeholder="{{ 'widget-config.set' | translate }}"> <input matInput type="number" min="0" formControlName="needleCircleSize" placeholder="{{ 'widget-config.set' | translate }}">
</mat-form-field> </mat-form-field>
<tb-color-input asBoxInput <tb-color-input asBoxInput
colorClearButton colorClearButton

6
ui-ngx/src/app/modules/home/components/widget/lib/settings/indicator/battery-level-widget-settings.component.html

@ -19,8 +19,10 @@
<div class="tb-form-panel"> <div class="tb-form-panel">
<div class="tb-form-panel-title" translate>widgets.battery-level.battery-level-card-style</div> <div class="tb-form-panel-title" translate>widgets.battery-level.battery-level-card-style</div>
<tb-image-cards-select rowHeight="1:1" <tb-image-cards-select rowHeight="1:1"
cols="4" [cols]="{columns: 4,
colsLtMd="2" breakpoints: {
'lt-md': 2
}}"
label="{{ 'widgets.battery-level.layout' | translate }}" formControlName="layout"> label="{{ 'widgets.battery-level.layout' | translate }}" formControlName="layout">
<tb-image-cards-select-option *ngFor="let layout of batteryLevelLayouts" <tb-image-cards-select-option *ngFor="let layout of batteryLevelLayouts"
[value]="layout" [value]="layout"

13
ui-ngx/src/app/modules/home/components/widget/lib/settings/indicator/liquid-level-card-widget-settings.component.html

@ -29,9 +29,11 @@
</div> </div>
<tb-image-cards-select #shapesImageCardsSelect <tb-image-cards-select #shapesImageCardsSelect
[fxShow]="levelCardWidgetSettingsForm.get('tankSelectionType').value === DataSourceType.static" [fxShow]="levelCardWidgetSettingsForm.get('tankSelectionType').value === DataSourceType.static"
rowHeight="{{ '1:1' }}" rowHeight="1:1"
[cols]="5" [cols]="{columns: 5,
[colsLtMd]="2" breakpoints: {
'lt-md': 2
}}"
style="width: 100%;" style="width: 100%;"
label="{{ 'widgets.liquid-level-card.shape-type' | translate }}" formControlName="selectedShape"> label="{{ 'widgets.liquid-level-card.shape-type' | translate }}" formControlName="selectedShape">
<tb-image-cards-select-option *ngFor="let shape of shapes" <tb-image-cards-select-option *ngFor="let shape of shapes"
@ -65,9 +67,8 @@
<div class="tb-form-panel"> <div class="tb-form-panel">
<div class="tb-form-panel-title" translate>widgets.liquid-level-card.layout</div> <div class="tb-form-panel-title" translate>widgets.liquid-level-card.layout</div>
<tb-image-cards-select #layoutsImageCardsSelect <tb-image-cards-select #layoutsImageCardsSelect
rowHeight="{{ '1:1' }}" rowHeight="1:1"
[cols]="3" cols="3"
[colsLtMd]="3"
label="{{ 'widgets.liquid-level-card.layout' | translate }}" formControlName="layout"> label="{{ 'widgets.liquid-level-card.layout' | translate }}" formControlName="layout">
<tb-image-cards-select-option <tb-image-cards-select-option
*ngFor="let layout of LevelCardLayouts" *ngFor="let layout of LevelCardLayouts"

5
ui-ngx/src/app/modules/home/components/widget/lib/settings/indicator/signal-strength-widget-settings.component.html

@ -19,7 +19,10 @@
<div class="tb-form-panel"> <div class="tb-form-panel">
<div class="tb-form-panel-title" translate>widgets.signal-strength.signal-strength-card-style</div> <div class="tb-form-panel-title" translate>widgets.signal-strength.signal-strength-card-style</div>
<tb-image-cards-select rowHeight="2:1" <tb-image-cards-select rowHeight="2:1"
cols="2" [cols]="{columns: 2,
breakpoints: {
'lt-sm': 1
}}"
label="{{ 'widgets.signal-strength.layout' | translate }}" formControlName="layout"> label="{{ 'widgets.signal-strength.layout' | translate }}" formControlName="layout">
<tb-image-cards-select-option *ngFor="let layout of signalStrengthLayouts" <tb-image-cards-select-option *ngFor="let layout of signalStrengthLayouts"
[value]="layout" [value]="layout"

6
ui-ngx/src/app/modules/home/components/widget/lib/settings/weather/wind-speed-direction-widget-settings.component.html

@ -19,8 +19,10 @@
<div class="tb-form-panel"> <div class="tb-form-panel">
<div class="tb-form-panel-title" translate>widgets.wind-speed-direction.wind-speed-direction-card-style</div> <div class="tb-form-panel-title" translate>widgets.wind-speed-direction.wind-speed-direction-card-style</div>
<tb-image-cards-select rowHeight="1:1" <tb-image-cards-select rowHeight="1:1"
cols="3" [cols]="{columns: 3,
colsLtMd="2" breakpoints: {
'lt-md': 2
}}"
label="{{ 'widgets.wind-speed-direction.layout' | translate }}" formControlName="layout"> label="{{ 'widgets.wind-speed-direction.layout' | translate }}" formControlName="layout">
<tb-image-cards-select-option *ngFor="let layout of windSpeedDirectionLayouts" <tb-image-cards-select-option *ngFor="let layout of windSpeedDirectionLayouts"
[value]="layout" [value]="layout"

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

@ -302,6 +302,9 @@ import {
import { import {
LiquidLevelCardWidgetSettingsComponent LiquidLevelCardWidgetSettingsComponent
} from '@home/components/widget/lib/settings/indicator/liquid-level-card-widget-settings.component'; } from '@home/components/widget/lib/settings/indicator/liquid-level-card-widget-settings.component';
import {
DoughnutWidgetSettingsComponent
} from '@home/components/widget/lib/settings/chart/doughnut-widget-settings.component';
@NgModule({ @NgModule({
declarations: [ declarations: [
@ -412,7 +415,8 @@ import {
SignalStrengthWidgetSettingsComponent, SignalStrengthWidgetSettingsComponent,
ValueChartCardWidgetSettingsComponent, ValueChartCardWidgetSettingsComponent,
ProgressBarWidgetSettingsComponent, ProgressBarWidgetSettingsComponent,
LiquidLevelCardWidgetSettingsComponent LiquidLevelCardWidgetSettingsComponent,
DoughnutWidgetSettingsComponent
], ],
imports: [ imports: [
CommonModule, CommonModule,
@ -529,6 +533,7 @@ import {
ValueChartCardWidgetSettingsComponent, ValueChartCardWidgetSettingsComponent,
ProgressBarWidgetSettingsComponent, ProgressBarWidgetSettingsComponent,
LiquidLevelCardWidgetSettingsComponent, LiquidLevelCardWidgetSettingsComponent,
DoughnutWidgetSettingsComponent
] ]
}) })
export class WidgetSettingsModule { export class WidgetSettingsModule {
@ -611,4 +616,5 @@ 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
}; };

39
ui-ngx/src/app/modules/home/components/widget/lib/timeseries-table-widget.component.ts

@ -56,7 +56,7 @@ import cssjs from '@core/css/css';
import { PageLink } from '@shared/models/page/page-link'; import { PageLink } from '@shared/models/page/page-link';
import { Direction, SortOrder, sortOrderFromString } from '@shared/models/page/sort-order'; import { Direction, SortOrder, sortOrderFromString } from '@shared/models/page/sort-order';
import { CollectionViewer, DataSource } from '@angular/cdk/collections'; import { CollectionViewer, DataSource } from '@angular/cdk/collections';
import { BehaviorSubject, merge, Observable, of, Subject, Subscription } from 'rxjs'; import { BehaviorSubject, fromEvent, merge, Observable, of, Subject, Subscription } from 'rxjs';
import { emptyPageData, PageData } from '@shared/models/page/page-data'; import { emptyPageData, PageData } from '@shared/models/page/page-data';
import { catchError, debounceTime, distinctUntilChanged, map, skip, startWith, takeUntil } from 'rxjs/operators'; import { catchError, debounceTime, distinctUntilChanged, map, skip, startWith, takeUntil } from 'rxjs/operators';
import { MatPaginator } from '@angular/material/paginator'; import { MatPaginator } from '@angular/material/paginator';
@ -81,7 +81,7 @@ import {
TableWidgetDataKeySettings, TableWidgetDataKeySettings,
TableWidgetSettings TableWidgetSettings
} from '@home/components/widget/lib/table-widget.models'; } from '@home/components/widget/lib/table-widget.models';
import { ConnectedPosition, Overlay, OverlayConfig, OverlayRef } from '@angular/cdk/overlay'; import { Overlay, OverlayConfig, OverlayRef } from '@angular/cdk/overlay';
import { SubscriptionEntityInfo } from '@core/api/widget-api.models'; import { SubscriptionEntityInfo } from '@core/api/widget-api.models';
import { DatePipe } from '@angular/common'; import { DatePipe } from '@angular/common';
import { coerceBooleanProperty } from '@angular/cdk/coercion'; import { coerceBooleanProperty } from '@angular/cdk/coercion';
@ -93,6 +93,7 @@ import {
} from '@home/components/widget/lib/display-columns-panel.component'; } from '@home/components/widget/lib/display-columns-panel.component';
import { ComponentPortal } from '@angular/cdk/portal'; import { ComponentPortal } from '@angular/cdk/portal';
import { FormBuilder } from '@angular/forms'; import { FormBuilder } from '@angular/forms';
import { DEFAULT_OVERLAY_POSITIONS } from '@shared/models/overlay.models';
export interface TimeseriesTableWidgetSettings extends TableWidgetSettings { export interface TimeseriesTableWidgetSettings extends TableWidgetSettings {
showTimestamp: boolean; showTimestamp: boolean;
@ -411,23 +412,23 @@ export class TimeseriesTableWidgetComponent extends PageComponent implements OnI
$event.stopPropagation(); $event.stopPropagation();
} }
if (this.sources.length) { if (this.sources.length) {
const target = $event.target || $event.currentTarget; const target = $event.target || $event.srcElement || $event.currentTarget;
const config = new OverlayConfig(); const config = new OverlayConfig({
config.backdropClass = 'cdk-overlay-transparent-backdrop'; panelClass: 'tb-panel-container',
config.hasBackdrop = true; backdropClass: 'cdk-overlay-transparent-backdrop',
const connectedPosition: ConnectedPosition = { hasBackdrop: true,
originX: 'end', height: 'fit-content',
originY: 'bottom', maxHeight: '75vh'
overlayX: 'end', });
overlayY: 'top' config.positionStrategy = this.overlay.position()
}; .flexibleConnectedTo(target as HTMLElement)
config.positionStrategy = this.overlay.position().flexibleConnectedTo(target as HTMLElement) .withPositions(DEFAULT_OVERLAY_POSITIONS);
.withPositions([connectedPosition]);
const overlayRef = this.overlay.create(config); const overlayRef = this.overlay.create(config);
overlayRef.backdropClick().subscribe(() => { overlayRef.backdropClick().subscribe(() => {
overlayRef.dispose(); overlayRef.dispose();
}); });
const source = this.sources[this.sourceIndex]; const source = this.sources[this.sourceIndex];
this.prepareDisplayedColumn(); this.prepareDisplayedColumn();
@ -450,8 +451,16 @@ export class TimeseriesTableWidgetComponent extends PageComponent implements OnI
]; ];
const injector = Injector.create({parent: this.viewContainerRef.injector, providers}); const injector = Injector.create({parent: this.viewContainerRef.injector, providers});
overlayRef.attach(new ComponentPortal(DisplayColumnsPanelComponent, const componentRef = overlayRef.attach(new ComponentPortal(DisplayColumnsPanelComponent,
this.viewContainerRef, injector)); this.viewContainerRef, injector));
const resizeWindows$ = fromEvent(window, 'resize').subscribe(() => {
overlayRef.updatePosition();
});
componentRef.onDestroy(() => {
resizeWindows$.unsubscribe();
});
this.ctx.detectChanges(); this.ctx.detectChanges();
} }
} }

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

@ -65,6 +65,7 @@ import { SignalStrengthWidgetComponent } from '@home/components/widget/lib/indic
import { ValueChartCardWidgetComponent } from '@home/components/widget/lib/cards/value-chart-card-widget.component'; import { ValueChartCardWidgetComponent } from '@home/components/widget/lib/cards/value-chart-card-widget.component';
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';
@NgModule({ @NgModule({
declarations: declarations:
@ -104,7 +105,8 @@ import { LiquidLevelWidgetComponent } from '@home/components/widget/lib/indicato
SignalStrengthWidgetComponent, SignalStrengthWidgetComponent,
ValueChartCardWidgetComponent, ValueChartCardWidgetComponent,
ProgressBarWidgetComponent, ProgressBarWidgetComponent,
LiquidLevelWidgetComponent LiquidLevelWidgetComponent,
DoughnutWidgetComponent
], ],
imports: [ imports: [
CommonModule, CommonModule,
@ -148,7 +150,8 @@ import { LiquidLevelWidgetComponent } from '@home/components/widget/lib/indicato
SignalStrengthWidgetComponent, SignalStrengthWidgetComponent,
ValueChartCardWidgetComponent, ValueChartCardWidgetComponent,
ProgressBarWidgetComponent, ProgressBarWidgetComponent,
LiquidLevelWidgetComponent LiquidLevelWidgetComponent,
DoughnutWidgetComponent
], ],
providers: [ providers: [
{provide: WIDGET_COMPONENTS_MODULE_TOKEN, useValue: WidgetComponentsModule } {provide: WIDGET_COMPONENTS_MODULE_TOKEN, useValue: WidgetComponentsModule }

26
ui-ngx/src/app/modules/home/models/datasource/scroll-grid-datasource.ts

@ -20,6 +20,7 @@ import { BehaviorSubject, Observable, of, Subscription } from 'rxjs';
import { catchError } from 'rxjs/operators'; import { catchError } from 'rxjs/operators';
import { emptyPageData, PageData } from '@shared/models/page/page-data'; import { emptyPageData, PageData } from '@shared/models/page/page-data';
import { BreakpointObserver } from '@angular/cdk/layout'; import { BreakpointObserver } from '@angular/cdk/layout';
import { resolveBreakpoint } from '@shared/models/constants';
export type GridEntitiesFetchFunction<T, F> = (pageSize: number, page: number, filter: F) => Observable<PageData<T>>; export type GridEntitiesFetchFunction<T, F> = (pageSize: number, page: number, filter: F) => Observable<PageData<T>>;
@ -48,7 +49,7 @@ export class ScrollGridDatasource<T, F> extends DataSource<(T | GridCellType)[]>
private _subscription: Subscription; private _subscription: Subscription;
constructor(private breakpointObserver: BreakpointObserver, constructor(private breakpointObserver: BreakpointObserver,
private columns: ScrollGridColumns, private columns: ScrollGridColumns | number,
private fetchFunction: GridEntitiesFetchFunction<T, F>, private fetchFunction: GridEntitiesFetchFunction<T, F>,
private filter: F) { private filter: F) {
super(); super();
@ -58,9 +59,9 @@ export class ScrollGridDatasource<T, F> extends DataSource<(T | GridCellType)[]>
this._viewport = (collectionViewer as any)._viewport; this._viewport = (collectionViewer as any)._viewport;
this._init(); this._init();
if (this.columns.breakpoints) { if (typeof this.columns === 'object' && this.columns.breakpoints) {
const breakpoints = Object.keys(this.columns.breakpoints); const breakpoints = Object.keys(this.columns.breakpoints);
this._subscription.add(this.breakpointObserver.observe(breakpoints).subscribe( this._subscription.add(this.breakpointObserver.observe(breakpoints.map(breakpoint => resolveBreakpoint(breakpoint))).subscribe(
() => { () => {
this._columnsChanged(this._detectColumns()); this._columnsChanged(this._detectColumns());
} }
@ -119,16 +120,21 @@ export class ScrollGridDatasource<T, F> extends DataSource<(T | GridCellType)[]>
} }
private _detectColumns(): number { private _detectColumns(): number {
let columns = this.columns.columns; if (typeof this.columns !== 'object') {
if (this.columns.breakpoints) { return this.columns;
for (const breakpont of Object.keys(this.columns.breakpoints)) { } else {
if (this.breakpointObserver.isMatched(breakpont)) { let columns = this.columns.columns;
columns = this.columns.breakpoints[breakpont]; if (this.columns.breakpoints) {
break; for (const breakpoint of Object.keys(this.columns.breakpoints)) {
const breakpointValue = resolveBreakpoint(breakpoint);
if (this.breakpointObserver.isMatched(breakpointValue)) {
columns = this.columns.breakpoints[breakpoint];
break;
}
} }
} }
return columns;
} }
return columns;
} }
private _init() { private _init() {

8
ui-ngx/src/app/shared/components/entity/entity-subtype-list.component.html

@ -24,7 +24,7 @@
*ngFor="let entitySubtype of entitySubtypeList" *ngFor="let entitySubtype of entitySubtypeList"
[removable]="!disabled" [removable]="!disabled"
(removed)="remove(entitySubtype)"> (removed)="remove(entitySubtype)">
{{entitySubtype}} {{customTranslate(entitySubtype)}}
<mat-icon matChipRemove *ngIf="!disabled">close</mat-icon> <mat-icon matChipRemove *ngIf="!disabled">close</mat-icon>
</mat-chip-row> </mat-chip-row>
<input matInput type="text" placeholder="{{ !disabled ? ((!entitySubtypeList || !entitySubtypeList.length) ? placeholder : secondaryPlaceholder) : '' }}" <input matInput type="text" placeholder="{{ !disabled ? ((!entitySubtypeList || !entitySubtypeList.length) ? placeholder : secondaryPlaceholder) : '' }}"
@ -33,6 +33,7 @@
(focusin)="onFocus()" (focusin)="onFocus()"
formControlName="entitySubtype" formControlName="entitySubtype"
matAutocompleteOrigin matAutocompleteOrigin
matChipInputAddOnBlur
#origin="matAutocompleteOrigin" #origin="matAutocompleteOrigin"
[matAutocompleteConnectedTo]="origin" [matAutocompleteConnectedTo]="origin"
[matAutocomplete]="entitySubtypeAutocomplete" [matAutocomplete]="entitySubtypeAutocomplete"
@ -47,11 +48,6 @@
<mat-option *ngFor="let entitySubtype of filteredEntitySubtypeList | async" [value]="entitySubtype"> <mat-option *ngFor="let entitySubtype of filteredEntitySubtypeList | async" [value]="entitySubtype">
<span [innerHTML]="entitySubtype | highlight:searchText"></span> <span [innerHTML]="entitySubtype | highlight:searchText"></span>
</mat-option> </mat-option>
<mat-option *ngIf="!(filteredEntitySubtypeList | async)?.length" [value]="null">
<span>
{{ translate.get(noSubtypesMathingText, {entitySubtype: searchText}) | async }}
</span>
</mat-option>
</mat-autocomplete> </mat-autocomplete>
<div matSuffix> <div matSuffix>
<ng-content select="[matSuffix]"></ng-content> <ng-content select="[matSuffix]"></ng-content>

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

@ -33,6 +33,7 @@ import { FloatLabelType, MatFormFieldAppearance, SubscriptSizing } from '@angula
import { coerceArray, coerceBoolean } from '@shared/decorators/coercion'; import { coerceArray, coerceBoolean } from '@shared/decorators/coercion';
import { PageLink } from '@shared/models/page/page-link'; import { PageLink } from '@shared/models/page/page-link';
import { PageData } from '@shared/models/page/page-data'; import { PageData } from '@shared/models/page/page-data';
import { UtilsService } from '@core/services/utils.service';
@Component({ @Component({
selector: 'tb-entity-subtype-list', selector: 'tb-entity-subtype-list',
@ -129,6 +130,7 @@ export class EntitySubTypeListComponent implements ControlValueAccessor, OnInit,
private edgeService: EdgeService, private edgeService: EdgeService,
private entityViewService: EntityViewService, private entityViewService: EntityViewService,
private alarmService: AlarmService, private alarmService: AlarmService,
private utils: UtilsService,
private fb: FormBuilder) { private fb: FormBuilder) {
this.entitySubtypeListFormGroup = this.fb.group({ this.entitySubtypeListFormGroup = this.fb.group({
entitySubtypeList: [this.entitySubtypeList, this.required ? [Validators.required] : []], entitySubtypeList: [this.entitySubtypeList, this.required ? [Validators.required] : []],
@ -298,7 +300,7 @@ export class EntitySubTypeListComponent implements ControlValueAccessor, OnInit,
} else { } else {
result = subTypes.filter(subType => searchText ? subType.toUpperCase().startsWith(searchText.toUpperCase()) : true); result = subTypes.filter(subType => searchText ? subType.toUpperCase().startsWith(searchText.toUpperCase()) : true);
} }
if (!result.length) { if (!result.length && searchText.length) {
result = [searchText]; result = [searchText];
} }
return result; return result;
@ -372,4 +374,8 @@ export class EntitySubTypeListComponent implements ControlValueAccessor, OnInit,
}, 0); }, 0);
} }
customTranslate(entity: string) {
return this.utils.customTranslation(entity, entity);
}
} }

87
ui-ngx/src/app/shared/components/markdown-editor.component.ts

@ -14,11 +14,23 @@
/// limitations under the License. /// limitations under the License.
/// ///
import { Component, ElementRef, forwardRef, Input, OnDestroy, OnInit, ViewChild } from '@angular/core'; import {
ChangeDetectorRef,
Component,
ElementRef,
forwardRef,
Input,
OnDestroy,
OnInit,
ViewChild,
ViewEncapsulation
} from '@angular/core';
import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms'; import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms';
import { Ace } from 'ace-builds'; import { Ace } from 'ace-builds';
import { coerceBooleanProperty } from '@angular/cdk/coercion';
import { getAce } from '@shared/models/ace/ace.models'; import { getAce } from '@shared/models/ace/ace.models';
import { ResizeObserver } from '@juggle/resize-observer';
import { coerceBoolean } from '@shared/decorators/coercion';
import { CancelAnimationFrame, RafService } from '@core/services/raf.service';
@Component({ @Component({
selector: 'tb-markdown-editor', selector: 'tb-markdown-editor',
@ -30,7 +42,8 @@ import { getAce } from '@shared/models/ace/ace.models';
useExisting: forwardRef(() => MarkdownEditorComponent), useExisting: forwardRef(() => MarkdownEditorComponent),
multi: true multi: true
} }
] ],
encapsulation: ViewEncapsulation.None
}) })
export class MarkdownEditorComponent implements OnInit, ControlValueAccessor, OnDestroy { export class MarkdownEditorComponent implements OnInit, ControlValueAccessor, OnDestroy {
@ -42,11 +55,13 @@ export class MarkdownEditorComponent implements OnInit, ControlValueAccessor, On
@Input() helpId: string; @Input() helpId: string;
@Input()
@coerceBoolean()
required: boolean;
@ViewChild('markdownEditor', {static: true}) @ViewChild('markdownEditor', {static: true})
markdownEditorElmRef: ElementRef; markdownEditorElmRef: ElementRef;
private markdownEditor: Ace.Editor;
editorMode = true; editorMode = true;
fullscreen = false; fullscreen = false;
@ -54,22 +69,15 @@ export class MarkdownEditorComponent implements OnInit, ControlValueAccessor, On
markdownValue: string; markdownValue: string;
renderValue: string; renderValue: string;
ignoreChange = false; private markdownEditor: Ace.Editor;
private ignoreChange = false;
private propagateChange = null;
private requiredValue: boolean;
get required(): boolean { private editorResize$: ResizeObserver;
return this.requiredValue; private editorsResizeCaf: CancelAnimationFrame;
} private propagateChange: (value: any) => void = () => {};
@Input() constructor(private cd: ChangeDetectorRef,
set required(value: boolean) { private raf: RafService) {
this.requiredValue = coerceBooleanProperty(value);
}
constructor() {
} }
ngOnInit(): void { ngOnInit(): void {
@ -100,6 +108,10 @@ export class MarkdownEditorComponent implements OnInit, ControlValueAccessor, On
this.updateView(); this.updateView();
} }
}); });
this.editorResize$ = new ResizeObserver(() => {
this.onAceEditorResize();
});
this.editorResize$.observe(editorElement);
} }
); );
@ -107,6 +119,13 @@ export class MarkdownEditorComponent implements OnInit, ControlValueAccessor, On
} }
ngOnDestroy(): void { ngOnDestroy(): void {
if (this.editorResize$) {
this.editorResize$.disconnect();
}
if (this.editorsResizeCaf) {
this.editorsResizeCaf();
this.editorsResizeCaf = null;
}
if (this.markdownEditor) { if (this.markdownEditor) {
this.markdownEditor.destroy(); this.markdownEditor.destroy();
} }
@ -134,15 +153,6 @@ export class MarkdownEditorComponent implements OnInit, ControlValueAccessor, On
} }
} }
updateView() {
const editorValue = this.markdownEditor.getValue();
if (this.markdownValue !== editorValue) {
this.markdownValue = editorValue;
this.renderValue = this.markdownValue ? this.markdownValue : ' ';
this.propagateChange(this.markdownValue);
}
}
onFullscreen() { onFullscreen() {
if (this.markdownEditor) { if (this.markdownEditor) {
setTimeout(() => { setTimeout(() => {
@ -159,4 +169,25 @@ export class MarkdownEditorComponent implements OnInit, ControlValueAccessor, On
}, 0); }, 0);
} }
} }
private updateView() {
const editorValue = this.markdownEditor.getValue();
if (this.markdownValue !== editorValue) {
this.markdownValue = editorValue;
this.renderValue = this.markdownValue ? this.markdownValue : ' ';
this.propagateChange(this.markdownValue);
this.cd.markForCheck();
}
}
private onAceEditorResize() {
if (this.editorsResizeCaf) {
this.editorsResizeCaf();
this.editorsResizeCaf = null;
}
this.editorsResizeCaf = this.raf.raf(() => {
this.markdownEditor.resize();
this.markdownEditor.renderer.updateFull();
});
}
} }

36
ui-ngx/src/app/shared/components/tb-error.component.ts

@ -14,17 +14,17 @@
/// limitations under the License. /// limitations under the License.
/// ///
import { Component, Input } from '@angular/core'; import { ChangeDetectorRef, Component, Input } from '@angular/core';
import { animate, state, style, transition, trigger } from '@angular/animations'; import { animate, state, style, transition, trigger } from '@angular/animations';
import { coerceBoolean } from '@shared/decorators/coercion'; import { coerceBoolean } from '@shared/decorators/coercion';
@Component({ @Component({
selector: 'tb-error', selector: 'tb-error',
template: ` template: `
<div [@animation]="state" [ngStyle]="{marginTop: noMargin ? '0' : '0.5rem', fontSize: '.75rem'}"> <div [@animation]="state" [ngStyle]="{marginTop: noMargin ? '0' : '0.5rem', fontSize: '.75rem'}">
<mat-error > <mat-error>
{{message}} {{message}}
</mat-error> </mat-error>
</div> </div>
`, `,
styles: [` styles: [`
@ -36,21 +36,23 @@ import { coerceBoolean } from '@shared/decorators/coercion';
trigger('animation', [ trigger('animation', [
state('show', style({ state('show', style({
opacity: 1, opacity: 1,
transform: 'translateY(0)'
})), })),
state('hide', style({ state('hide', style({
opacity: 0, opacity: 0,
transform: 'translateY(-1rem)' transform: 'translateY(-1rem)'
})), })),
transition('show => hide', animate('200ms ease-out')), transition('* <=> *', animate('200ms ease-out'))
transition('* => show', animate('200ms ease-in'))
]), ]),
] ]
}) })
export class TbErrorComponent { export class TbErrorComponent {
errorValue: any; errorValue: string;
state: any; state = 'hide';
message; message: string;
constructor(private cd: ChangeDetectorRef) {
}
@Input() @Input()
@coerceBoolean() @coerceBoolean()
@ -58,15 +60,13 @@ export class TbErrorComponent {
@Input() @Input()
set error(value) { set error(value) {
if (value && !this.message) { if (this.errorValue !== value) {
this.message = value;
this.state = 'hide';
setTimeout(() => {
this.state = 'show';
});
} else {
this.errorValue = value; this.errorValue = value;
if (value) {
this.message = value;
}
this.state = value ? 'show' : 'hide'; this.state = value ? 'show' : 'hide';
this.cd.markForCheck();
} }
} }
} }

4
ui-ngx/src/app/shared/models/alarm.models.ts

@ -102,6 +102,8 @@ export interface Alarm extends BaseData<AlarmId> {
originator: EntityId; originator: EntityId;
severity: AlarmSeverity; severity: AlarmSeverity;
status: AlarmStatus; status: AlarmStatus;
acknowledged: boolean;
cleared: boolean;
startTs: number; startTs: number;
endTs: number; endTs: number;
ackTs: number; ackTs: number;
@ -181,6 +183,8 @@ export const simulatedAlarm: AlarmInfo = {
type: 'TEMPERATURE', type: 'TEMPERATURE',
severity: AlarmSeverity.MAJOR, severity: AlarmSeverity.MAJOR,
status: AlarmStatus.ACTIVE_UNACK, status: AlarmStatus.ACTIVE_UNACK,
acknowledged: false,
cleared: false,
details: { details: {
message: 'Temperature is high!' message: 'Temperature is high!'
}, },

7
ui-ngx/src/app/shared/models/constants.ts

@ -71,6 +71,13 @@ export const MediaBreakpoints = {
'md-lg': 'screen and (min-width: 960px) and (max-width: 1819px)' 'md-lg': 'screen and (min-width: 960px) and (max-width: 1819px)'
}; };
export const resolveBreakpoint = (breakpoint: string): string => {
if (MediaBreakpoints[breakpoint]) {
return MediaBreakpoints[breakpoint];
}
return breakpoint;
};
export const helpBaseUrl = 'https://thingsboard.io'; export const helpBaseUrl = 'https://thingsboard.io';
export const HelpLinks = { export const HelpLinks = {

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

@ -409,8 +409,8 @@ export interface Datasource {
export const datasourcesHasAggregation = (datasources?: Array<Datasource>): boolean => { export const datasourcesHasAggregation = (datasources?: Array<Datasource>): boolean => {
if (datasources) { if (datasources) {
const foundDatasource = datasources.find(datasource => { const foundDatasource = datasources.find(datasource => {
const found = datasource.dataKeys && datasource.dataKeys.find(key => key.type === DataKeyType.timeseries && const found = datasource.dataKeys && datasource.dataKeys.find(key => key?.type === DataKeyType.timeseries &&
key.aggregationType && key.aggregationType !== AggregationType.NONE); key?.aggregationType && key.aggregationType !== AggregationType.NONE);
return !!found; return !!found;
}); });
if (foundDatasource) { if (foundDatasource) {

4
ui-ngx/src/assets/dashboard/customer_user_home_page.json

@ -461,7 +461,7 @@
"keyFilters": [ "keyFilters": [
{ {
"key": { "key": {
"type": "ATTRIBUTE", "type": "SERVER_ATTRIBUTE",
"key": "active" "key": "active"
}, },
"valueType": "BOOLEAN", "valueType": "BOOLEAN",
@ -493,7 +493,7 @@
"keyFilters": [ "keyFilters": [
{ {
"key": { "key": {
"type": "ATTRIBUTE", "type": "SERVER_ATTRIBUTE",
"key": "active" "key": "active"
}, },
"valueType": "BOOLEAN", "valueType": "BOOLEAN",

4
ui-ngx/src/assets/dashboard/tenant_admin_home_page.json

@ -943,7 +943,7 @@
"keyFilters": [ "keyFilters": [
{ {
"key": { "key": {
"type": "ATTRIBUTE", "type": "SERVER_ATTRIBUTE",
"key": "active" "key": "active"
}, },
"valueType": "BOOLEAN", "valueType": "BOOLEAN",
@ -975,7 +975,7 @@
"keyFilters": [ "keyFilters": [
{ {
"key": { "key": {
"type": "ATTRIBUTE", "type": "SERVER_ATTRIBUTE",
"key": "active" "key": "active"
}, },
"valueType": "BOOLEAN", "valueType": "BOOLEAN",

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

@ -5338,6 +5338,27 @@
"Ok": "Ok" "Ok": "Ok"
} }
}, },
"doughnut": {
"total": "Total",
"layout": "Layout",
"layout-default": "Default",
"layout-with-total": "With total",
"auto-scale": "Auto scale",
"central-total-value": "Central total value",
"legend-position-top": "Top",
"legend-position-bottom": "Bottom",
"legend-position-left": "Left",
"legend-position-right": "Right",
"legend-label": "Label",
"legend-value": "Value",
"tooltip": "Tooltip",
"tooltip-value": "Value",
"tooltip-value-type-absolute": "Absolute",
"tooltip-value-type-percentage": "Percentage",
"tooltip-background-color": "Background color",
"tooltip-background-blur": "Background blur",
"doughnut-card-style": "Doughnut card style"
},
"entities-hierarchy": { "entities-hierarchy": {
"hierarchy-data-settings": "Hierarchy data settings", "hierarchy-data-settings": "Hierarchy data settings",
"relations-query-function": "Node relations query function", "relations-query-function": "Node relations query function",
@ -6293,6 +6314,7 @@
"pagination": "Pagination", "pagination": "Pagination",
"rows": "Rows", "rows": "Rows",
"timeseries-column-error": "At least one timeseries column should be specified", "timeseries-column-error": "At least one timeseries column should be specified",
"alarm-column-error": "At least one alarm column should be specified",
"table-tabs": "Table tabs", "table-tabs": "Table tabs",
"show-cell-actions-menu-mobile": "Show cell actions dropdown menu in mobile mode" "show-cell-actions-menu-mobile": "Show cell actions dropdown menu in mobile mode"
}, },

29
ui-ngx/src/assets/widget/doughnut/default-layout.svg

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 45 KiB

29
ui-ngx/src/assets/widget/doughnut/horizontal-default-layout.svg

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 46 KiB

28
ui-ngx/src/assets/widget/doughnut/horizontal-with-total-layout.svg

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 46 KiB

28
ui-ngx/src/assets/widget/doughnut/with-total-layout.svg

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 45 KiB

2
ui-ngx/src/styles.scss

@ -268,7 +268,7 @@ pre.tb-highlight {
letter-spacing: normal; letter-spacing: normal;
} }
.tb-timewindow-panel, .tb-legend-config-panel, .tb-filter-panel { .tb-timewindow-panel, .tb-legend-config-panel, .tb-filter-panel, .tb-panel-container {
overflow: hidden; overflow: hidden;
background: #fff; background: #fff;
border-radius: 4px; border-radius: 4px;

20
ui-ngx/yarn.lock

@ -5360,6 +5360,14 @@ ecc-jsbn@~0.1.1:
jsbn "~0.1.0" jsbn "~0.1.0"
safer-buffer "^2.1.0" safer-buffer "^2.1.0"
echarts@^5.4.3:
version "5.4.3"
resolved "https://registry.yarnpkg.com/echarts/-/echarts-5.4.3.tgz#f5522ef24419164903eedcfd2b506c6fc91fb20c"
integrity sha512-mYKxLxhzy6zyTi/FaEbJMOZU1ULGEQHaeIeuMR5L+JnJTpz+YR03mnnpBhbR4+UYJAgiXgpyTVLffPAjOTLkZA==
dependencies:
tslib "2.3.0"
zrender "5.4.4"
editorconfig@^0.15.3: editorconfig@^0.15.3:
version "0.15.3" version "0.15.3"
resolved "https://registry.yarnpkg.com/editorconfig/-/editorconfig-0.15.3.tgz#bef84c4e75fb8dcb0ce5cee8efd51c15999befc5" resolved "https://registry.yarnpkg.com/editorconfig/-/editorconfig-0.15.3.tgz#bef84c4e75fb8dcb0ce5cee8efd51c15999befc5"
@ -10329,6 +10337,11 @@ tsconfig-paths@^4.1.0:
minimist "^1.2.6" minimist "^1.2.6"
strip-bom "^3.0.0" strip-bom "^3.0.0"
tslib@2.3.0:
version "2.3.0"
resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.3.0.tgz#803b8cdab3e12ba581a4ca41c8839bbb0dacb09e"
integrity sha512-N82ooyxVNm6h1riLCoyS9e3fuJ3AMG2zIZs2Gd1ATcSFjSA23Q0fzjjZeh0jbJvWVDZ0cJT8yaNNaaXHzueNjg==
tslib@2.5.0, tslib@^2.0.0, tslib@^2.0.3, tslib@^2.1.0, tslib@^2.2.0, tslib@^2.3.0, tslib@^2.3.1, tslib@^2.4.0, tslib@^2.4.1, tslib@^2.5.0: tslib@2.5.0, tslib@^2.0.0, tslib@^2.0.3, tslib@^2.1.0, tslib@^2.2.0, tslib@^2.3.0, tslib@^2.3.1, tslib@^2.4.0, tslib@^2.4.1, tslib@^2.5.0:
version "2.5.0" version "2.5.0"
resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.5.0.tgz#42bfed86f5787aeb41d031866c8f402429e0fddf" resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.5.0.tgz#42bfed86f5787aeb41d031866c8f402429e0fddf"
@ -11075,3 +11088,10 @@ zone.js@~0.13.0:
integrity sha512-7m3hNNyswsdoDobCkYNAy5WiUulkMd3+fWaGT9ij6iq3Zr/IwJo4RMCYPSDjT+r7tnPErmY9sZpKhWQ8S5k6XQ== integrity sha512-7m3hNNyswsdoDobCkYNAy5WiUulkMd3+fWaGT9ij6iq3Zr/IwJo4RMCYPSDjT+r7tnPErmY9sZpKhWQ8S5k6XQ==
dependencies: dependencies:
tslib "^2.3.0" tslib "^2.3.0"
zrender@5.4.4:
version "5.4.4"
resolved "https://registry.yarnpkg.com/zrender/-/zrender-5.4.4.tgz#8854f1d95ecc82cf8912f5a11f86657cb8c9e261"
integrity sha512-0VxCNJ7AGOMCWeHVyTrGzUgrK4asT4ml9PEkeGirAkKNYXYzoPJCLvmyfdoOXcjTHPs10OZVMfD1Rwg16AZyYw==
dependencies:
tslib "2.3.0"

Loading…
Cancel
Save