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. 45
      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. 6
      ui-ngx/src/assets/dashboard/customer_user_home_page.json
  85. 6
      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.pie",
"charts.pie_chart_js",
"charts.doughnut_chart_js",
"doughnut",
"horizontal_doughnut",
"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_TEXT_SEARCH_DESCRIPTION;
import static org.thingsboard.server.controller.ControllerConstants.UUID_WIKI_LINK;
import static org.thingsboard.server.dao.entity.BaseEntityService.NULL_CUSTOMER_ID;
@RequiredArgsConstructor
@RestController
@ -439,32 +440,28 @@ public class UserController extends BaseController {
@RequestParam(required = false) String sortProperty,
@ApiParam(value = SORT_ORDER_DESCRIPTION, allowableValues = SORT_ORDER_ALLOWABLE_VALUES)
@RequestParam(required = false) String sortOrder) throws ThingsboardException {
try {
checkParameter("alarmId", strAlarmId);
AlarmId alarmEntityId = new AlarmId(toUUID(strAlarmId));
Alarm alarm = checkAlarmId(alarmEntityId, Operation.READ);
SecurityUser currentUser = getCurrentUser();
TenantId tenantId = currentUser.getTenantId();
CustomerId originatorCustomerId = entityService.fetchEntityCustomerId(tenantId, alarm.getOriginator()).get();
PageLink pageLink = createPageLink(pageSize, page, textSearch, sortProperty, sortOrder);
PageData<User> pageData;
if (Authority.TENANT_ADMIN.equals(currentUser.getAuthority())) {
if (alarm.getCustomerId() == null) {
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);
}
checkParameter("alarmId", strAlarmId);
AlarmId alarmEntityId = new AlarmId(toUUID(strAlarmId));
Alarm alarm = checkAlarmId(alarmEntityId, Operation.READ);
SecurityUser currentUser = getCurrentUser();
TenantId tenantId = currentUser.getTenantId();
CustomerId originatorCustomerId = entityService.fetchEntityCustomerId(tenantId, alarm.getOriginator()).orElse(NULL_CUSTOMER_ID);
PageLink pageLink = createPageLink(pageSize, page, textSearch, sortProperty, sortOrder);
PageData<User> pageData;
if (Authority.TENANT_ADMIN.equals(currentUser.getAuthority())) {
if (alarm.getCustomerId() == null) {
pageData = userService.findTenantAdmins(tenantId, pageLink);
} 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()));
} catch (Exception e) {
throw handleException(e);
} else {
pageData = userService.findCustomerUsers(tenantId, alarm.getCustomerId(), pageLink);
}
return pageData.mapData(user -> new UserEmailInfo(user.getId(), user.getEmail(), user.getFirstName(), user.getLastName()));
}
@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) {
log.trace("[{}][{}] Handling alarm update {}: {}", tenantId, entityId, alarm, deleted);
for (Map.Entry<String, TbSubscriptionsInfo> entry : subInfo.getSubs().entrySet()) {
if (entry.getValue().notifications) {
if (entry.getValue().alarms) {
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();
result = handlerExecutor.submit(() -> validateOrCreateDeviceX509Certificate(certChain));
} else if (transportApiRequestMsg.hasGetOrCreateDeviceRequestMsg()) {
result = handle(transportApiRequestMsg.getGetOrCreateDeviceRequestMsg());
result = handlerExecutor.submit(() -> handle(transportApiRequestMsg.getGetOrCreateDeviceRequestMsg()));
} else if (transportApiRequestMsg.hasEntityProfileRequestMsg()) {
result = handle(transportApiRequestMsg.getEntityProfileRequestMsg());
} else if (transportApiRequestMsg.hasLwM2MRequestMsg()) {
@ -223,7 +223,6 @@ public class DefaultTransportApiService implements TransportApiService {
}
private TransportApiResponseMsg validateCredentials(String credentialsId, DeviceCredentialsType credentialsType) {
//TODO: Make async and enable caching
DeviceCredentials credentials = deviceCredentialsService.findDeviceCredentialsByCredentialsId(credentialsId);
if (credentials != null && credentials.getCredentialsType() == credentialsType) {
return getDeviceInfo(credentials);
@ -336,76 +335,74 @@ public class DefaultTransportApiService implements TransportApiService {
return VALID;
}
private ListenableFuture<TransportApiResponseMsg> handle(GetOrCreateDeviceFromGatewayRequestMsg requestMsg) {
private TransportApiResponseMsg handle(GetOrCreateDeviceFromGatewayRequestMsg requestMsg) {
DeviceId gatewayId = new DeviceId(new UUID(requestMsg.getGatewayIdMSB(), requestMsg.getGatewayIdLSB()));
ListenableFuture<Device> gatewayFuture = deviceService.findDeviceByIdAsync(TenantId.SYS_TENANT_ID, gatewayId);
return Futures.transform(gatewayFuture, gateway -> {
Lock deviceCreationLock = deviceCreationLocks.computeIfAbsent(requestMsg.getDeviceName(), id -> new ReentrantLock());
deviceCreationLock.lock();
try {
Device device = deviceService.findDeviceByTenantIdAndName(gateway.getTenantId(), requestMsg.getDeviceName());
if (device == null) {
TenantId tenantId = gateway.getTenantId();
device = new Device();
device.setTenantId(tenantId);
device.setName(requestMsg.getDeviceName());
device.setType(requestMsg.getDeviceType());
device.setCustomerId(gateway.getCustomerId());
DeviceProfile deviceProfile = deviceProfileCache.findOrCreateDeviceProfile(gateway.getTenantId(), requestMsg.getDeviceType());
device.setDeviceProfileId(deviceProfile.getId());
ObjectNode additionalInfo = JacksonUtil.newObjectNode();
additionalInfo.put(DataConstants.LAST_CONNECTED_GATEWAY, gatewayId.toString());
device.setAdditionalInfo(additionalInfo);
Device savedDevice = deviceService.saveDevice(device);
tbClusterService.onDeviceUpdated(savedDevice, null);
device = savedDevice;
relationService.saveRelation(TenantId.SYS_TENANT_ID, new EntityRelation(gateway.getId(), device.getId(), "Created"));
TbMsgMetaData metaData = new TbMsgMetaData();
CustomerId customerId = gateway.getCustomerId();
if (customerId != null && !customerId.isNullUid()) {
metaData.putValue("customerId", customerId.toString());
}
metaData.putValue("gatewayId", gatewayId.toString());
Device gateway = deviceService.findDeviceById(TenantId.SYS_TENANT_ID, gatewayId);
Lock deviceCreationLock = deviceCreationLocks.computeIfAbsent(requestMsg.getDeviceName(), id -> new ReentrantLock());
deviceCreationLock.lock();
try {
Device device = deviceService.findDeviceByTenantIdAndName(gateway.getTenantId(), requestMsg.getDeviceName());
if (device == null) {
TenantId tenantId = gateway.getTenantId();
device = new Device();
device.setTenantId(tenantId);
device.setName(requestMsg.getDeviceName());
device.setType(requestMsg.getDeviceType());
device.setCustomerId(gateway.getCustomerId());
DeviceProfile deviceProfile = deviceProfileCache.findOrCreateDeviceProfile(gateway.getTenantId(), requestMsg.getDeviceType());
device.setDeviceProfileId(deviceProfile.getId());
ObjectNode additionalInfo = JacksonUtil.newObjectNode();
additionalInfo.put(DataConstants.LAST_CONNECTED_GATEWAY, gatewayId.toString());
device.setAdditionalInfo(additionalInfo);
Device savedDevice = deviceService.saveDevice(device);
tbClusterService.onDeviceUpdated(savedDevice, null);
device = savedDevice;
relationService.saveRelation(TenantId.SYS_TENANT_ID, new EntityRelation(gateway.getId(), device.getId(), "Created"));
TbMsgMetaData metaData = new TbMsgMetaData();
CustomerId customerId = gateway.getCustomerId();
if (customerId != null && !customerId.isNullUid()) {
metaData.putValue("customerId", customerId.toString());
}
metaData.putValue("gatewayId", gatewayId.toString());
DeviceId deviceId = device.getId();
JsonNode entityNode = JacksonUtil.valueToTree(device);
TbMsg tbMsg = TbMsg.newMsg(TbMsgType.ENTITY_CREATED, deviceId, customerId, metaData, TbMsgDataType.JSON, JacksonUtil.toString(entityNode));
tbClusterService.pushMsgToRuleEngine(tenantId, deviceId, tbMsg, null);
} else {
JsonNode deviceAdditionalInfo = device.getAdditionalInfo();
if (deviceAdditionalInfo == null) {
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);
}
DeviceId deviceId = device.getId();
JsonNode entityNode = JacksonUtil.valueToTree(device);
TbMsg tbMsg = TbMsg.newMsg(TbMsgType.ENTITY_CREATED, deviceId, customerId, metaData, TbMsgDataType.JSON, JacksonUtil.toString(entityNode));
tbClusterService.pushMsgToRuleEngine(tenantId, deviceId, tbMsg, null);
} else {
JsonNode deviceAdditionalInfo = device.getAdditionalInfo();
if (deviceAdditionalInfo == null) {
deviceAdditionalInfo = JacksonUtil.newObjectNode();
}
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());
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);
}
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) {

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

@ -121,18 +121,47 @@ public class DeviceConnectivityControllerTest extends AbstractControllerTest {
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);
JsonNode connectivity = adminSettings.getJsonValue();
((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);
adminSettings.setJsonValue(config);
doPost("/api/admin/settings", adminSettings).andExpect(status().isOk());
Tenant tenant = new Tenant();
tenant.setTitle("My tenant");
@ -212,19 +241,19 @@ public class DeviceConnectivityControllerTest extends AbstractControllerTest {
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 " +
"-u %s -m \"{temperature:25}\"",
"-u \"%s\" -m \"{temperature:25}\"",
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(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);
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()));
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 && " +
"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()));
JsonNode linuxCoapCommands = commands.get(COAP);
@ -251,18 +280,18 @@ public class DeviceConnectivityControllerTest extends AbstractControllerTest {
JsonNode mqttCommands = commands.get(MQTT);
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(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);
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()));
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 && " +
"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()));
}
@ -295,18 +324,18 @@ public class DeviceConnectivityControllerTest extends AbstractControllerTest {
JsonNode mqttCommands = commands.get(MQTT);
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(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);
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));
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 && " +
"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));
}

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.Primary;
import org.springframework.test.context.ContextConfiguration;
import org.thingsboard.common.util.JacksonUtil;
import org.thingsboard.common.util.ThingsBoardExecutors;
import org.thingsboard.server.common.data.AdminSettings;
import org.thingsboard.server.common.data.Device;
@ -66,16 +67,47 @@ public class DeviceConnectivityControllerWithDefaultPortTest extends AbstractCon
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);
JsonNode connectivity = adminSettings.getJsonValue();
((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);
adminSettings.setJsonValue(config);
doPost("/api/admin/settings", adminSettings).andExpect(status().isOk());
Tenant tenant = new 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.settings.StarredDashboardInfo;
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.service.DaoSqlTest;
import org.thingsboard.server.dao.user.UserDao;
@ -81,6 +82,9 @@ public class UserControllerTest extends AbstractControllerTest {
@Autowired
private UserDao userDao;
@Autowired
private DeviceService deviceService;
static class Config {
@Bean
@Primary
@ -740,6 +744,43 @@ public class UserControllerTest extends AbstractControllerTest {
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
public void testDeleteUserWithDeleteRelationsOk() throws Exception {
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);
Assert.assertEquals(tenantWidgetsBundles, loadedWidgetsBundles2);
// cleanup
loginSysAdmin();
for (WidgetsBundle sysWidgetsBundle : sysWidgetsBundles) {
doDelete("/api/widgetsBundle/" + sysWidgetsBundle.getId().getId().toString())
.andExpect(status().isOk());
}
}
@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();
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)
.untilAsserted(() -> {
partitions.stream()
@ -498,7 +498,7 @@ public class TbRuleEngineQueueConsumerManagerTest {
verify(consumer, never()).unsubscribe();
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)
.untilAsserted(() -> {
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 String deviceName;
public DeviceCacheKey(DeviceId deviceId) {
this(null, deviceId, null);
}
public DeviceCacheKey(TenantId tenantId, DeviceId deviceId) {
this(tenantId, deviceId, null);
}
@ -44,11 +48,12 @@ public class DeviceCacheKey implements Serializable {
@Override
public String toString() {
if (deviceId != null) {
return tenantId + "_" + deviceId;
} else {
if (deviceId == null) {
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) {
return AlarmApiCallResult.builder().successful(false).build();
} else {
var propagationIds = getPropagationEntityIdsList(alarm);
deleteEntityRelations(tenantId, alarm.getId());
alarmDao.removeById(tenantId, alarm.getUuidId());
eventPublisher.publishEvent(DeleteEntityEvent.builder().tenantId(tenantId)
@ -195,7 +196,7 @@ public class BaseAlarmService extends AbstractCachedEntityService<TenantId, Page
if (checkAndDeleteAlarmType) {
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) {
log.trace("Executing findDeviceById [{}]", deviceId);
validateId(deviceId, INCORRECT_DEVICE_ID + deviceId);
return cache.getAndPutInTransaction(new DeviceCacheKey(tenantId, deviceId),
() -> {
//TODO: possible bug source since sometimes we need to clear cache by tenant id and sometimes by sys tenant id?
if (TenantId.SYS_TENANT_ID.equals(tenantId)) {
return deviceDao.findById(tenantId, deviceId.getId());
} else {
return deviceDao.findDeviceByTenantIdAndId(tenantId, deviceId.getId());
}
}, true);
if (TenantId.SYS_TENANT_ID.equals(tenantId)) {
return cache.getAndPutInTransaction(new DeviceCacheKey(deviceId),
() -> deviceDao.findById(tenantId, deviceId.getId()), true);
} else {
return cache.getAndPutInTransaction(new DeviceCacheKey(tenantId, deviceId),
() -> deviceDao.findDeviceByTenantIdAndId(tenantId, deviceId.getId()), true);
}
}
@Override
@ -258,6 +256,7 @@ public class DeviceServiceImpl extends AbstractCachedEntityService<DeviceCacheKe
List<DeviceCacheKey> keys = new ArrayList<>(3);
keys.add(new DeviceCacheKey(event.getTenantId(), event.getNewName()));
if (event.getDeviceId() != null) {
keys.add(new DeviceCacheKey(event.getDeviceId()));
keys.add(new DeviceCacheKey(event.getTenantId(), event.getDeviceId()));
}
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()) {
case ACCESS_TOKEN:
command.append(" -u ").append(deviceCredentials.getCredentialsId());
command.append(" -u \"").append(deviceCredentials.getCredentialsId()).append("\"");
break;
case MQTT_BASIC:
BasicMqttCredentials credentials = JacksonUtil.fromString(deviceCredentials.getCredentialsValue(),
BasicMqttCredentials.class);
if (credentials != null) {
if (credentials.getClientId() != null) {
command.append(" -i ").append(credentials.getClientId());
command.append(" -i \"").append(credentials.getClientId()).append("\"");
}
if (credentials.getUserName() != null) {
command.append(" -u ").append(credentials.getUserName());
command.append(" -u \"").append(credentials.getUserName()).append("\"");
}
if (credentials.getPassword() != null) {
command.append(" -P ").append(credentials.getPassword());
command.append(" -P \"").append(credentials.getPassword()).append("\"");;
}
} else {
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 volatile boolean firstReplyReceived;
private CountDownLatch firstReply = new CountDownLatch(1);
private CountDownLatch latch = new CountDownLatch(1);
private final CountDownLatch firstReply = new CountDownLatch(1);
private final CountDownLatch latch = new CountDownLatch(1);
private final long timeoutMultiplier;
@ -48,7 +48,8 @@ public class WsClient extends WebSocketClient {
}
@Override
public void onMessage(String message) {
public synchronized void onMessage(String message) {
log.error("WS onMessage: {}", message);
if (!firstReplyReceived) {
firstReplyReceived = true;
firstReply.countDown();
@ -66,12 +67,13 @@ public class WsClient extends WebSocketClient {
}
@Override
public void onClose(int code, String reason, boolean remote) {
log.info("ws is closed, due to [{}]", reason);
public synchronized void onClose(int code, String reason, boolean remote) {
log.error("WS onClose: [{}]", reason);
}
@Override
public void onError(Exception ex) {
public synchronized void onError(Exception ex) {
log.error("WS onError: ", ex);
ex.printStackTrace();
}

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

@ -25,6 +25,7 @@
</encoder>
</appender>
<root level="INFO">
<appender-ref ref="STDOUT"/>
</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()) {
entity = new HttpEntity<>(headers);
} else {
entity = new HttpEntity<>(getData(msg), headers);
entity = new HttpEntity<>(getData(msg, config.isIgnoreRequestBody(), config.isParseToPlainText()), headers);
}
URI uri = buildEncodedUri(endpointUrl);
@ -242,12 +242,19 @@ public class TbHttpClient {
return uri;
}
private String getData(TbMsg msg) {
String data = msg.getData();
private String getData(TbMsg tbMsg, boolean ignoreBody, boolean parseToPlainText) {
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;
data = data.replaceAll("^\"|\"$", "");
try {
data = JacksonUtil.fromString(data, String.class);
} catch (Exception ignored) {}
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;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.node.ObjectNode;
import lombok.extern.slf4j.Slf4j;
import org.thingsboard.rule.engine.api.RuleNode;
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.external.TbAbstractExternalNode;
import org.thingsboard.server.common.data.plugin.ComponentType;
import org.thingsboard.server.common.data.util.TbPair;
import org.thingsboard.server.common.msg.TbMsg;
@Slf4j
@ -30,6 +33,7 @@ import org.thingsboard.server.common.msg.TbMsg;
type = ComponentType.EXTERNAL,
name = "rest api call",
configClazz = TbRestApiCallNodeConfiguration.class,
version = 1,
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. " +
"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 {
static final String PARSE_TO_PLAIN_TEXT = "parseToPlainText";
static final String TRIM_DOUBLE_QUOTES = "trimDoubleQuotes";
protected TbHttpClient httpClient;
@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 maxParallelRequestsCount;
private boolean useRedisQueueForMsgPersistence;
private boolean trimDoubleQuotes;
private boolean parseToPlainText;
private boolean enableProxy;
private boolean useSystemProxyProperties;
private String proxyHost;
@ -58,7 +58,7 @@ public class TbRestApiCallNodeConfiguration implements NodeConfiguration<TbRestA
configuration.setReadTimeoutMs(0);
configuration.setMaxParallelRequestsCount(0);
configuration.setUseRedisQueueForMsgPersistence(false);
configuration.setTrimDoubleQuotes(false);
configuration.setParseToPlainText(false);
configuration.setEnableProxy(false);
configuration.setCredentials(new AnonymousCredentials());
configuration.setIgnoreRequestBody(false);

45
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.nio.NioEventLoopGroup;
import org.assertj.core.api.Assertions;
import org.awaitility.Awaitility;
import org.junit.After;
import org.junit.Assert;
import org.junit.Before;
import org.junit.Test;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.BeforeEach;
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.Mockito;
import org.mockserver.integration.ClientAndServer;
import org.springframework.util.LinkedMultiValueMap;
import org.springframework.web.client.AsyncRestTemplate;
import org.thingsboard.common.util.JacksonUtil;
import org.thingsboard.rule.engine.api.TbContext;
import org.thingsboard.server.common.data.id.DeviceId;
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.is;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.BDDMockito.willCallRealMethod;
import static org.mockito.Mockito.mock;
@ -61,13 +64,13 @@ public class TbHttpClientTest {
EventLoopGroup eventLoop;
TbHttpClient client;
@Before
@BeforeEach
public void setUp() throws Exception {
client = mock(TbHttpClient.class);
willCallRealMethod().given(client).getSharedOrCreateEventLoopGroup(any());
when(client.getSharedOrCreateEventLoopGroup(any())).thenCallRealMethod();
}
@After
@AfterEach
public void tearDown() throws Exception {
if (eventLoop != null) {
eventLoop.shutdownGracefully();
@ -91,7 +94,7 @@ public class TbHttpClientTest {
Mockito.when(client.buildEncodedUri(any())).thenCallRealMethod();
String url = "http://localhost:8080/";
URI uri = client.buildEncodedUri(url);
Assert.assertEquals(url, uri.toString());
Assertions.assertEquals(url, uri.toString());
}
@Test
@ -114,7 +117,7 @@ public class TbHttpClientTest {
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";
URI uri = client.buildEncodedUri(url);
Assert.assertEquals(expected, uri.toString());
Assertions.assertEquals(expected, uri.toString());
}
@Test
@ -183,7 +186,7 @@ public class TbHttpClientTest {
verify(ctx, times(1)).tellSuccess(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) {
@ -219,9 +222,21 @@ public class TbHttpClientTest {
Map<String, String> data = metaData.getData();
Assertions.assertThat(data).hasSize(2);
Assertions.assertThat(data.get("Content-Type")).isEqualTo("binary");
Assertions.assertThat(data.get("Set-Cookie")).isEqualTo("[\"sap-context=sap-client=075; path=/\",\"sap-token=sap-client=075; path=/\"]");
Assertions.assertEquals(2, data.size());
Assertions.assertEquals(data.get("Content-Type"), "binary");
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;
import com.datastax.oss.driver.api.core.uuid.Uuids;
import com.fasterxml.jackson.databind.JsonNode;
import org.apache.http.HttpException;
import org.apache.http.HttpRequest;
import org.apache.http.HttpResponse;
@ -26,6 +27,7 @@ import org.apache.http.protocol.HttpContext;
import org.apache.http.protocol.HttpRequestHandler;
import org.junit.After;
import org.junit.Test;
import org.junit.jupiter.api.Assertions;
import org.junit.runner.RunWith;
import org.mockito.ArgumentCaptor;
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.RuleNodeId;
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.TbMsgDataType;
import org.thingsboard.server.common.msg.TbMsgMetaData;
@ -91,7 +94,9 @@ public class TbRestApiCallNodeTest {
@After
public void teardown() {
server.stop();
if (server != null) {
server.stop();
}
}
@Test
@ -211,4 +216,20 @@ public class TbRestApiCallNodeTest {
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",
"date-fns": "2.0.0-alpha.27",
"dayjs": "1.11.4",
"echarts": "^5.4.3",
"flot": "https://github.com/thingsboard/flot.git#0.9-work",
"flot.curvedlines": "https://github.com/MichaelZinsmaier/CurvedLines.git#master",
"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) {
const prevData = currentData.data;
if (!data.data.length) {
if (!data.data.length && !prevData.length) {
update = false;
} else if (prevData && prevData[0] && prevData[0].length > 1 && data.data.length > 0) {
const prevTs = prevData[0][0];
@ -1560,6 +1560,8 @@ export class WidgetSubscription implements IWidgetSubscription {
}
this.notifyDataLoaded();
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 {
flex: 1;
width: 180px;
.mdc-evolution-chip-set__chips {
width: 100%;
}
}
.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) {
$event.stopPropagation();
}
const unacknowledgedAlarms = alarms.filter(alarm => {
return alarm.status === AlarmStatus.CLEARED_UNACK || alarm.status === AlarmStatus.ACTIVE_UNACK;
})
const unacknowledgedAlarms = alarms.filter(alarm => !alarm.acknowledged);
let title = '';
let content = '';
if (!unacknowledgedAlarms.length) {
@ -356,9 +354,7 @@ export class AlarmTableConfig extends EntityTableConfig<AlarmInfo, TimePageLink>
if ($event) {
$event.stopPropagation();
}
const activeAlarms = alarms.filter(alarm => {
return alarm.status === AlarmStatus.ACTIVE_ACK || alarm.status === AlarmStatus.ACTIVE_UNACK;
})
const activeAlarms = alarms.filter(alarm => !alarm.cleared);
let title = '';
let content = '';
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.
-->
<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">
<tb-hotkeys-cheatsheet #cheatSheetComponent></tb-hotkeys-cheatsheet>
<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,
breakpoints: {
'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: 485px)': 3
}

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

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

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

@ -16,20 +16,11 @@
:host {
width: 100%;
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 {
overflow: hidden;
background-color: #fff;
}
.mat-padding {
padding: 16px;
.tb-filter {
height: 100%;
&-container {
overflow: auto;
}
}
}

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 { EventTableHeaderComponent } from '@home/components/event/event-table-header.component';
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 { Direction } from '@shared/models/page/sort-order';
import { DialogService } from '@core/services/dialog.service';
@ -50,6 +50,7 @@ import {
EventFilterPanelData,
FilterEntityColumn
} from '@home/components/event/event-filter-panel.component';
import { DEFAULT_OVERLAY_POSITIONS } from '@shared/models/overlay.models';
export class EventTableConfig extends EntityTableConfig<Event, TimePageLink> {
@ -457,19 +458,16 @@ export class EventTableConfig extends EntityTableConfig<Event, TimePageLink> {
$event.stopPropagation();
}
const target = $event.target || $event.srcElement || $event.currentTarget;
const config = new OverlayConfig();
config.backdropClass = 'cdk-overlay-transparent-backdrop';
config.hasBackdrop = true;
const connectedPosition: ConnectedPosition = {
originX: 'end',
originY: 'bottom',
overlayX: 'end',
overlayY: 'top'
};
config.positionStrategy = this.overlay.position().flexibleConnectedTo(target as HTMLElement)
.withPositions([connectedPosition]);
config.maxHeight = '70vh';
config.height = 'min-content';
const config = new OverlayConfig({
panelClass: 'tb-panel-container',
backdropClass: 'cdk-overlay-transparent-backdrop',
hasBackdrop: true,
height: 'fit-content',
maxHeight: '65vh'
});
config.positionStrategy = this.overlay.position()
.flexibleConnectedTo(target as HTMLElement)
.withPositions(DEFAULT_OVERLAY_POSITIONS);
const overlayRef = this.overlay.create(config);
overlayRef.backdropClick().subscribe(() => {
@ -491,7 +489,11 @@ export class EventTableConfig extends EntityTableConfig<Event, TimePageLink> {
const injector = Injector.create({parent: this.viewContainerRef.injector, providers});
const componentRef = overlayRef.attach(new ComponentPortal(EventFilterPanelComponent,
this.viewContainerRef, injector));
const resizeWindows$ = fromEvent(window, 'resize').subscribe(() => {
overlayRef.updatePosition();
});
componentRef.onDestroy(() => {
resizeWindows$.unsubscribe();
if (componentRef.instance.result && !isEqual(this.filterParams, componentRef.instance.result.filterParams)) {
this.filterParams = componentRef.instance.result.filterParams;
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;
@Input()
columns: ScrollGridColumns = {columns: 1};
columns: ScrollGridColumns | number = 1;
@Input()
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;
.mat-stepper-horizontal {
display: flex;
height: 100%;
overflow: hidden;
.mat-horizontal-stepper-wrapper {
flex: 1 1 100%;
max-height: 100%;
}
.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 }}"
removeKeyTitle="{{ 'widgets.table.remove-column' | translate }}"
noKeysText="{{ 'widgets.table.no-columns' | translate }}"
requiredKeysText="{{ 'widgets.table.alarm-column-error' | translate }}"
hideDataKeyColor
hideUnits
hideDecimals

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

@ -82,6 +82,9 @@ import {
import {
LiquidLevelCardBasicConfigComponent
} 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({
declarations: [
@ -107,7 +110,8 @@ import {
RadialGaugeBasicConfigComponent,
ThermometerScaleGaugeBasicConfigComponent,
CompassGaugeBasicConfigComponent,
LiquidLevelCardBasicConfigComponent
LiquidLevelCardBasicConfigComponent,
DoughnutBasicConfigComponent
],
imports: [
CommonModule,
@ -137,7 +141,8 @@ import {
RadialGaugeBasicConfigComponent,
ThermometerScaleGaugeBasicConfigComponent,
CompassGaugeBasicConfigComponent,
LiquidLevelCardBasicConfigComponent
LiquidLevelCardBasicConfigComponent,
DoughnutBasicConfigComponent
]
})
export class BasicWidgetConfigModule {
@ -161,5 +166,6 @@ export const basicWidgetConfigComponentsMap: {[key: string]: Type<IBasicWidgetCo
'tb-radial-gauge-basic-config': RadialGaugeBasicConfigComponent,
'tb-thermometer-scale-gauge-basic-config': ThermometerScaleGaugeBasicConfigComponent,
'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-title" translate>widget-config.appearance</div>
<tb-image-cards-select rowHeight="2:1"
cols="2"
[cols]="{columns: 2,
breakpoints: {
'lt-sm': 1
}}"
label="{{ 'widgets.progress-bar.layout' | translate }}" formControlName="layout">
<tb-image-cards-select-option *ngFor="let layout of progressBarLayouts"
[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-title" translate>widget-config.appearance</div>
<tb-image-cards-select rowHeight="{{ horizontal ? '3:1' : '7:5' }}"
[cols]="horizontal ? 2 : 4"
[colsLtMd]="horizontal ? 1 : 2"
[cols]="{columns: horizontal ? 2 : 4,
breakpoints: {
'lt-md': horizontal ? 1 : 2
}}"
label="{{ 'widgets.value-card.layout' | translate }}" formControlName="layout">
<tb-image-cards-select-option *ngFor="let layout of valueCardLayouts"
[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-title" translate>widget-config.appearance</div>
<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">
<tb-image-cards-select-option *ngFor="let layout of valueChartCardLayouts"
[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"
[hideDecimals]="hideDecimals"
[hideUnits]="hideUnits"
[hideDataKeyDecimals]="hideDataKeyDecimals"
[hideDataKeyUnits]="hideDataKeyUnits"
[dataKeyType]="dataKeyType"
[singleRow]="false"
[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()
hideDecimals = false;
@Input()
@coerceBoolean()
hideDataKeyUnits = false;
@Input()
@coerceBoolean()
hideDataKeyDecimals = false;
@Input()
@coerceBoolean()
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.colorPlate = config.colorPlate;
this.widgetConfig.config.settings.highlights = config.highlights.rangeList;
this.widgetConfig.config.settings.highlights = config.highlights;
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-title" translate>widget-config.appearance</div>
<tb-image-cards-select rowHeight="1:1"
cols="4"
colsLtMd="2"
[cols]="{columns: 4,
breakpoints: {
'lt-md': 2
}}"
label="{{ 'widgets.battery-level.layout' | translate }}" formControlName="layout">
<tb-image-cards-select-option *ngFor="let layout of batteryLevelLayouts"
[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>
<tb-image-cards-select #shapesImageCardsSelect
[fxShow]="levelCardWidgetConfigForm.get('tankSelectionType').value === DataSourceType.static"
rowHeight="{{ '1:1' }}"
[cols]="5"
[colsLtMd]="2"
rowHeight="1:1"
[cols]="{columns: 5,
breakpoints: {
'lt-md': 2
}}"
style="width: 100%;"
label="{{ 'widgets.liquid-level-card.shape-type' | translate }}" formControlName="selectedShape">
<tb-image-cards-select-option *ngFor="let shape of shapes"
@ -106,9 +108,8 @@
</div>
</ng-container>
</div>
<tb-image-cards-select #layoutsImageCardsSelect rowHeight="{{ '1:1' }}"
[cols]="3"
[colsLtMd]="3"
<tb-image-cards-select #layoutsImageCardsSelect rowHeight="1:1"
cols="3"
label="{{ 'widgets.liquid-level-card.layout' | translate }}" formControlName="layout">
<tb-image-cards-select-option
*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-title" translate>widget-config.appearance</div>
<tb-image-cards-select rowHeight="2:1"
cols="2"
[cols]="{columns: 2,
breakpoints: {
'lt-sm': 1
}}"
label="{{ 'widgets.signal-strength.layout' | translate }}" formControlName="layout">
<tb-image-cards-select-option *ngFor="let layout of signalStrengthLayouts"
[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-title" translate>widget-config.appearance</div>
<tb-image-cards-select rowHeight="1:1"
cols="3"
colsLtMd="2"
[cols]="{columns: 3,
breakpoints: {
'lt-md': 2
}}"
label="{{ 'widgets.wind-speed-direction.layout' | translate }}" formControlName="layout">
<tb-image-cards-select-option *ngFor="let layout of windSpeedDirectionLayouts"
[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;
if (!valid) {
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();
}
const target = $event.target || $event.srcElement || $event.currentTarget;
const config = new OverlayConfig();
config.backdropClass = 'cdk-overlay-transparent-backdrop';
config.hasBackdrop = true;
const connectedPosition: ConnectedPosition = {
originX: 'end',
originY: 'bottom',
overlayX: 'end',
overlayY: 'top'
};
config.positionStrategy = this.overlay.position().flexibleConnectedTo(target as HTMLElement)
.withPositions([connectedPosition]);
const config = new OverlayConfig({
panelClass: 'tb-panel-container',
backdropClass: 'cdk-overlay-transparent-backdrop',
hasBackdrop: true,
height: 'fit-content',
maxHeight: '75vh'
});
config.positionStrategy = this.overlay.position()
.flexibleConnectedTo(target as HTMLElement)
.withPositions(DEFAULT_OVERLAY_POSITIONS);
const overlayRef = this.overlay.create(config);
overlayRef.backdropClick().subscribe(() => {
@ -549,9 +548,18 @@ export class AlarmsTableWidgetComponent extends PageComponent implements OnInit,
useValue: overlayRef
}
];
const injector = Injector.create({parent: this.viewContainerRef.injector, providers});
overlayRef.attach(new ComponentPortal(DisplayColumnsPanelComponent,
const componentRef = overlayRef.attach(new ComponentPortal(DisplayColumnsPanelComponent,
this.viewContainerRef, injector));
const resizeWindows$ = fromEvent(window, 'resize').subscribe(() => {
overlayRef.updatePosition();
});
componentRef.onDestroy(() => {
resizeWindows$.unsubscribe();
});
this.ctx.detectChanges();
}
@ -888,12 +896,21 @@ export class AlarmsTableWidgetComponent extends PageComponent implements OnInit,
$event.stopPropagation();
}
if (this.alarmsDatasource.selection.hasValue()) {
const alarmIds = this.alarmsDatasource.selection.selected.filter(
(alarmId) => alarmId !== NULL_UUID
const unacknowledgedAlarms = this.alarmsDatasource.selection.selected.filter(
alarm => alarm.id.id !== NULL_UUID && !alarm.acknowledged
);
if (alarmIds.length) {
const title = this.translate.instant('alarm.aknowledge-alarms-title', {count: alarmIds.length});
const content = this.translate.instant('alarm.aknowledge-alarms-text', {count: alarmIds.length});
let title = '';
let content = '';
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(
title,
content,
@ -901,16 +918,14 @@ export class AlarmsTableWidgetComponent extends PageComponent implements OnInit,
this.translate.instant('action.yes')
).subscribe((res) => {
if (res) {
if (res) {
const tasks: Observable<AlarmInfo>[] = [];
for (const alarmId of alarmIds) {
tasks.push(this.alarmService.ackAlarm(alarmId));
}
forkJoin(tasks).subscribe(() => {
this.alarmsDatasource.clearSelection();
this.subscription.update();
});
const tasks: Observable<AlarmInfo>[] = [];
for (const alarm of unacknowledgedAlarms) {
tasks.push(this.alarmService.ackAlarm(alarm.id.id));
}
forkJoin(tasks).subscribe(() => {
this.alarmsDatasource.clearSelection();
this.subscription.update();
});
}
});
}
@ -944,12 +959,21 @@ export class AlarmsTableWidgetComponent extends PageComponent implements OnInit,
$event.stopPropagation();
}
if (this.alarmsDatasource.selection.hasValue()) {
const alarmIds = this.alarmsDatasource.selection.selected.filter(
(alarmId) => alarmId !== NULL_UUID
const activeAlarms = this.alarmsDatasource.selection.selected.filter(
alarm => alarm.id.id !== NULL_UUID && !alarm.cleared
);
if (alarmIds.length) {
const title = this.translate.instant('alarm.clear-alarms-title', {count: alarmIds.length});
const content = this.translate.instant('alarm.clear-alarms-text', {count: alarmIds.length});
let title = '';
let content = '';
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(
title,
content,
@ -957,16 +981,14 @@ export class AlarmsTableWidgetComponent extends PageComponent implements OnInit,
this.translate.instant('action.yes')
).subscribe((res) => {
if (res) {
if (res) {
const tasks: Observable<AlarmInfo>[] = [];
for (const alarmId of alarmIds) {
tasks.push(this.alarmService.clearAlarm(alarmId));
}
forkJoin(tasks).subscribe(() => {
this.alarmsDatasource.clearSelection();
this.subscription.update();
});
const tasks: Observable<AlarmInfo>[] = [];
for (const alarm of activeAlarms) {
tasks.push(this.alarmService.clearAlarm(alarm.id.id));
}
forkJoin(tasks).subscribe(() => {
this.alarmsDatasource.clearSelection();
this.subscription.update();
});
}
});
}
@ -1124,7 +1146,8 @@ class AlarmsDatasource implements DataSource<AlarmDataInfo> {
private alarmsSubject = new BehaviorSubject<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>();
@ -1202,7 +1225,7 @@ class AlarmsDatasource implements DataSource<AlarmDataInfo> {
}
if (this.selection.hasValue()) {
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);
if (this.selection.isEmpty()) {
isEmptySelection = true;
@ -1276,14 +1299,14 @@ class AlarmsDatasource implements DataSource<AlarmDataInfo> {
toggleSelection(alarm: AlarmDataInfo) {
const hasValue = this.selection.hasValue();
this.selection.toggle(alarm.id.id);
this.selection.toggle(alarm);
if (hasValue !== this.selection.hasValue()) {
this.onSelectionModeChanged(this.selection.hasValue());
}
}
isSelected(alarm: AlarmDataInfo): boolean {
return this.selection.isSelected(alarm.id.id);
return this.selection.isSelected(alarm);
}
clearSelection() {
@ -1304,7 +1327,7 @@ class AlarmsDatasource implements DataSource<AlarmDataInfo> {
}
} else {
alarms.forEach(row => {
this.selection.select(row.id.id);
this.selection.select(row);
});
if (numSelected === 0) {
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%;
height: 100%;
min-width: 300px;
overflow: hidden;
overflow: auto;
background: #fff;
border-radius: 4px;
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 { CollectionViewer, DataSource } from '@angular/cdk/collections';
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 { EntityId } from '@shared/models/id/entity-id';
import { entityTypeTranslations } from '@shared/models/entity-type.models';
@ -83,7 +83,7 @@ import {
TableWidgetSettings,
widthStyle
} 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 {
DISPLAY_COLUMNS_PANEL_DATA,
@ -106,6 +106,7 @@ import { ResizeObserver } from '@juggle/resize-observer';
import { hidePageSizePixelValue } from '@shared/models/constants';
import { AggregationType } from '@shared/models/time/time.models';
import { FormBuilder } from '@angular/forms';
import { DEFAULT_OVERLAY_POSITIONS } from '@shared/models/overlay.models';
interface EntitiesTableWidgetSettings extends TableWidgetSettings {
entitiesTitle: string;
@ -462,18 +463,17 @@ export class EntitiesTableWidgetComponent extends PageComponent implements OnIni
if ($event) {
$event.stopPropagation();
}
const target = $event.target || $event.currentTarget;
const config = new OverlayConfig();
config.backdropClass = 'cdk-overlay-transparent-backdrop';
config.hasBackdrop = true;
const connectedPosition: ConnectedPosition = {
originX: 'end',
originY: 'bottom',
overlayX: 'end',
overlayY: 'top'
};
config.positionStrategy = this.overlay.position().flexibleConnectedTo(target as HTMLElement)
.withPositions([connectedPosition]);
const target = $event.target || $event.srcElement || $event.currentTarget;
const config = new OverlayConfig({
panelClass: 'tb-panel-container',
backdropClass: 'cdk-overlay-transparent-backdrop',
hasBackdrop: true,
height: 'fit-content',
maxHeight: '75vh'
});
config.positionStrategy = this.overlay.position()
.flexibleConnectedTo(target as HTMLElement)
.withPositions(DEFAULT_OVERLAY_POSITIONS);
const overlayRef = this.overlay.create(config);
overlayRef.backdropClick().subscribe(() => {
@ -481,11 +481,11 @@ export class EntitiesTableWidgetComponent extends PageComponent implements OnIni
});
const columns: DisplayColumn[] = this.columns.map(column => ({
title: column.title,
def: column.def,
display: this.displayedColumns.indexOf(column.def) > -1,
selectable: this.columnSelectionAvailability[column.def]
}));
title: column.title,
def: column.def,
display: this.displayedColumns.indexOf(column.def) > -1,
selectable: this.columnSelectionAvailability[column.def]
}));
const providers: StaticProvider[] = [
{
@ -506,9 +506,18 @@ export class EntitiesTableWidgetComponent extends PageComponent implements OnIni
useValue: overlayRef
}
];
const injector = Injector.create({parent: this.viewContainerRef.injector, providers});
overlayRef.attach(new ComponentPortal(DisplayColumnsPanelComponent,
const componentRef = overlayRef.attach(new ComponentPortal(DisplayColumnsPanelComponent,
this.viewContainerRef, injector));
const resizeWindows$ = fromEvent(window, 'resize').subscribe(() => {
overlayRef.updatePosition();
});
componentRef.onDestroy(() => {
resizeWindows$.unsubscribe();
});
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-title" translate>widgets.progress-bar.progress-bar-card-style</div>
<tb-image-cards-select rowHeight="2:1"
cols="2"
[cols]="{columns: 2,
breakpoints: {
'lt-sm': 1
}}"
label="{{ 'widgets.progress-bar.layout' | translate }}" formControlName="layout">
<tb-image-cards-select-option *ngFor="let layout of progressBarLayouts"
[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-title" translate>widgets.value-card.value-card-style</div>
<tb-image-cards-select rowHeight="{{ horizontal ? '3:1' : '7:5' }}"
[cols]="horizontal ? 2 : 4"
[colsLtMd]="horizontal ? 1 : 2"
[cols]="{columns: horizontal ? 2 : 4,
breakpoints: {
'lt-md': horizontal ? 1 : 2
}}"
label="{{ 'widgets.value-card.layout' | translate }}" formControlName="layout">
<tb-image-cards-select-option *ngFor="let layout of valueCardLayouts"
[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-title" translate>widgets.value-chart-card.value-chart-card-style</div>
<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">
<tb-image-cards-select-option *ngFor="let layout of valueChartCardLayouts"
[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">
<tb-image-cards-select rowHeight="{{ '3:1' }}"
[cols]="2"
[colsLtMd]="1"
<tb-image-cards-select rowHeight="3:1"
[cols]="{columns: 2,
breakpoints: {
'lt-md': 1
}}"
label="{{ 'widgets.count.layout' | translate }}" formControlName="layout">
<tb-image-cards-select-option *ngFor="let layout of countCardLayouts"
[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,
ElementRef,
forwardRef,
Input, OnChanges,
OnDestroy, OnInit,
QueryList, SimpleChanges,
Input,
OnChanges,
OnDestroy,
OnInit,
QueryList,
SimpleChanges,
ViewEncapsulation
} from '@angular/core';
import { ControlValueAccessor, NG_VALUE_ACCESSOR, UntypedFormControl } from '@angular/forms';
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 { BreakpointObserver } from '@angular/cdk/layout';
import { MediaBreakpoints } from '@shared/models/constants';
import { BreakpointObserver, BreakpointState } from '@angular/cdk/layout';
import { SafeUrl } from '@angular/platform-browser';
import { resolveBreakpoint } from '@shared/models/constants';
export interface ImageCardsSelectOption {
name: string;
@ -40,6 +43,11 @@ export interface ImageCardsSelectOption {
image: string | SafeUrl;
}
export interface ImageCardsColumns {
columns: number;
breakpoints?: {[breakpoint: string]: number};
}
@Directive(
{
// eslint-disable-next-line @angular-eslint/directive-selector
@ -83,10 +91,7 @@ export class ImageCardsSelectComponent implements ControlValueAccessor, OnInit,
disabled: boolean;
@Input()
cols = 4;
@Input()
colsLtMd = 2;
cols: ImageCardsColumns | number = 4;
@Input()
rowHeight = '9:5';
@ -108,28 +113,20 @@ export class ImageCardsSelectComponent implements ControlValueAccessor, OnInit,
private _destroyed = new Subject<void>();
private _colsChanged = new BehaviorSubject<void>(null);
constructor(private breakpointObserver: BreakpointObserver) {
this.valueFormControl = new UntypedFormControl('');
}
ngOnInit(): void {
const gridColumns = this.breakpointObserver.isMatched(MediaBreakpoints['lt-md']) ? this.colsLtMd : this.cols;
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()
);
this._initCols();
}
ngOnChanges(changes: SimpleChanges): void {
for (const propName of Object.keys(changes)) {
const change = changes[propName];
if (!change.firstChange && change.currentValue !== change.previousValue) {
if (['cols', 'colsLtMd'].includes(propName)) {
this._colsChanged.next(null);
if (['cols'].includes(propName)) {
this._initCols();
}
}
}
@ -146,6 +143,40 @@ export class ImageCardsSelectComponent implements ControlValueAccessor, OnInit,
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() {
if (this.imageCardsSelectOptions?.length) {
this.options.length = 0;
@ -165,7 +196,7 @@ export class ImageCardsSelectComponent implements ControlValueAccessor, OnInit,
this.propagateChange = fn;
}
registerOnTouched(fn: any): void {
registerOnTouched(_fn: any): 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>
<div fxLayout="row" fxLayoutAlign="start center" fxLayoutGap="8px">
<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>
<tb-color-input asBoxInput
colorClearButton
@ -102,7 +102,7 @@
<div translate>widgets.gauge.needle-circle</div>
<div fxLayout="row" fxLayoutAlign="start center" fxLayoutGap="8px">
<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>
<tb-color-input asBoxInput
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-title" translate>widgets.battery-level.battery-level-card-style</div>
<tb-image-cards-select rowHeight="1:1"
cols="4"
colsLtMd="2"
[cols]="{columns: 4,
breakpoints: {
'lt-md': 2
}}"
label="{{ 'widgets.battery-level.layout' | translate }}" formControlName="layout">
<tb-image-cards-select-option *ngFor="let layout of batteryLevelLayouts"
[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>
<tb-image-cards-select #shapesImageCardsSelect
[fxShow]="levelCardWidgetSettingsForm.get('tankSelectionType').value === DataSourceType.static"
rowHeight="{{ '1:1' }}"
[cols]="5"
[colsLtMd]="2"
rowHeight="1:1"
[cols]="{columns: 5,
breakpoints: {
'lt-md': 2
}}"
style="width: 100%;"
label="{{ 'widgets.liquid-level-card.shape-type' | translate }}" formControlName="selectedShape">
<tb-image-cards-select-option *ngFor="let shape of shapes"
@ -65,9 +67,8 @@
<div class="tb-form-panel">
<div class="tb-form-panel-title" translate>widgets.liquid-level-card.layout</div>
<tb-image-cards-select #layoutsImageCardsSelect
rowHeight="{{ '1:1' }}"
[cols]="3"
[colsLtMd]="3"
rowHeight="1:1"
cols="3"
label="{{ 'widgets.liquid-level-card.layout' | translate }}" formControlName="layout">
<tb-image-cards-select-option
*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-title" translate>widgets.signal-strength.signal-strength-card-style</div>
<tb-image-cards-select rowHeight="2:1"
cols="2"
[cols]="{columns: 2,
breakpoints: {
'lt-sm': 1
}}"
label="{{ 'widgets.signal-strength.layout' | translate }}" formControlName="layout">
<tb-image-cards-select-option *ngFor="let layout of signalStrengthLayouts"
[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-title" translate>widgets.wind-speed-direction.wind-speed-direction-card-style</div>
<tb-image-cards-select rowHeight="1:1"
cols="3"
colsLtMd="2"
[cols]="{columns: 3,
breakpoints: {
'lt-md': 2
}}"
label="{{ 'widgets.wind-speed-direction.layout' | translate }}" formControlName="layout">
<tb-image-cards-select-option *ngFor="let layout of windSpeedDirectionLayouts"
[value]="layout"

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

@ -302,6 +302,9 @@ import {
import {
LiquidLevelCardWidgetSettingsComponent
} 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({
declarations: [
@ -412,7 +415,8 @@ import {
SignalStrengthWidgetSettingsComponent,
ValueChartCardWidgetSettingsComponent,
ProgressBarWidgetSettingsComponent,
LiquidLevelCardWidgetSettingsComponent
LiquidLevelCardWidgetSettingsComponent,
DoughnutWidgetSettingsComponent
],
imports: [
CommonModule,
@ -529,6 +533,7 @@ import {
ValueChartCardWidgetSettingsComponent,
ProgressBarWidgetSettingsComponent,
LiquidLevelCardWidgetSettingsComponent,
DoughnutWidgetSettingsComponent
]
})
export class WidgetSettingsModule {
@ -611,4 +616,5 @@ export const widgetSettingsComponentsMap: {[key: string]: Type<IWidgetSettingsCo
'tb-value-chart-card-widget-settings': ValueChartCardWidgetSettingsComponent,
'tb-progress-bar-widget-settings': ProgressBarWidgetSettingsComponent,
'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 { Direction, SortOrder, sortOrderFromString } from '@shared/models/page/sort-order';
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 { catchError, debounceTime, distinctUntilChanged, map, skip, startWith, takeUntil } from 'rxjs/operators';
import { MatPaginator } from '@angular/material/paginator';
@ -81,7 +81,7 @@ import {
TableWidgetDataKeySettings,
TableWidgetSettings
} 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 { DatePipe } from '@angular/common';
import { coerceBooleanProperty } from '@angular/cdk/coercion';
@ -93,6 +93,7 @@ import {
} from '@home/components/widget/lib/display-columns-panel.component';
import { ComponentPortal } from '@angular/cdk/portal';
import { FormBuilder } from '@angular/forms';
import { DEFAULT_OVERLAY_POSITIONS } from '@shared/models/overlay.models';
export interface TimeseriesTableWidgetSettings extends TableWidgetSettings {
showTimestamp: boolean;
@ -411,23 +412,23 @@ export class TimeseriesTableWidgetComponent extends PageComponent implements OnI
$event.stopPropagation();
}
if (this.sources.length) {
const target = $event.target || $event.currentTarget;
const config = new OverlayConfig();
config.backdropClass = 'cdk-overlay-transparent-backdrop';
config.hasBackdrop = true;
const connectedPosition: ConnectedPosition = {
originX: 'end',
originY: 'bottom',
overlayX: 'end',
overlayY: 'top'
};
config.positionStrategy = this.overlay.position().flexibleConnectedTo(target as HTMLElement)
.withPositions([connectedPosition]);
const target = $event.target || $event.srcElement || $event.currentTarget;
const config = new OverlayConfig({
panelClass: 'tb-panel-container',
backdropClass: 'cdk-overlay-transparent-backdrop',
hasBackdrop: true,
height: 'fit-content',
maxHeight: '75vh'
});
config.positionStrategy = this.overlay.position()
.flexibleConnectedTo(target as HTMLElement)
.withPositions(DEFAULT_OVERLAY_POSITIONS);
const overlayRef = this.overlay.create(config);
overlayRef.backdropClick().subscribe(() => {
overlayRef.dispose();
});
const source = this.sources[this.sourceIndex];
this.prepareDisplayedColumn();
@ -450,8 +451,16 @@ export class TimeseriesTableWidgetComponent extends PageComponent implements OnI
];
const injector = Injector.create({parent: this.viewContainerRef.injector, providers});
overlayRef.attach(new ComponentPortal(DisplayColumnsPanelComponent,
const componentRef = overlayRef.attach(new ComponentPortal(DisplayColumnsPanelComponent,
this.viewContainerRef, injector));
const resizeWindows$ = fromEvent(window, 'resize').subscribe(() => {
overlayRef.updatePosition();
});
componentRef.onDestroy(() => {
resizeWindows$.unsubscribe();
});
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 { ProgressBarWidgetComponent } from '@home/components/widget/lib/cards/progress-bar-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({
declarations:
@ -104,7 +105,8 @@ import { LiquidLevelWidgetComponent } from '@home/components/widget/lib/indicato
SignalStrengthWidgetComponent,
ValueChartCardWidgetComponent,
ProgressBarWidgetComponent,
LiquidLevelWidgetComponent
LiquidLevelWidgetComponent,
DoughnutWidgetComponent
],
imports: [
CommonModule,
@ -148,7 +150,8 @@ import { LiquidLevelWidgetComponent } from '@home/components/widget/lib/indicato
SignalStrengthWidgetComponent,
ValueChartCardWidgetComponent,
ProgressBarWidgetComponent,
LiquidLevelWidgetComponent
LiquidLevelWidgetComponent,
DoughnutWidgetComponent
],
providers: [
{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 { emptyPageData, PageData } from '@shared/models/page/page-data';
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>>;
@ -48,7 +49,7 @@ export class ScrollGridDatasource<T, F> extends DataSource<(T | GridCellType)[]>
private _subscription: Subscription;
constructor(private breakpointObserver: BreakpointObserver,
private columns: ScrollGridColumns,
private columns: ScrollGridColumns | number,
private fetchFunction: GridEntitiesFetchFunction<T, F>,
private filter: F) {
super();
@ -58,9 +59,9 @@ export class ScrollGridDatasource<T, F> extends DataSource<(T | GridCellType)[]>
this._viewport = (collectionViewer as any)._viewport;
this._init();
if (this.columns.breakpoints) {
if (typeof this.columns === 'object' && 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());
}
@ -119,16 +120,21 @@ export class ScrollGridDatasource<T, F> extends DataSource<(T | GridCellType)[]>
}
private _detectColumns(): number {
let columns = this.columns.columns;
if (this.columns.breakpoints) {
for (const breakpont of Object.keys(this.columns.breakpoints)) {
if (this.breakpointObserver.isMatched(breakpont)) {
columns = this.columns.breakpoints[breakpont];
break;
if (typeof this.columns !== 'object') {
return this.columns;
} else {
let columns = this.columns.columns;
if (this.columns.breakpoints) {
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() {

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

@ -24,7 +24,7 @@
*ngFor="let entitySubtype of entitySubtypeList"
[removable]="!disabled"
(removed)="remove(entitySubtype)">
{{entitySubtype}}
{{customTranslate(entitySubtype)}}
<mat-icon matChipRemove *ngIf="!disabled">close</mat-icon>
</mat-chip-row>
<input matInput type="text" placeholder="{{ !disabled ? ((!entitySubtypeList || !entitySubtypeList.length) ? placeholder : secondaryPlaceholder) : '' }}"
@ -33,6 +33,7 @@
(focusin)="onFocus()"
formControlName="entitySubtype"
matAutocompleteOrigin
matChipInputAddOnBlur
#origin="matAutocompleteOrigin"
[matAutocompleteConnectedTo]="origin"
[matAutocomplete]="entitySubtypeAutocomplete"
@ -47,11 +48,6 @@
<mat-option *ngFor="let entitySubtype of filteredEntitySubtypeList | async" [value]="entitySubtype">
<span [innerHTML]="entitySubtype | highlight:searchText"></span>
</mat-option>
<mat-option *ngIf="!(filteredEntitySubtypeList | async)?.length" [value]="null">
<span>
{{ translate.get(noSubtypesMathingText, {entitySubtype: searchText}) | async }}
</span>
</mat-option>
</mat-autocomplete>
<div matSuffix>
<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 { PageLink } from '@shared/models/page/page-link';
import { PageData } from '@shared/models/page/page-data';
import { UtilsService } from '@core/services/utils.service';
@Component({
selector: 'tb-entity-subtype-list',
@ -129,6 +130,7 @@ export class EntitySubTypeListComponent implements ControlValueAccessor, OnInit,
private edgeService: EdgeService,
private entityViewService: EntityViewService,
private alarmService: AlarmService,
private utils: UtilsService,
private fb: FormBuilder) {
this.entitySubtypeListFormGroup = this.fb.group({
entitySubtypeList: [this.entitySubtypeList, this.required ? [Validators.required] : []],
@ -298,7 +300,7 @@ export class EntitySubTypeListComponent implements ControlValueAccessor, OnInit,
} else {
result = subTypes.filter(subType => searchText ? subType.toUpperCase().startsWith(searchText.toUpperCase()) : true);
}
if (!result.length) {
if (!result.length && searchText.length) {
result = [searchText];
}
return result;
@ -372,4 +374,8 @@ export class EntitySubTypeListComponent implements ControlValueAccessor, OnInit,
}, 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.
///
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 { Ace } from 'ace-builds';
import { coerceBooleanProperty } from '@angular/cdk/coercion';
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({
selector: 'tb-markdown-editor',
@ -30,7 +42,8 @@ import { getAce } from '@shared/models/ace/ace.models';
useExisting: forwardRef(() => MarkdownEditorComponent),
multi: true
}
]
],
encapsulation: ViewEncapsulation.None
})
export class MarkdownEditorComponent implements OnInit, ControlValueAccessor, OnDestroy {
@ -42,11 +55,13 @@ export class MarkdownEditorComponent implements OnInit, ControlValueAccessor, On
@Input() helpId: string;
@Input()
@coerceBoolean()
required: boolean;
@ViewChild('markdownEditor', {static: true})
markdownEditorElmRef: ElementRef;
private markdownEditor: Ace.Editor;
editorMode = true;
fullscreen = false;
@ -54,22 +69,15 @@ export class MarkdownEditorComponent implements OnInit, ControlValueAccessor, On
markdownValue: string;
renderValue: string;
ignoreChange = false;
private propagateChange = null;
private requiredValue: boolean;
private markdownEditor: Ace.Editor;
private ignoreChange = false;
get required(): boolean {
return this.requiredValue;
}
private editorResize$: ResizeObserver;
private editorsResizeCaf: CancelAnimationFrame;
private propagateChange: (value: any) => void = () => {};
@Input()
set required(value: boolean) {
this.requiredValue = coerceBooleanProperty(value);
}
constructor() {
constructor(private cd: ChangeDetectorRef,
private raf: RafService) {
}
ngOnInit(): void {
@ -100,6 +108,10 @@ export class MarkdownEditorComponent implements OnInit, ControlValueAccessor, On
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 {
if (this.editorResize$) {
this.editorResize$.disconnect();
}
if (this.editorsResizeCaf) {
this.editorsResizeCaf();
this.editorsResizeCaf = null;
}
if (this.markdownEditor) {
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() {
if (this.markdownEditor) {
setTimeout(() => {
@ -159,4 +169,25 @@ export class MarkdownEditorComponent implements OnInit, ControlValueAccessor, On
}, 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.
///
import { Component, Input } from '@angular/core';
import { ChangeDetectorRef, Component, Input } from '@angular/core';
import { animate, state, style, transition, trigger } from '@angular/animations';
import { coerceBoolean } from '@shared/decorators/coercion';
@Component({
selector: 'tb-error',
template: `
<div [@animation]="state" [ngStyle]="{marginTop: noMargin ? '0' : '0.5rem', fontSize: '.75rem'}">
<mat-error >
{{message}}
</mat-error>
<div [@animation]="state" [ngStyle]="{marginTop: noMargin ? '0' : '0.5rem', fontSize: '.75rem'}">
<mat-error>
{{message}}
</mat-error>
</div>
`,
styles: [`
@ -36,21 +36,23 @@ import { coerceBoolean } from '@shared/decorators/coercion';
trigger('animation', [
state('show', style({
opacity: 1,
transform: 'translateY(0)'
})),
state('hide', style({
opacity: 0,
transform: 'translateY(-1rem)'
})),
transition('show => hide', animate('200ms ease-out')),
transition('* => show', animate('200ms ease-in'))
transition('* <=> *', animate('200ms ease-out'))
]),
]
})
export class TbErrorComponent {
errorValue: any;
state: any;
message;
errorValue: string;
state = 'hide';
message: string;
constructor(private cd: ChangeDetectorRef) {
}
@Input()
@coerceBoolean()
@ -58,15 +60,13 @@ export class TbErrorComponent {
@Input()
set error(value) {
if (value && !this.message) {
this.message = value;
this.state = 'hide';
setTimeout(() => {
this.state = 'show';
});
} else {
if (this.errorValue !== value) {
this.errorValue = value;
if (value) {
this.message = value;
}
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;
severity: AlarmSeverity;
status: AlarmStatus;
acknowledged: boolean;
cleared: boolean;
startTs: number;
endTs: number;
ackTs: number;
@ -181,6 +183,8 @@ export const simulatedAlarm: AlarmInfo = {
type: 'TEMPERATURE',
severity: AlarmSeverity.MAJOR,
status: AlarmStatus.ACTIVE_UNACK,
acknowledged: false,
cleared: false,
details: {
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)'
};
export const resolveBreakpoint = (breakpoint: string): string => {
if (MediaBreakpoints[breakpoint]) {
return MediaBreakpoints[breakpoint];
}
return breakpoint;
};
export const helpBaseUrl = 'https://thingsboard.io';
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 => {
if (datasources) {
const foundDatasource = datasources.find(datasource => {
const found = datasource.dataKeys && datasource.dataKeys.find(key => key.type === DataKeyType.timeseries &&
key.aggregationType && key.aggregationType !== AggregationType.NONE);
const found = datasource.dataKeys && datasource.dataKeys.find(key => key?.type === DataKeyType.timeseries &&
key?.aggregationType && key.aggregationType !== AggregationType.NONE);
return !!found;
});
if (foundDatasource) {

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

@ -461,7 +461,7 @@
"keyFilters": [
{
"key": {
"type": "ATTRIBUTE",
"type": "SERVER_ATTRIBUTE",
"key": "active"
},
"valueType": "BOOLEAN",
@ -493,7 +493,7 @@
"keyFilters": [
{
"key": {
"type": "ATTRIBUTE",
"type": "SERVER_ATTRIBUTE",
"key": "active"
},
"valueType": "BOOLEAN",
@ -569,4 +569,4 @@
},
"externalId": null,
"name": "Customer User Home Page"
}
}

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

@ -943,7 +943,7 @@
"keyFilters": [
{
"key": {
"type": "ATTRIBUTE",
"type": "SERVER_ATTRIBUTE",
"key": "active"
},
"valueType": "BOOLEAN",
@ -975,7 +975,7 @@
"keyFilters": [
{
"key": {
"type": "ATTRIBUTE",
"type": "SERVER_ATTRIBUTE",
"key": "active"
},
"valueType": "BOOLEAN",
@ -1051,4 +1051,4 @@
},
"externalId": null,
"name": "Tenant Administrator Home Page"
}
}

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

@ -5338,6 +5338,27 @@
"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": {
"hierarchy-data-settings": "Hierarchy data settings",
"relations-query-function": "Node relations query function",
@ -6293,6 +6314,7 @@
"pagination": "Pagination",
"rows": "Rows",
"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",
"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;
}
.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;
background: #fff;
border-radius: 4px;

20
ui-ngx/yarn.lock

@ -5360,6 +5360,14 @@ ecc-jsbn@~0.1.1:
jsbn "~0.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:
version "0.15.3"
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"
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:
version "2.5.0"
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==
dependencies:
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