Browse Source

Merge branch 'master' into fix/rule-node-repartition

pull/10157/head
ViacheslavKlimov 3 years ago
parent
commit
a1c09cbf6a
  1. 36
      application/src/main/data/json/tenant/dashboards/gateways.json
  2. 14
      application/src/main/java/org/thingsboard/server/actors/device/DeviceActorMessageProcessor.java
  3. 2
      application/src/main/java/org/thingsboard/server/actors/ruleChain/RuleChainActorMessageProcessor.java
  4. 2
      application/src/main/java/org/thingsboard/server/service/edge/rpc/EdgeGrpcService.java
  5. 5
      application/src/main/java/org/thingsboard/server/service/edge/rpc/processor/rule/RuleChainEdgeProcessor.java
  6. 1
      application/src/main/java/org/thingsboard/server/service/queue/TbMsgPackCallback.java
  7. 37
      application/src/test/java/org/thingsboard/server/edge/DeviceEdgeTest.java
  8. 23
      application/src/test/java/org/thingsboard/server/edge/RuleChainEdgeTest.java
  9. 7
      application/src/test/java/org/thingsboard/server/edge/imitator/EdgeImitator.java
  10. 87
      application/src/test/java/org/thingsboard/server/service/edge/rpc/constructor/RuleChainMsgConstructorTest.java
  11. 4
      application/src/test/java/org/thingsboard/server/service/edge/rpc/processor/BaseEdgeProcessorTest.java
  12. 2
      common/dao-api/src/main/java/org/thingsboard/server/dao/edge/EdgeService.java
  13. 26
      dao/src/main/java/org/thingsboard/server/dao/edge/EdgeServiceImpl.java
  14. 4
      dao/src/main/java/org/thingsboard/server/dao/notification/DefaultNotifications.java
  15. 5
      rest-client/src/main/java/org/thingsboard/rest/client/RestClient.java
  16. 3
      rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/edge/AbstractTbMsgPushNode.java
  17. 67
      rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/geo/TbGpsGeofencingActionNode.java
  18. 3
      rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/geo/TbGpsGeofencingActionNodeConfiguration.java
  19. 4
      rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/util/EntitiesFieldsAsyncLoader.java
  20. 23
      rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/util/GpsGeofencingEvents.java
  21. 39
      rule-engine/rule-engine-components/src/test/java/org/thingsboard/rule/engine/geo/GpsGeofencingActionTestCase.java
  22. 259
      rule-engine/rule-engine-components/src/test/java/org/thingsboard/rule/engine/geo/TbGpsGeofencingActionNodeTest.java
  23. 18
      rule-engine/rule-engine-components/src/test/java/org/thingsboard/rule/engine/util/EntitiesFieldsAsyncLoaderTest.java
  24. 1
      ui-ngx/src/app/core/api/widget-api.models.ts
  25. 37
      ui-ngx/src/app/core/http/image.service.ts
  26. 1
      ui-ngx/src/app/core/notification/notification.models.ts
  27. 8
      ui-ngx/src/app/core/services/item-buffer.service.ts
  28. 30
      ui-ngx/src/app/modules/common/modules-map.ts
  29. 8
      ui-ngx/src/app/modules/home/components/dashboard/dashboard.component.ts
  30. 2
      ui-ngx/src/app/modules/home/components/filter/filters-edit.component.ts
  31. 2
      ui-ngx/src/app/modules/home/components/profile/device/snmp/snmp-device-profile-communication-config.component.html
  32. 15
      ui-ngx/src/app/modules/home/components/profile/device/snmp/snmp-device-profile-communication-config.component.scss
  33. 6
      ui-ngx/src/app/modules/home/components/profile/device/snmp/snmp-device-profile-mapping.component.html
  34. 10
      ui-ngx/src/app/modules/home/components/profile/device/snmp/snmp-device-profile-mapping.component.scss
  35. 6
      ui-ngx/src/app/modules/home/components/public-api.ts
  36. 4
      ui-ngx/src/app/modules/home/components/vc/repository-settings.component.html
  37. 5
      ui-ngx/src/app/modules/home/components/vc/repository-settings.component.ts
  38. 6
      ui-ngx/src/app/modules/home/components/vc/version-control.component.html
  39. 50
      ui-ngx/src/app/modules/home/components/widget/config/basic/indicator/liquid-level-card-basic-config.component.html
  40. 6
      ui-ngx/src/app/modules/home/components/widget/config/basic/indicator/liquid-level-card-basic-config.component.ts
  41. 2
      ui-ngx/src/app/modules/home/components/widget/lib/cards/aggregated-value-card-widget.component.ts
  42. 8
      ui-ngx/src/app/modules/home/components/widget/lib/gateway/gateway-configuration.component.ts
  43. 2
      ui-ngx/src/app/modules/home/components/widget/lib/gateway/gateway-connectors.component.html
  44. 7
      ui-ngx/src/app/modules/home/components/widget/lib/gateway/gateway-connectors.component.scss
  45. 3
      ui-ngx/src/app/modules/home/components/widget/lib/gateway/gateway-connectors.component.ts
  46. 63
      ui-ngx/src/app/modules/home/components/widget/lib/indicator/liquid-level-widget.component.ts
  47. 31
      ui-ngx/src/app/modules/home/components/widget/lib/indicator/liquid-level-widget.models.ts
  48. 23
      ui-ngx/src/app/modules/home/components/widget/lib/maps/leaflet-map.ts
  49. 3
      ui-ngx/src/app/modules/home/components/widget/lib/maps/markers.ts
  50. 19
      ui-ngx/src/app/modules/home/components/widget/lib/settings/common/action/widget-action.component.html
  51. 10
      ui-ngx/src/app/modules/home/components/widget/lib/settings/common/action/widget-action.component.ts
  52. 46
      ui-ngx/src/app/modules/home/components/widget/lib/settings/indicator/liquid-level-card-widget-settings.component.html
  53. 4
      ui-ngx/src/app/modules/home/components/widget/lib/settings/indicator/liquid-level-card-widget-settings.component.ts
  54. 3
      ui-ngx/src/app/modules/home/components/widget/widget.component.ts
  55. 19
      ui-ngx/src/app/modules/home/home.component.ts
  56. 25
      ui-ngx/src/app/modules/home/models/widget-component.models.ts
  57. 1
      ui-ngx/src/app/modules/home/pages/account/account-routing.module.ts
  58. 1
      ui-ngx/src/app/modules/home/pages/admin/admin-routing.module.ts
  59. 17
      ui-ngx/src/app/modules/home/pages/admin/oauth2-settings.component.ts
  60. 10
      ui-ngx/src/app/modules/home/pages/admin/security-settings.component.ts
  61. 2
      ui-ngx/src/app/modules/home/pages/admin/two-factor-auth-settings.component.ts
  62. 1
      ui-ngx/src/app/modules/home/pages/home-pages.models.ts
  63. 2
      ui-ngx/src/app/shared/components/dashboard-autocomplete.component.html
  64. 1
      ui-ngx/src/app/shared/components/json-object-edit.component.scss
  65. 2
      ui-ngx/src/app/shared/components/page.component.ts
  66. 29
      ui-ngx/src/app/shared/components/snack-bar-component.html
  67. 38
      ui-ngx/src/app/shared/components/snack-bar-component.scss
  68. 1
      ui-ngx/src/app/shared/components/time/timewindow.component.scss
  69. 2
      ui-ngx/src/app/shared/components/toast.directive.ts
  70. 7
      ui-ngx/src/app/shared/models/widget.models.ts
  71. 2
      ui-ngx/src/assets/help/en_US/rulenode/common_node_fields_templatization.md
  72. 2
      ui-ngx/src/assets/help/en_US/rulenode/common_node_script_args.md
  73. 2
      ui-ngx/src/assets/help/en_US/rulenode/switch_node_script_fn.md
  74. 2
      ui-ngx/src/assets/help/en_US/rulenode/tbel/common_node_script_args.md
  75. 2
      ui-ngx/src/assets/help/en_US/rulenode/tbel/switch_node_script_fn.md
  76. 54
      ui-ngx/src/assets/locale/locale.constant-de_DE.json
  77. 5
      ui-ngx/src/assets/locale/locale.constant-en_US.json
  78. 6703
      ui-ngx/src/assets/locale/locale.constant-pl_PL.json
  79. 570
      ui-ngx/src/assets/locale/locale.constant-zh_CN.json
  80. 30
      ui-ngx/src/form.scss
  81. 4
      ui-ngx/src/styles.scss

36
application/src/main/data/json/tenant/dashboards/gateways.json

@ -2317,7 +2317,7 @@
"useShowWidgetActionFunction": null,
"showWidgetActionFunction": "return true;",
"type": "custom",
"customFunction": "const url = `${window.location.origin}/entities/devices/${entityId.id}`;\nwindow.open(url, '_blank');",
"customFunction": "const url = `${window.location.origin + widgetContext.utils.getEntityDetailsPageURL(entityId.id, entityId.entityType)}`;\nwindow.open(url, '_blank');",
"openInSeparateDialog": false,
"openInPopover": false,
"id": "45e4507d-3adc-bb31-8b2b-1ba09bbd56ac"
@ -2484,7 +2484,7 @@
"useShowWidgetActionFunction": null,
"showWidgetActionFunction": "return true;",
"type": "custom",
"customFunction": "const url = `${window.location.origin}/entities/devices/${entityId.id}`;\nwindow.open(url, '_blank');",
"customFunction": "const url = `${window.location.origin + widgetContext.utils.getEntityDetailsPageURL(entityId.id, entityId.entityType)}`;\nwindow.open(url, '_blank');",
"openInSeparateDialog": false,
"openInPopover": false,
"id": "852eccce-98eb-24db-c783-bdd62566f906"
@ -2650,7 +2650,7 @@
"useShowWidgetActionFunction": null,
"showWidgetActionFunction": "return true;",
"type": "custom",
"customFunction": "const url = `${window.location.origin}/entities/devices/${entityId.id}`;\nwindow.open(url, '_blank');",
"customFunction": "const url = `${window.location.origin + widgetContext.utils.getEntityDetailsPageURL(entityId.id, entityId.entityType)}`;\nwindow.open(url, '_blank');",
"openInSeparateDialog": false,
"openInPopover": false,
"id": "3c31ba62-e760-2bea-4c8d-d32784a86c24"
@ -2816,7 +2816,7 @@
"useShowWidgetActionFunction": null,
"showWidgetActionFunction": "return true;",
"type": "custom",
"customFunction": "const url = `${window.location.origin}/entities/devices/${entityId.id}`;\nwindow.open(url, '_blank');",
"customFunction": "const url = `${window.location.origin + widgetContext.utils.getEntityDetailsPageURL(entityId.id, entityId.entityType)}`;\nwindow.open(url, '_blank');",
"openInSeparateDialog": false,
"openInPopover": false,
"id": "4b55ea81-93bf-4206-9166-3e0bdc1dd9f3"
@ -2982,7 +2982,7 @@
"useShowWidgetActionFunction": null,
"showWidgetActionFunction": "return true;",
"type": "custom",
"customFunction": "const url = `${window.location.origin}/entities/devices/${entityId.id}`;\nwindow.open(url, '_blank');",
"customFunction": "const url = `${window.location.origin + widgetContext.utils.getEntityDetailsPageURL(entityId.id, entityId.entityType)}`;\nwindow.open(url, '_blank');",
"openInSeparateDialog": false,
"openInPopover": false,
"id": "babf88d0-a118-e2b5-f10e-3a5970c8a65b"
@ -3148,7 +3148,7 @@
"useShowWidgetActionFunction": null,
"showWidgetActionFunction": "return true;",
"type": "custom",
"customFunction": "const url = `${window.location.origin}/entities/devices/${entityId.id}`;\nwindow.open(url, '_blank');",
"customFunction": "const url = `${window.location.origin + widgetContext.utils.getEntityDetailsPageURL(entityId.id, entityId.entityType)}`;\nwindow.open(url, '_blank');",
"openInSeparateDialog": false,
"openInPopover": false,
"id": "94de7690-f91d-b032-6771-85af99abd749"
@ -3314,7 +3314,7 @@
"useShowWidgetActionFunction": null,
"showWidgetActionFunction": "return true;",
"type": "custom",
"customFunction": "const url = `${window.location.origin}/entities/devices/${entityId.id}`;\nwindow.open(url, '_blank');",
"customFunction": "const url = `${window.location.origin + widgetContext.utils.getEntityDetailsPageURL(entityId.id, entityId.entityType)}`;\nwindow.open(url, '_blank');",
"openInSeparateDialog": false,
"openInPopover": false,
"id": "18414f44-1c65-536a-14de-eaf21a7d56bd"
@ -3480,7 +3480,7 @@
"useShowWidgetActionFunction": null,
"showWidgetActionFunction": "return true;",
"type": "custom",
"customFunction": "const url = `${window.location.origin}/entities/devices/${entityId.id}`;\nwindow.open(url, '_blank');",
"customFunction": "const url = `${window.location.origin + widgetContext.utils.getEntityDetailsPageURL(entityId.id, entityId.entityType)}`;\nwindow.open(url, '_blank');",
"openInSeparateDialog": false,
"openInPopover": false,
"id": "794974da-c9d2-a9f7-be47-c9eb642094e8"
@ -3646,7 +3646,7 @@
"useShowWidgetActionFunction": null,
"showWidgetActionFunction": "return true;",
"type": "custom",
"customFunction": "const url = `${window.location.origin}/entities/devices/${entityId.id}`;\nwindow.open(url, '_blank');",
"customFunction": "const url = `${window.location.origin + widgetContext.utils.getEntityDetailsPageURL(entityId.id, entityId.entityType)}`;\nwindow.open(url, '_blank');",
"openInSeparateDialog": false,
"openInPopover": false,
"id": "2add705b-3e53-8559-8126-380cac686fb0"
@ -3812,7 +3812,7 @@
"useShowWidgetActionFunction": null,
"showWidgetActionFunction": "return true;",
"type": "custom",
"customFunction": "const url = `${window.location.origin}/entities/devices/${entityId.id}`;\nwindow.open(url, '_blank');",
"customFunction": "const url = `${window.location.origin + widgetContext.utils.getEntityDetailsPageURL(entityId.id, entityId.entityType)}`;\nwindow.open(url, '_blank');",
"openInSeparateDialog": false,
"openInPopover": false,
"id": "7e1ba820-9992-d52a-579b-20485abb3926"
@ -3978,7 +3978,7 @@
"useShowWidgetActionFunction": null,
"showWidgetActionFunction": "return true;",
"type": "custom",
"customFunction": "const url = `${window.location.origin}/entities/devices/${entityId.id}`;\nwindow.open(url, '_blank');",
"customFunction": "const url = `${window.location.origin + widgetContext.utils.getEntityDetailsPageURL(entityId.id, entityId.entityType)}`;\nwindow.open(url, '_blank');",
"openInSeparateDialog": false,
"openInPopover": false,
"id": "91af27c1-b37c-2276-6022-a332e41b2b33"
@ -4144,7 +4144,7 @@
"useShowWidgetActionFunction": null,
"showWidgetActionFunction": "return true;",
"type": "custom",
"customFunction": "const url = `${window.location.origin}/entities/devices/${entityId.id}`;\nwindow.open(url, '_blank');",
"customFunction": "const url = `${window.location.origin + widgetContext.utils.getEntityDetailsPageURL(entityId.id, entityId.entityType)}`;\nwindow.open(url, '_blank');",
"openInSeparateDialog": false,
"openInPopover": false,
"id": "26cf8696-054b-13ec-7984-6fc5df20e6f1"
@ -4310,7 +4310,7 @@
"useShowWidgetActionFunction": null,
"showWidgetActionFunction": "return true;",
"type": "custom",
"customFunction": "const url = `${window.location.origin}/entities/devices/${entityId.id}`;\nwindow.open(url, '_blank');",
"customFunction": "const url = `${window.location.origin + widgetContext.utils.getEntityDetailsPageURL(entityId.id, entityId.entityType)}`;\nwindow.open(url, '_blank');",
"openInSeparateDialog": false,
"openInPopover": false,
"id": "1dcfaf24-32be-cd19-62d6-86d12cc6a7ef"
@ -4476,7 +4476,7 @@
"useShowWidgetActionFunction": null,
"showWidgetActionFunction": "return true;",
"type": "custom",
"customFunction": "const url = `${window.location.origin}/entities/devices/${entityId.id}`;\nwindow.open(url, '_blank');",
"customFunction": "const url = `${window.location.origin + widgetContext.utils.getEntityDetailsPageURL(entityId.id, entityId.entityType)}`;\nwindow.open(url, '_blank');",
"openInSeparateDialog": false,
"openInPopover": false,
"id": "ad2bc817-f3c4-150c-4672-8fe0c38aee8d"
@ -4642,7 +4642,7 @@
"useShowWidgetActionFunction": null,
"showWidgetActionFunction": "return true;",
"type": "custom",
"customFunction": "const url = `${window.location.origin}/entities/devices/${entityId.id}`;\nwindow.open(url, '_blank');",
"customFunction": "const url = `${window.location.origin + widgetContext.utils.getEntityDetailsPageURL(entityId.id, entityId.entityType)}`;\nwindow.open(url, '_blank');",
"openInSeparateDialog": false,
"openInPopover": false,
"id": "d1ad84cd-bd9c-4dca-e4a0-f444ae8598bd"
@ -4808,7 +4808,7 @@
"useShowWidgetActionFunction": null,
"showWidgetActionFunction": "return true;",
"type": "custom",
"customFunction": "const url = `${window.location.origin}/entities/devices/${entityId.id}`;\nwindow.open(url, '_blank');",
"customFunction": "const url = `${window.location.origin + widgetContext.utils.getEntityDetailsPageURL(entityId.id, entityId.entityType)}`;\nwindow.open(url, '_blank');",
"openInSeparateDialog": false,
"openInPopover": false,
"id": "bf80eef9-b879-9a08-40a4-488dbdefa125"
@ -4974,7 +4974,7 @@
"useShowWidgetActionFunction": null,
"showWidgetActionFunction": "return true;",
"type": "custom",
"customFunction": "const url = `${window.location.origin}/entities/devices/${entityId.id}`;\nwindow.open(url, '_blank');",
"customFunction": "const url = `${window.location.origin + widgetContext.utils.getEntityDetailsPageURL(entityId.id, entityId.entityType)}`;\nwindow.open(url, '_blank');",
"openInSeparateDialog": false,
"openInPopover": false,
"id": "b5a406b3-cc0a-8a09-9aec-3f8befae5fb8"
@ -5140,7 +5140,7 @@
"useShowWidgetActionFunction": null,
"showWidgetActionFunction": "return true;",
"type": "custom",
"customFunction": "const url = `${window.location.origin}/entities/devices/${entityId.id}`;\nwindow.open(url, '_blank');",
"customFunction": "const url = `${window.location.origin + widgetContext.utils.getEntityDetailsPageURL(entityId.id, entityId.entityType)}`;\nwindow.open(url, '_blank');",
"openInSeparateDialog": false,
"openInPopover": false,
"id": "ec1dfba3-4b43-2491-8948-f602337f8a3b"

14
application/src/main/java/org/thingsboard/server/actors/device/DeviceActorMessageProcessor.java

@ -66,7 +66,6 @@ import org.thingsboard.server.common.msg.rule.engine.DeviceCredentialsUpdateNoti
import org.thingsboard.server.common.msg.rule.engine.DeviceEdgeUpdateMsg;
import org.thingsboard.server.common.msg.rule.engine.DeviceNameOrTypeUpdateMsg;
import org.thingsboard.server.common.msg.timeout.DeviceActorServerSideRpcTimeoutMsg;
import org.thingsboard.server.gen.transport.TransportProtos;
import org.thingsboard.server.gen.transport.TransportProtos.AttributeUpdateNotificationMsg;
import org.thingsboard.server.gen.transport.TransportProtos.ClaimDeviceMsg;
import org.thingsboard.server.gen.transport.TransportProtos.DeviceSessionsCacheEntry;
@ -90,7 +89,9 @@ import org.thingsboard.server.gen.transport.TransportProtos.ToTransportMsg;
import org.thingsboard.server.gen.transport.TransportProtos.ToTransportUpdateCredentialsProto;
import org.thingsboard.server.gen.transport.TransportProtos.TransportToDeviceActorMsg;
import org.thingsboard.server.gen.transport.TransportProtos.TsKvProto;
import org.thingsboard.server.gen.transport.TransportProtos.UplinkNotificationMsg;
import org.thingsboard.server.service.rpc.RpcSubmitStrategy;
import org.thingsboard.server.service.state.DefaultDeviceStateService;
import org.thingsboard.server.service.transport.msg.TransportToDeviceActorMsgWrapper;
import javax.annotation.Nullable;
@ -173,7 +174,7 @@ public class DeviceActorMessageProcessor extends AbstractContextAwareMsgProcesso
private EdgeId findRelatedEdgeId() {
List<EntityRelation> result =
systemContext.getRelationService().findByToAndType(tenantId, deviceId, EntityRelation.CONTAINS_TYPE, RelationTypeGroup.COMMON);
systemContext.getRelationService().findByToAndType(tenantId, deviceId, EntityRelation.CONTAINS_TYPE, RelationTypeGroup.EDGE);
if (result != null && result.size() > 0) {
EntityRelation relationToEdge = result.get(0);
if (relationToEdge.getFrom() != null && relationToEdge.getFrom().getId() != null) {
@ -212,8 +213,11 @@ public class DeviceActorMessageProcessor extends AbstractContextAwareMsgProcesso
if (systemContext.isEdgesEnabled() && edgeId != null) {
log.debug("[{}][{}] device is related to edge: [{}]. Saving RPC request: [{}][{}] to edge queue", tenantId, deviceId, edgeId.getId(), rpcId, requestId);
try {
saveRpcRequestToEdgeQueue(request, requestId).get();
sent = true;
if (systemContext.getEdgeService().isEdgeActiveAsync(tenantId, edgeId, DefaultDeviceStateService.ACTIVITY_STATE).get()) {
saveRpcRequestToEdgeQueue(request, requestId).get();
} else {
log.error("[{}][{}][{}] Failed to save RPC request to edge queue {}. The Edge is currently offline or unreachable", tenantId, deviceId, edgeId.getId(), request);
}
} catch (InterruptedException | ExecutionException e) {
log.error("[{}][{}][{}] Failed to save RPC request to edge queue {}", tenantId, deviceId, edgeId.getId(), request, e);
}
@ -470,7 +474,7 @@ public class DeviceActorMessageProcessor extends AbstractContextAwareMsgProcesso
callback.onSuccess();
}
private void processUplinkNotificationMsg(SessionInfoProto sessionInfo, TransportProtos.UplinkNotificationMsg uplinkNotificationMsg) {
private void processUplinkNotificationMsg(SessionInfoProto sessionInfo, UplinkNotificationMsg uplinkNotificationMsg) {
String nodeId = sessionInfo.getNodeId();
sessions.entrySet().stream()
.filter(kv -> kv.getValue().getSessionInfo().getNodeId().equals(nodeId) && (kv.getValue().isSubscribedToAttributes() || kv.getValue().isSubscribedToRPC()))

2
application/src/main/java/org/thingsboard/server/actors/ruleChain/RuleChainActorMessageProcessor.java

@ -16,7 +16,6 @@
package org.thingsboard.server.actors.ruleChain;
import lombok.extern.slf4j.Slf4j;
import org.thingsboard.server.common.data.msg.TbNodeConnectionType;
import org.thingsboard.server.actors.ActorSystemContext;
import org.thingsboard.server.actors.TbActorCtx;
import org.thingsboard.server.actors.TbActorRef;
@ -29,6 +28,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.id.TenantId;
import org.thingsboard.server.common.data.msg.TbNodeConnectionType;
import org.thingsboard.server.common.data.plugin.ComponentLifecycleEvent;
import org.thingsboard.server.common.data.plugin.ComponentLifecycleState;
import org.thingsboard.server.common.data.relation.EntityRelation;

2
application/src/main/java/org/thingsboard/server/service/edge/rpc/EdgeGrpcService.java

@ -477,6 +477,8 @@ public class EdgeGrpcService extends EdgeRpcServiceGrpc.EdgeRpcServiceImplBase i
TbMsgMetaData md = new TbMsgMetaData();
if (!persistToTelemetry) {
md.putValue(DataConstants.SCOPE, DataConstants.SERVER_SCOPE);
md.putValue("edgeName", edge.getName());
md.putValue("edgeType", edge.getType());
}
TbMsg tbMsg = TbMsg.newMsg(msgType, edgeId, md, TbMsgDataType.JSON, data);
clusterService.pushMsgToRuleEngine(tenantId, edgeId, tbMsg, null);

5
application/src/main/java/org/thingsboard/server/service/edge/rpc/processor/rule/RuleChainEdgeProcessor.java

@ -18,6 +18,7 @@ package org.thingsboard.server.service.edge.rpc.processor.rule;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import org.thingsboard.server.common.data.EdgeUtils;
import org.thingsboard.server.common.data.edge.Edge;
import org.thingsboard.server.common.data.edge.EdgeEvent;
import org.thingsboard.server.common.data.id.RuleChainId;
import org.thingsboard.server.common.data.rule.RuleChain;
@ -53,6 +54,10 @@ public class RuleChainEdgeProcessor extends BaseEdgeProcessor {
isRoot = Boolean.parseBoolean(edgeEvent.getBody().get(EDGE_IS_ROOT_BODY_KEY).asText());
} catch (Exception ignored) {}
}
if (!isRoot) {
Edge edge = edgeService.findEdgeById(edgeEvent.getTenantId(), edgeEvent.getEdgeId());
isRoot = edge.getRootRuleChainId().equals(ruleChainId);
}
UpdateMsgType msgType = getUpdateMsgType(edgeEvent.getAction());
RuleChainUpdateMsg ruleChainUpdateMsg = ((RuleChainMsgConstructor)
ruleChainMsgConstructorFactory.getMsgConstructorByEdgeVersion(edgeVersion))

1
application/src/main/java/org/thingsboard/server/service/queue/TbMsgPackCallback.java

@ -24,7 +24,6 @@ import org.thingsboard.server.common.data.id.TenantId;
import org.thingsboard.server.common.msg.queue.RuleEngineException;
import org.thingsboard.server.common.msg.queue.RuleNodeInfo;
import org.thingsboard.server.common.msg.queue.TbMsgCallback;
import org.thingsboard.server.common.msg.tools.TbRateLimitsException;
import java.util.UUID;
import java.util.concurrent.TimeUnit;

37
application/src/test/java/org/thingsboard/server/edge/DeviceEdgeTest.java

@ -711,7 +711,7 @@ public class DeviceEdgeTest extends AbstractEdgeTest {
edgeImitator.sendUplinkMsg(uplinkMsgBuilder.build());
Assert.assertTrue(edgeImitator.waitForResponses());
Assert.assertTrue(onUpdateCallback.getSubscribeLatch().await(30, TimeUnit.SECONDS));
Assert.assertTrue(onUpdateCallback.getSubscribeLatch().await(TIMEOUT, TimeUnit.SECONDS));
Assert.assertEquals(JacksonUtil.newObjectNode().put(attrKey, attrValue),
JacksonUtil.fromBytes(onUpdateCallback.getPayloadBytes()));
@ -798,7 +798,21 @@ public class DeviceEdgeTest extends AbstractEdgeTest {
// clean up stored edge events
edgeEventService.cleanupEvents(1);
// perform rpc call to verify edgeId in DeviceActorMessageProcessor updated properly
// edge is disconnected: perform rpc call - no edge event saved
doPostAsync(
"/api/rpc/oneway/" + device.getId().getId().toString(),
JacksonUtil.toString(createDefaultRpc()),
String.class,
status().isOk());
Awaitility.await()
.atMost(TIMEOUT, TimeUnit.SECONDS)
.until(() -> {
PageData<EdgeEvent> result = edgeEventService.findEdgeEvents(tenantId, tmpEdge.getId(), 0L, null, new TimePageLink(1));
return result.getTotalElements() == 0;
});
// edge is connected: perform rpc call to verify edgeId in DeviceActorMessageProcessor updated properly
simulateEdgeActivation(tmpEdge);
doPostAsync(
"/api/rpc/oneway/" + device.getId().getId().toString(),
JacksonUtil.toString(createDefaultRpc()),
@ -857,4 +871,23 @@ public class DeviceEdgeTest extends AbstractEdgeTest {
return rpc;
}
private void simulateEdgeActivation(Edge edge) throws Exception {
ObjectNode attributes = JacksonUtil.newObjectNode();
attributes.put("active", true);
doPost("/api/plugins/telemetry/EDGE/" + edge.getId() + "/attributes/" + DataConstants.SERVER_SCOPE, attributes);
Awaitility.await()
.atMost(TIMEOUT, TimeUnit.SECONDS)
.until(() -> {
List<Map<String, Object>> values = doGetAsyncTyped("/api/plugins/telemetry/EDGE/" + edge.getId() +
"/values/attributes/SERVER_SCOPE", new TypeReference<>() {});
Optional<Map<String, Object>> activeAttrOpt = values.stream().filter(att -> att.get("key").equals("active")).findFirst();
if (activeAttrOpt.isEmpty()) {
return false;
}
Map<String, Object> activeAttr = activeAttrOpt.get();
return "true".equals(activeAttr.get("value").toString());
});
}
}

23
application/src/test/java/org/thingsboard/server/edge/RuleChainEdgeTest.java

@ -146,7 +146,7 @@ public class RuleChainEdgeTest extends AbstractEdgeTest {
}
}
private void createRuleChainMetadata(RuleChain ruleChain) {
private RuleChainMetaData createRuleChainMetadata(RuleChain ruleChain) {
RuleChainMetaData ruleChainMetaData = new RuleChainMetaData();
ruleChainMetaData.setRuleChainId(ruleChain.getId());
@ -182,7 +182,7 @@ public class RuleChainEdgeTest extends AbstractEdgeTest {
ruleChainMetaData.addConnectionInfo(0, 2, "fail");
ruleChainMetaData.addConnectionInfo(1, 2, "success");
doPost("/api/ruleChain/metadata", ruleChainMetaData, RuleChainMetaData.class);
return doPost("/api/ruleChain/metadata", ruleChainMetaData, RuleChainMetaData.class);
}
@Test
@ -193,9 +193,10 @@ public class RuleChainEdgeTest extends AbstractEdgeTest {
ruleChain.setType(RuleChainType.EDGE);
RuleChain savedRuleChain = doPost("/api/ruleChain", ruleChain, RuleChain.class);
edgeImitator.expectMessageAmount(1);
edgeImitator.expectMessageAmount(2);
doPost("/api/edge/" + edge.getUuidId()
+ "/ruleChain/" + savedRuleChain.getUuidId(), RuleChain.class);
RuleChainMetaData metaData = createRuleChainMetadata(savedRuleChain);
Assert.assertTrue(edgeImitator.waitForMessages());
// set new rule chain as root
@ -213,6 +214,22 @@ public class RuleChainEdgeTest extends AbstractEdgeTest {
Assert.assertTrue(ruleChainMsg.isRoot());
Assert.assertEquals(savedRuleChain.getId(), ruleChainMsg.getId());
// update metadata for root rule chain
edgeImitator.expectMessageAmount(1);
metaData.getNodes().forEach(n -> n.setDebugMode(true));
doPost("/api/ruleChain/metadata", metaData, RuleChainMetaData.class);
Assert.assertTrue(edgeImitator.waitForMessages());
ruleChainUpdateMsgOpt = edgeImitator.findMessageByType(RuleChainUpdateMsg.class);
Assert.assertTrue(ruleChainUpdateMsgOpt.isPresent());
ruleChainUpdateMsg = ruleChainUpdateMsgOpt.get();
ruleChainMsg = JacksonUtil.fromString(ruleChainUpdateMsg.getEntity(), RuleChain.class, true);
Assert.assertNotNull(ruleChainMsg);
Assert.assertTrue(UpdateMsgType.ENTITY_CREATED_RPC_MESSAGE.equals(ruleChainUpdateMsg.getMsgType()) ||
UpdateMsgType.ENTITY_UPDATED_RPC_MESSAGE.equals(ruleChainUpdateMsg.getMsgType()));
Assert.assertEquals(savedRuleChain.getId(), ruleChainMsg.getId());
Assert.assertEquals(savedRuleChain.getName(), ruleChainMsg.getName());
Assert.assertTrue(ruleChainMsg.isRoot());
// revert root rule chain
edgeImitator.expectMessageAmount(1);
doPost("/api/edge/" + edge.getUuidId()

7
application/src/test/java/org/thingsboard/server/edge/imitator/EdgeImitator.java

@ -26,6 +26,7 @@ import lombok.extern.slf4j.Slf4j;
import org.checkerframework.checker.nullness.qual.Nullable;
import org.thingsboard.edge.rpc.EdgeGrpcClient;
import org.thingsboard.edge.rpc.EdgeRpcClient;
import org.thingsboard.server.controller.AbstractWebTest;
import org.thingsboard.server.gen.edge.v1.AdminSettingsUpdateMsg;
import org.thingsboard.server.gen.edge.v1.AlarmCommentUpdateMsg;
import org.thingsboard.server.gen.edge.v1.AlarmUpdateMsg;
@ -72,8 +73,6 @@ import java.util.stream.Collectors;
@Slf4j
public class EdgeImitator {
public static final int TIMEOUT_IN_SECONDS = 30;
private String routingKey;
private String routingSecret;
@ -344,7 +343,7 @@ public class EdgeImitator {
}
public boolean waitForMessages() throws InterruptedException {
return waitForMessages(TIMEOUT_IN_SECONDS);
return waitForMessages(AbstractWebTest.TIMEOUT);
}
public boolean waitForMessages(int timeoutInSeconds) throws InterruptedException {
@ -359,7 +358,7 @@ public class EdgeImitator {
}
public boolean waitForResponses() throws InterruptedException {
return responsesLatch.await(TIMEOUT_IN_SECONDS, TimeUnit.SECONDS);
return responsesLatch.await(AbstractWebTest.TIMEOUT, TimeUnit.SECONDS);
}
public void expectResponsesAmount(int messageAmount) {

87
application/src/test/java/org/thingsboard/server/service/edge/rpc/constructor/RuleChainMsgConstructorTest.java

@ -15,7 +15,6 @@
*/
package org.thingsboard.server.service.edge.rpc.constructor;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.JsonNode;
import lombok.extern.slf4j.Slf4j;
import org.junit.Assert;
@ -61,7 +60,7 @@ public class RuleChainMsgConstructorTest {
}
@Test
public void testConstructRuleChainMetadataUpdatedMsg_V_3_4_0() throws JsonProcessingException {
public void testConstructRuleChainMetadataUpdatedMsg_V_3_4_0() {
RuleChainId ruleChainId = new RuleChainId(UUID.randomUUID());
RuleChainMetaData ruleChainMetaData = createRuleChainMetaData(
ruleChainId, 3, createRuleNodes(ruleChainId), createConnections());
@ -80,7 +79,7 @@ public class RuleChainMsgConstructorTest {
}
@Test
public void testConstructRuleChainMetadataUpdatedMsg_V_3_3_3() throws JsonProcessingException {
public void testConstructRuleChainMetadataUpdatedMsg_V_3_3_3() {
RuleChainId ruleChainId = new RuleChainId(UUID.randomUUID());
RuleChainMetaData ruleChainMetaData = createRuleChainMetaData(
ruleChainId, 3, createRuleNodes(ruleChainId), createConnections());
@ -120,7 +119,7 @@ public class RuleChainMsgConstructorTest {
}
@Test
public void testConstructRuleChainMetadataUpdatedMsg_V_3_3_0() throws JsonProcessingException {
public void testConstructRuleChainMetadataUpdatedMsg_V_3_3_0() {
RuleChainId ruleChainId = new RuleChainId(UUID.randomUUID());
RuleChainMetaData ruleChainMetaData = createRuleChainMetaData(ruleChainId, 3, createRuleNodes(ruleChainId), createConnections());
RuleChainMetadataUpdateMsg ruleChainMetadataUpdateMsg =
@ -161,7 +160,7 @@ public class RuleChainMsgConstructorTest {
}
@Test
public void testConstructRuleChainMetadataUpdatedMsg_V_3_3_0_inDifferentOrder() throws JsonProcessingException {
public void testConstructRuleChainMetadataUpdatedMsg_V_3_3_0_inDifferentOrder() {
// same rule chain metadata, but different order of rule nodes
RuleChainId ruleChainId = new RuleChainId(UUID.randomUUID());
RuleChainMetaData ruleChainMetaData1 = createRuleChainMetaData(ruleChainId, 8, createRuleNodesInDifferentOrder(ruleChainId), createConnectionsInDifferentOrder());
@ -254,7 +253,7 @@ public class RuleChainMsgConstructorTest {
return result;
}
private List<RuleNode> createRuleNodes(RuleChainId ruleChainId) throws JsonProcessingException {
private List<RuleNode> createRuleNodes(RuleChainId ruleChainId) {
List<RuleNode> result = new ArrayList<>();
result.add(getOutputNode(ruleChainId));
result.add(getAcknowledgeNode(ruleChainId));
@ -301,7 +300,7 @@ public class RuleChainMsgConstructorTest {
return result;
}
private List<RuleNode> createRuleNodesInDifferentOrder(RuleChainId ruleChainId) throws JsonProcessingException {
private List<RuleNode> createRuleNodesInDifferentOrder(RuleChainId ruleChainId) {
List<RuleNode> result = new ArrayList<>();
result.add(getPushToAnalyticsNode(ruleChainId));
result.add(getPushToCloudNode(ruleChainId));
@ -319,99 +318,99 @@ public class RuleChainMsgConstructorTest {
}
private RuleNode getOutputNode(RuleChainId ruleChainId) throws JsonProcessingException {
private RuleNode getOutputNode(RuleChainId ruleChainId) {
return createRuleNode(ruleChainId,
"org.thingsboard.rule.engine.flow.TbRuleChainOutputNode",
"Output node",
JacksonUtil.OBJECT_MAPPER.readTree("{\"version\":0}"),
JacksonUtil.OBJECT_MAPPER.readTree("{\"description\":\"\",\"layoutX\":178,\"layoutY\":592}"));
JacksonUtil.toJsonNode("{\"version\":0}"),
JacksonUtil.toJsonNode("{\"description\":\"\",\"layoutX\":178,\"layoutY\":592}"));
}
private RuleNode getCheckpointNode(RuleChainId ruleChainId) throws JsonProcessingException {
private RuleNode getCheckpointNode(RuleChainId ruleChainId) {
return createRuleNode(ruleChainId,
"org.thingsboard.rule.engine.flow.TbCheckpointNode",
"Checkpoint node",
JacksonUtil.OBJECT_MAPPER.readTree("{\"queueName\":\"HighPriority\"}"),
JacksonUtil.OBJECT_MAPPER.readTree("{\"description\":\"\",\"layoutX\":178,\"layoutY\":647}"));
JacksonUtil.toJsonNode("{\"queueName\":\"HighPriority\"}"),
JacksonUtil.toJsonNode("{\"description\":\"\",\"layoutX\":178,\"layoutY\":647}"));
}
private RuleNode getSaveTimeSeriesNode(RuleChainId ruleChainId) throws JsonProcessingException {
private RuleNode getSaveTimeSeriesNode(RuleChainId ruleChainId) {
return createRuleNode(ruleChainId,
"org.thingsboard.rule.engine.telemetry.TbMsgTimeseriesNode",
"Save Timeseries",
JacksonUtil.OBJECT_MAPPER.readTree("{\"defaultTTL\":0}"),
JacksonUtil.OBJECT_MAPPER.readTree("{\"layoutX\":823,\"layoutY\":157}"));
JacksonUtil.toJsonNode("{\"defaultTTL\":0}"),
JacksonUtil.toJsonNode("{\"layoutX\":823,\"layoutY\":157}"));
}
private RuleNode getMessageTypeSwitchNode(RuleChainId ruleChainId) throws JsonProcessingException {
private RuleNode getMessageTypeSwitchNode(RuleChainId ruleChainId) {
return createRuleNode(ruleChainId,
"org.thingsboard.rule.engine.filter.TbMsgTypeSwitchNode",
"Message Type Switch",
JacksonUtil.OBJECT_MAPPER.readTree("{\"version\":0}"),
JacksonUtil.OBJECT_MAPPER.readTree("{\"layoutX\":347,\"layoutY\":149}"));
JacksonUtil.toJsonNode("{\"version\":0}"),
JacksonUtil.toJsonNode("{\"layoutX\":347,\"layoutY\":149}"));
}
private RuleNode getLogOtherNode(RuleChainId ruleChainId) throws JsonProcessingException {
private RuleNode getLogOtherNode(RuleChainId ruleChainId) {
return createRuleNode(ruleChainId,
"org.thingsboard.rule.engine.action.TbLogNode",
"Log Other",
JacksonUtil.OBJECT_MAPPER.readTree("{\"jsScript\":\"return '\\\\nIncoming message:\\\\n' + JSON.stringify(msg) + '\\\\nIncoming metadata:\\\\n' + JSON.stringify(metadata);\"}"),
JacksonUtil.OBJECT_MAPPER.readTree("{\"layoutX\":824,\"layoutY\":378}"));
JacksonUtil.toJsonNode("{\"jsScript\":\"return '\\\\nIncoming message:\\\\n' + JSON.stringify(msg) + '\\\\nIncoming metadata:\\\\n' + JSON.stringify(metadata);\"}"),
JacksonUtil.toJsonNode("{\"layoutX\":824,\"layoutY\":378}"));
}
private RuleNode getPushToCloudNode(RuleChainId ruleChainId) throws JsonProcessingException {
private RuleNode getPushToCloudNode(RuleChainId ruleChainId) {
return createRuleNode(ruleChainId,
"org.thingsboard.rule.engine.edge.TbMsgPushToCloudNode",
"Push to cloud",
JacksonUtil.OBJECT_MAPPER.readTree("{\"scope\":\"SERVER_SCOPE\"}"),
JacksonUtil.OBJECT_MAPPER.readTree("{\"layoutX\":1129,\"layoutY\":52}"));
JacksonUtil.toJsonNode("{\"scope\":\"SERVER_SCOPE\"}"),
JacksonUtil.toJsonNode("{\"layoutX\":1129,\"layoutY\":52}"));
}
private RuleNode getAcknowledgeNode(RuleChainId ruleChainId) throws JsonProcessingException {
private RuleNode getAcknowledgeNode(RuleChainId ruleChainId) {
return createRuleNode(ruleChainId,
"org.thingsboard.rule.engine.flow.TbAckNode",
"Acknowledge node",
JacksonUtil.OBJECT_MAPPER.readTree("{\"version\":0}"),
JacksonUtil.OBJECT_MAPPER.readTree("{\"description\":\"\",\"layoutX\":177,\"layoutY\":703}"));
JacksonUtil.toJsonNode("{\"version\":0}"),
JacksonUtil.toJsonNode("{\"description\":\"\",\"layoutX\":177,\"layoutY\":703}"));
}
private RuleNode getDeviceProfileNode(RuleChainId ruleChainId) throws JsonProcessingException {
private RuleNode getDeviceProfileNode(RuleChainId ruleChainId) {
return createRuleNode(ruleChainId,
"org.thingsboard.rule.engine.profile.TbDeviceProfileNode",
"Device Profile Node",
JacksonUtil.OBJECT_MAPPER.readTree("{\"persistAlarmRulesState\":false,\"fetchAlarmRulesStateOnStart\":false}"),
JacksonUtil.OBJECT_MAPPER.readTree("{\"description\":\"Process incoming messages from devices with the alarm rules defined in the device profile. Dispatch all incoming messages with \\\"Success\\\" relation type.\",\"layoutX\":187,\"layoutY\":468}"));
JacksonUtil.toJsonNode("{\"persistAlarmRulesState\":false,\"fetchAlarmRulesStateOnStart\":false}"),
JacksonUtil.toJsonNode("{\"description\":\"Process incoming messages from devices with the alarm rules defined in the device profile. Dispatch all incoming messages with \\\"Success\\\" relation type.\",\"layoutX\":187,\"layoutY\":468}"));
}
private RuleNode getSaveClientAttributesNode(RuleChainId ruleChainId) throws JsonProcessingException {
private RuleNode getSaveClientAttributesNode(RuleChainId ruleChainId) {
return createRuleNode(ruleChainId,
"org.thingsboard.rule.engine.telemetry.TbMsgAttributesNode",
"Save Client Attributes",
JacksonUtil.OBJECT_MAPPER.readTree("{\"scope\":\"CLIENT_SCOPE\"}"),
JacksonUtil.OBJECT_MAPPER.readTree("{\"layoutX\":824,\"layoutY\":52}"));
JacksonUtil.toJsonNode("{\"scope\":\"CLIENT_SCOPE\"}"),
JacksonUtil.toJsonNode("{\"layoutX\":824,\"layoutY\":52}"));
}
private RuleNode getLogRpcFromDeviceNode(RuleChainId ruleChainId) throws JsonProcessingException {
private RuleNode getLogRpcFromDeviceNode(RuleChainId ruleChainId) {
return createRuleNode(ruleChainId,
"org.thingsboard.rule.engine.action.TbLogNode",
"Log RPC from Device",
JacksonUtil.OBJECT_MAPPER.readTree("{\"jsScript\":\"return '\\\\nIncoming message:\\\\n' + JSON.stringify(msg) + '\\\\nIncoming metadata:\\\\n' + JSON.stringify(metadata);\"}"),
JacksonUtil.OBJECT_MAPPER.readTree("{\"layoutX\":825,\"layoutY\":266}"));
JacksonUtil.toJsonNode("{\"jsScript\":\"return '\\\\nIncoming message:\\\\n' + JSON.stringify(msg) + '\\\\nIncoming metadata:\\\\n' + JSON.stringify(metadata);\"}"),
JacksonUtil.toJsonNode("{\"layoutX\":825,\"layoutY\":266}"));
}
private RuleNode getRpcCallRequestNode(RuleChainId ruleChainId) throws JsonProcessingException {
private RuleNode getRpcCallRequestNode(RuleChainId ruleChainId) {
return createRuleNode(ruleChainId,
"org.thingsboard.rule.engine.rpc.TbSendRPCRequestNode",
"RPC Call Request",
JacksonUtil.OBJECT_MAPPER.readTree("{\"timeoutInSeconds\":60}"),
JacksonUtil.OBJECT_MAPPER.readTree("{\"layoutX\":824,\"layoutY\":466}"));
JacksonUtil.toJsonNode("{\"timeoutInSeconds\":60}"),
JacksonUtil.toJsonNode("{\"layoutX\":824,\"layoutY\":466}"));
}
private RuleNode getPushToAnalyticsNode(RuleChainId ruleChainId) throws JsonProcessingException {
private RuleNode getPushToAnalyticsNode(RuleChainId ruleChainId) {
return createRuleNode(ruleChainId,
"org.thingsboard.rule.engine.flow.TbRuleChainInputNode",
"Push to Analytics",
JacksonUtil.OBJECT_MAPPER.readTree("{\"ruleChainId\":\"af588000-6c7c-11ec-bafd-c9a47a5c8d99\"}"),
JacksonUtil.OBJECT_MAPPER.readTree("{\"description\":\"\",\"layoutX\":477,\"layoutY\":560}"));
JacksonUtil.toJsonNode("{\"ruleChainId\":\"af588000-6c7c-11ec-bafd-c9a47a5c8d99\"}"),
JacksonUtil.toJsonNode("{\"description\":\"\",\"layoutX\":477,\"layoutY\":560}"));
}
}
}

4
application/src/test/java/org/thingsboard/server/service/edge/rpc/processor/BaseEdgeProcessorTest.java

@ -55,6 +55,7 @@ import org.thingsboard.server.dao.rule.RuleChainService;
import org.thingsboard.server.dao.service.DataValidator;
import org.thingsboard.server.dao.tenant.TenantProfileService;
import org.thingsboard.server.dao.tenant.TenantService;
import org.thingsboard.server.dao.timeseries.TimeseriesService;
import org.thingsboard.server.dao.user.UserService;
import org.thingsboard.server.dao.widget.WidgetTypeService;
import org.thingsboard.server.dao.widget.WidgetsBundleService;
@ -209,6 +210,9 @@ public abstract class BaseEdgeProcessorTest {
@MockBean
protected AttributesService attributesService;
@MockBean
protected TimeseriesService timeseriesService;
@MockBean
protected TbClusterService tbClusterService;

2
common/dao-api/src/main/java/org/thingsboard/server/dao/edge/EdgeService.java

@ -91,4 +91,6 @@ public interface EdgeService extends EntityDaoService {
PageData<EdgeId> findRelatedEdgeIdsByEntityId(TenantId tenantId, EntityId entityId, PageLink pageLink);
String findMissingToRelatedRuleChains(TenantId tenantId, EdgeId edgeId, String tbRuleChainInputNodeClassName);
ListenableFuture<Boolean> isEdgeActiveAsync(TenantId tenantId, EdgeId edgeId, String activityState);
}

26
dao/src/main/java/org/thingsboard/server/dao/edge/EdgeServiceImpl.java

@ -30,10 +30,10 @@ import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.transaction.event.TransactionalEventListener;
import org.thingsboard.common.util.JacksonUtil;
import org.thingsboard.server.common.data.DataConstants;
import org.thingsboard.server.common.data.EntitySubtype;
import org.thingsboard.server.common.data.EntityType;
import org.thingsboard.server.common.data.StringUtils;
import org.thingsboard.server.common.data.Tenant;
import org.thingsboard.server.common.data.User;
import org.thingsboard.server.common.data.audit.ActionType;
import org.thingsboard.server.common.data.edge.Edge;
@ -48,14 +48,15 @@ import org.thingsboard.server.common.data.id.RuleChainId;
import org.thingsboard.server.common.data.id.TenantId;
import org.thingsboard.server.common.data.id.TenantProfileId;
import org.thingsboard.server.common.data.id.UserId;
import org.thingsboard.server.common.data.kv.KvEntry;
import org.thingsboard.server.common.data.page.PageData;
import org.thingsboard.server.common.data.page.PageDataIterable;
import org.thingsboard.server.common.data.page.PageDataIterableByTenantIdEntityId;
import org.thingsboard.server.common.data.page.PageLink;
import org.thingsboard.server.common.data.relation.EntityRelation;
import org.thingsboard.server.common.data.relation.EntitySearchDirection;
import org.thingsboard.server.common.data.rule.RuleChain;
import org.thingsboard.server.common.data.rule.RuleNode;
import org.thingsboard.server.dao.attributes.AttributesService;
import org.thingsboard.server.dao.entity.AbstractCachedEntityService;
import org.thingsboard.server.dao.eventsourcing.ActionEntityEvent;
import org.thingsboard.server.dao.exception.DataValidationException;
@ -64,7 +65,7 @@ import org.thingsboard.server.dao.rule.RuleChainService;
import org.thingsboard.server.dao.service.DataValidator;
import org.thingsboard.server.dao.service.PaginatedRemover;
import org.thingsboard.server.dao.service.Validator;
import org.thingsboard.server.dao.tenant.TenantService;
import org.thingsboard.server.dao.timeseries.TimeseriesService;
import org.thingsboard.server.dao.user.UserService;
import javax.annotation.Nullable;
@ -105,7 +106,10 @@ public class EdgeServiceImpl extends AbstractCachedEntityService<EdgeCacheKey, E
private RelationService relationService;
@Autowired
private TenantService tenantService;
private TimeseriesService timeseriesService;
@Autowired
private AttributesService attributesService;
@Autowired
private DataValidator<Edge> edgeValidator;
@ -113,6 +117,8 @@ public class EdgeServiceImpl extends AbstractCachedEntityService<EdgeCacheKey, E
@Value("${edges.enabled}")
@Getter
private boolean edgesEnabled;
@Value("${edges.state.persistToTelemetry:false}")
private boolean persistToTelemetry;
@TransactionalEventListener(classes = EdgeCacheEvictEvent.class)
@Override
@ -530,6 +536,18 @@ public class EdgeServiceImpl extends AbstractCachedEntityService<EdgeCacheKey, E
return result.toString();
}
@Override
public ListenableFuture<Boolean> isEdgeActiveAsync(TenantId tenantId, EdgeId edgeId, String key) {
ListenableFuture<? extends Optional<? extends KvEntry>> futureKvEntry;
if (persistToTelemetry) {
futureKvEntry = timeseriesService.findLatest(tenantId, edgeId, key);
} else {
futureKvEntry = attributesService.find(tenantId, edgeId, DataConstants.SERVER_SCOPE, key);
}
return Futures.transformAsync(futureKvEntry, kvEntryOpt ->
Futures.immediateFuture(kvEntryOpt.flatMap(KvEntry::getBooleanValue).orElse(false)), MoreExecutors.directExecutor());
}
private List<RuleChain> findEdgeRuleChains(TenantId tenantId, EdgeId edgeId) {
List<RuleChain> result = new ArrayList<>();
PageLink pageLink = new PageLink(DEFAULT_PAGE_SIZE);

4
dao/src/main/java/org/thingsboard/server/dao/notification/DefaultNotifications.java

@ -40,9 +40,9 @@ import org.thingsboard.server.common.data.notification.rule.trigger.config.Alarm
import org.thingsboard.server.common.data.notification.rule.trigger.config.ApiUsageLimitNotificationRuleTriggerConfig;
import org.thingsboard.server.common.data.notification.rule.trigger.config.DeviceActivityNotificationRuleTriggerConfig;
import org.thingsboard.server.common.data.notification.rule.trigger.config.DeviceActivityNotificationRuleTriggerConfig.DeviceEvent;
import org.thingsboard.server.common.data.notification.rule.trigger.config.EdgeCommunicationFailureNotificationRuleTriggerConfig;
import org.thingsboard.server.common.data.notification.rule.trigger.config.EdgeConnectionNotificationRuleTriggerConfig;
import org.thingsboard.server.common.data.notification.rule.trigger.config.EdgeConnectionNotificationRuleTriggerConfig.EdgeConnectivityEvent;
import org.thingsboard.server.common.data.notification.rule.trigger.config.EdgeCommunicationFailureNotificationRuleTriggerConfig;
import org.thingsboard.server.common.data.notification.rule.trigger.config.EntitiesLimitNotificationRuleTriggerConfig;
import org.thingsboard.server.common.data.notification.rule.trigger.config.EntityActionNotificationRuleTriggerConfig;
import org.thingsboard.server.common.data.notification.rule.trigger.config.NewPlatformVersionNotificationRuleTriggerConfig;
@ -347,7 +347,7 @@ public class DefaultNotifications {
public static final DefaultNotification edgeCommunicationFailures = DefaultNotification.builder()
.name("Edge communication failure notification")
.type(NotificationType.EDGE_COMMUNICATION_FAILURE)
.subject("Edge '${edgeName}' communication failure occured")
.subject("Edge '${edgeName}' communication failure occurred")
.text("Failure message: '${failureMsg}'")
.icon("error").color(RED_COLOR)
.button("Go to Edge").link("/edgeManagement/instances/${edgeId}")

5
rest-client/src/main/java/org/thingsboard/rest/client/RestClient.java

@ -525,12 +525,11 @@ public class RestClient implements Closeable {
}
public PageData<AlarmCommentInfo> getAlarmComments(AlarmId alarmId, PageLink pageLink) {
String urlSecondPart = "/api/alarm/{alarmId}/comment";
Map<String, String> params = new HashMap<>();
params.put("alarmId", alarmId.getId().toString());
addPageLinkToParam(params, pageLink);
return restTemplate.exchange(
baseURL + urlSecondPart + "&" + getUrlParams(pageLink),
baseURL + "/api/alarm/{alarmId}/comment?" + getUrlParams(pageLink),
HttpMethod.GET,
HttpEntity.EMPTY,
new ParameterizedTypeReference<PageData<AlarmCommentInfo>>() {

3
rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/edge/AbstractTbMsgPushNode.java

@ -47,6 +47,7 @@ import static org.thingsboard.server.common.data.msg.TbMsgType.INACTIVITY_EVENT;
import static org.thingsboard.server.common.data.msg.TbMsgType.POST_ATTRIBUTES_REQUEST;
import static org.thingsboard.server.common.data.msg.TbMsgType.POST_TELEMETRY_REQUEST;
import static org.thingsboard.server.common.data.msg.TbMsgType.TIMESERIES_UPDATED;
import static org.thingsboard.server.common.data.msg.TbMsgType.TO_SERVER_RPC_REQUEST;
@Slf4j
public abstract class AbstractTbMsgPushNode<T extends BaseTbMsgPushNodeConfiguration, S, U> implements TbNode {
@ -176,6 +177,6 @@ public abstract class AbstractTbMsgPushNode<T extends BaseTbMsgPushNodeConfigura
protected boolean isSupportedMsgType(TbMsg msg) {
return msg.isTypeOneOf(POST_TELEMETRY_REQUEST, POST_ATTRIBUTES_REQUEST, ATTRIBUTES_UPDATED, ATTRIBUTES_DELETED, TIMESERIES_UPDATED,
ALARM, CONNECT_EVENT, DISCONNECT_EVENT, ACTIVITY_EVENT, INACTIVITY_EVENT);
ALARM, CONNECT_EVENT, DISCONNECT_EVENT, ACTIVITY_EVENT, INACTIVITY_EVENT, TO_SERVER_RPC_REQUEST);
}
}

67
rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/geo/TbGpsGeofencingActionNode.java

@ -15,6 +15,8 @@
*/
package org.thingsboard.rule.engine.geo;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.node.ObjectNode;
import com.google.gson.Gson;
import com.google.gson.JsonObject;
import com.google.gson.JsonParser;
@ -28,6 +30,7 @@ import org.thingsboard.server.common.data.kv.AttributeKvEntry;
import org.thingsboard.server.common.data.kv.BaseAttributeKvEntry;
import org.thingsboard.server.common.data.kv.StringDataEntry;
import org.thingsboard.server.common.data.plugin.ComponentType;
import org.thingsboard.server.common.data.util.TbPair;
import org.thingsboard.server.common.msg.TbMsg;
import java.util.Collections;
@ -39,6 +42,11 @@ import java.util.concurrent.ExecutionException;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.TimeoutException;
import static org.thingsboard.rule.engine.util.GpsGeofencingEvents.ENTERED;
import static org.thingsboard.rule.engine.util.GpsGeofencingEvents.INSIDE;
import static org.thingsboard.rule.engine.util.GpsGeofencingEvents.LEFT;
import static org.thingsboard.rule.engine.util.GpsGeofencingEvents.OUTSIDE;
/**
* Created by ashvayka on 19.01.18.
*/
@ -46,15 +54,24 @@ import java.util.concurrent.TimeoutException;
@RuleNode(
type = ComponentType.ACTION,
name = "gps geofencing events",
version = 1,
configClazz = TbGpsGeofencingActionNodeConfiguration.class,
relationTypes = {"Success", "Entered", "Left", "Inside", "Outside"},
nodeDescription = "Produces incoming messages using GPS based geofencing",
nodeDetails = "Extracts latitude and longitude parameters from incoming message and returns different events based on configuration parameters",
nodeDetails = "Extracts latitude and longitude parameters from incoming message and returns different events based on configuration parameters. " +
"<br><br>" +
"If an object with coordinates extracted from incoming message enters the geofence, sends a message with the type <code>Entered</code>. " +
"If an object leaves the geofence, sends a message with the type <code>Left</code>. " +
"If the presence monitoring strategy <b>\"On first message\"</b> is selected, sends messages via rule node connection type <code>Inside</code> or <code>Outside</code> only the first time the geofencing and duration conditions are satisfied; otherwise sends messages via rule node connection type <code>Success</code>. " +
"If the presence monitoring strategy <b>\"On each message\"</b> is selected, sends messages via rule node connection type <code>Inside</code> or <code>Outside</code> every time the geofencing condition is satisfied. " +
"<br><br>" +
"Output connections: <code>Entered</code>, <code>Left</code>, <code>Inside</code>, <code>Outside</code>, <code>Success</code>",
uiResources = {"static/rulenode/rulenode-core-config.js"},
configDirective = "tbActionNodeGpsGeofencingConfig"
)
public class TbGpsGeofencingActionNode extends AbstractGeofencingNode<TbGpsGeofencingActionNodeConfiguration> {
private static final String REPORT_PRESENCE_STATUS_ON_EACH_MESSAGE = "reportPresenceStatusOnEachMessage";
private final Map<EntityId, EntityGeofencingState> entityStates = new HashMap<>();
private final Gson gson = new Gson();
private final JsonParser parser = new JsonParser();
@ -80,25 +97,32 @@ public class TbGpsGeofencingActionNode extends AbstractGeofencingNode<TbGpsGeofe
}
});
boolean told = false;
if (entityState.getStateSwitchTime() == 0L || entityState.isInside() != matches) {
switchState(ctx, msg.getOriginator(), entityState, matches, ts);
ctx.tellNext(msg, matches ? "Entered" : "Left");
told = true;
} else {
if (!entityState.isStayed()) {
long stayTime = ts - entityState.getStateSwitchTime();
if (stayTime > (entityState.isInside() ?
TimeUnit.valueOf(config.getMinInsideDurationTimeUnit()).toMillis(config.getMinInsideDuration()) : TimeUnit.valueOf(config.getMinOutsideDurationTimeUnit()).toMillis(config.getMinOutsideDuration()))) {
setStaid(ctx, msg.getOriginator(), entityState);
ctx.tellNext(msg, entityState.isInside() ? "Inside" : "Outside");
told = true;
}
}
ctx.tellNext(msg, matches ? ENTERED : LEFT);
return;
}
if (config.isReportPresenceStatusOnEachMessage()) {
ctx.tellNext(msg, entityState.isInside() ? INSIDE : OUTSIDE);
return;
}
if (!told) {
if (entityState.isStayed()) {
ctx.tellSuccess(msg);
return;
}
long stayTime = ts - entityState.getStateSwitchTime();
if (stayTime > (entityState.isInside() ?
TimeUnit.valueOf(config.getMinInsideDurationTimeUnit()).toMillis(config.getMinInsideDuration()) :
TimeUnit.valueOf(config.getMinOutsideDurationTimeUnit()).toMillis(config.getMinOutsideDuration()))) {
setStaid(ctx, msg.getOriginator(), entityState);
ctx.tellNext(msg, entityState.isInside() ? INSIDE : OUTSIDE);
return;
}
ctx.tellSuccess(msg);
}
private void switchState(TbContext ctx, EntityId entityId, EntityGeofencingState entityState, boolean matches, long ts) {
@ -127,4 +151,17 @@ public class TbGpsGeofencingActionNode extends AbstractGeofencingNode<TbGpsGeofe
protected Class<TbGpsGeofencingActionNodeConfiguration> getConfigClazz() {
return TbGpsGeofencingActionNodeConfiguration.class;
}
@Override
public TbPair<Boolean, JsonNode> upgrade(int fromVersion, JsonNode oldConfiguration) throws TbNodeException {
boolean hasChanges = false;
if (fromVersion == 0) {
if (!oldConfiguration.has(REPORT_PRESENCE_STATUS_ON_EACH_MESSAGE)) {
hasChanges = true;
((ObjectNode) oldConfiguration).put(REPORT_PRESENCE_STATUS_ON_EACH_MESSAGE, false);
}
}
return new TbPair<>(hasChanges, oldConfiguration);
}
}

3
rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/geo/TbGpsGeofencingActionNodeConfiguration.java

@ -31,6 +31,8 @@ public class TbGpsGeofencingActionNodeConfiguration extends TbGpsGeofencingFilte
private String minInsideDurationTimeUnit;
private String minOutsideDurationTimeUnit;
private boolean reportPresenceStatusOnEachMessage;
@Override
public TbGpsGeofencingActionNodeConfiguration defaultConfiguration() {
TbGpsGeofencingActionNodeConfiguration configuration = new TbGpsGeofencingActionNodeConfiguration();
@ -43,6 +45,7 @@ public class TbGpsGeofencingActionNodeConfiguration extends TbGpsGeofencingFilte
configuration.setMinOutsideDurationTimeUnit(TimeUnit.MINUTES.name());
configuration.setMinInsideDuration(1);
configuration.setMinOutsideDuration(1);
configuration.setReportPresenceStatusOnEachMessage(true);
return configuration;
}
}

4
rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/util/EntitiesFieldsAsyncLoader.java

@ -25,6 +25,7 @@ import org.thingsboard.server.common.data.id.AlarmId;
import org.thingsboard.server.common.data.id.AssetId;
import org.thingsboard.server.common.data.id.CustomerId;
import org.thingsboard.server.common.data.id.DeviceId;
import org.thingsboard.server.common.data.id.EdgeId;
import org.thingsboard.server.common.data.id.EntityId;
import org.thingsboard.server.common.data.id.EntityViewId;
import org.thingsboard.server.common.data.id.RuleChainId;
@ -63,6 +64,9 @@ public class EntitiesFieldsAsyncLoader {
case ENTITY_VIEW:
return toEntityFieldsDataAsync(ctx.getEntityViewService().findEntityViewByIdAsync(ctx.getTenantId(), (EntityViewId) originatorId),
EntityFieldsData::new, ctx);
case EDGE:
return toEntityFieldsDataAsync(ctx.getEdgeService().findEdgeByIdAsync(ctx.getTenantId(), (EdgeId) originatorId),
EntityFieldsData::new, ctx);
default:
return Futures.immediateFailedFuture(new TbNodeException("Unexpected originator EntityType: " + originatorId.getEntityType()));
}

23
rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/util/GpsGeofencingEvents.java

@ -0,0 +1,23 @@
/**
* Copyright © 2016-2024 The Thingsboard Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.thingsboard.rule.engine.util;
public class GpsGeofencingEvents {
public static final String ENTERED = "Entered";
public static final String INSIDE = "Inside";
public static final String LEFT = "Left";
public static final String OUTSIDE = "Outside";
}

39
rule-engine/rule-engine-components/src/test/java/org/thingsboard/rule/engine/geo/GpsGeofencingActionTestCase.java

@ -0,0 +1,39 @@
/**
* Copyright © 2016-2024 The Thingsboard Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.thingsboard.rule.engine.geo;
import lombok.Data;
import org.thingsboard.server.common.data.id.EntityId;
import java.util.HashMap;
import java.util.Map;
@Data
public class GpsGeofencingActionTestCase {
private EntityId entityId;
private Map<EntityId, EntityGeofencingState> entityStates;
private boolean msgInside;
private boolean reportPresenceStatusOnEachMessage;
public GpsGeofencingActionTestCase(EntityId entityId, boolean msgInside, boolean reportPresenceStatusOnEachMessage, EntityGeofencingState entityGeofencingState) {
this.entityId = entityId;
this.msgInside = msgInside;
this.reportPresenceStatusOnEachMessage = reportPresenceStatusOnEachMessage;
this.entityStates = new HashMap<>();
this.entityStates.put(entityId, entityGeofencingState);
}
}

259
rule-engine/rule-engine-components/src/test/java/org/thingsboard/rule/engine/geo/TbGpsGeofencingActionNodeTest.java

@ -0,0 +1,259 @@
/**
* Copyright © 2016-2024 The Thingsboard Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.thingsboard.rule.engine.geo;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.extension.ExtendWith;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.Arguments;
import org.junit.jupiter.params.provider.MethodSource;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.test.util.ReflectionTestUtils;
import org.thingsboard.common.util.JacksonUtil;
import org.thingsboard.rule.engine.AbstractRuleNodeUpgradeTest;
import org.thingsboard.rule.engine.api.TbContext;
import org.thingsboard.rule.engine.api.TbNode;
import org.thingsboard.rule.engine.api.TbNodeConfiguration;
import org.thingsboard.rule.engine.api.TbNodeException;
import org.thingsboard.server.common.data.id.DeviceId;
import org.thingsboard.server.common.data.id.EntityId;
import org.thingsboard.server.common.data.msg.TbMsgType;
import org.thingsboard.server.common.msg.TbMsg;
import org.thingsboard.server.common.msg.TbMsgMetaData;
import org.thingsboard.server.dao.attributes.AttributesService;
import java.time.Duration;
import java.util.UUID;
import java.util.stream.Stream;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.spy;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import static org.thingsboard.rule.engine.util.GpsGeofencingEvents.ENTERED;
import static org.thingsboard.rule.engine.util.GpsGeofencingEvents.INSIDE;
import static org.thingsboard.rule.engine.util.GpsGeofencingEvents.LEFT;
import static org.thingsboard.rule.engine.util.GpsGeofencingEvents.OUTSIDE;
import static org.thingsboard.server.common.data.msg.TbNodeConnectionType.SUCCESS;
@ExtendWith(MockitoExtension.class)
class TbGpsGeofencingActionNodeTest extends AbstractRuleNodeUpgradeTest {
@Mock
private TbContext ctx;
@Mock
private AttributesService attributesService;
private TbGpsGeofencingActionNode node;
@BeforeEach
void setUp() {
node = spy(new TbGpsGeofencingActionNode());
}
@AfterEach
void tearDown() {
node.destroy();
}
private static Stream<Arguments> givenReportPresenceStatusOnEachMessage_whenOnMsg_thenVerifyOutputMsgType() {
DeviceId deviceId = new DeviceId(UUID.randomUUID());
long tsNow = System.currentTimeMillis();
long tsNowMinusMinuteAndMillis = tsNow - Duration.ofMinutes(1).plusMillis(1).toMillis();
return Stream.of(
// default config with presenceMonitoringStrategyOnEachMessage false and msgInside true
Arguments.of(new GpsGeofencingActionTestCase(deviceId, true, false,
new EntityGeofencingState(false, 0, false)), ENTERED),
Arguments.of(new GpsGeofencingActionTestCase(deviceId, true, false,
new EntityGeofencingState(true, tsNow, false)), SUCCESS),
Arguments.of(new GpsGeofencingActionTestCase(deviceId, true, false,
new EntityGeofencingState(true, tsNowMinusMinuteAndMillis, false)), INSIDE),
Arguments.of(new GpsGeofencingActionTestCase(deviceId, true, false,
new EntityGeofencingState(true, tsNow, true)), SUCCESS),
// default config with presenceMonitoringStrategyOnEachMessage false and msgInside false
Arguments.of(new GpsGeofencingActionTestCase(deviceId, false, false,
new EntityGeofencingState(false, 0, false)), LEFT),
Arguments.of(new GpsGeofencingActionTestCase(deviceId, false, false,
new EntityGeofencingState(false, tsNow, false)), SUCCESS),
Arguments.of(new GpsGeofencingActionTestCase(deviceId, false, false,
new EntityGeofencingState(false, tsNowMinusMinuteAndMillis, false)), OUTSIDE),
Arguments.of(new GpsGeofencingActionTestCase(deviceId, false, false,
new EntityGeofencingState(false, tsNow, true)), SUCCESS),
// default config with presenceMonitoringStrategyOnEachMessage true and msgInside true
Arguments.of(new GpsGeofencingActionTestCase(deviceId, true, true,
new EntityGeofencingState(false, 0, false)), ENTERED),
Arguments.of(new GpsGeofencingActionTestCase(deviceId, true, true,
new EntityGeofencingState(true, tsNow, false)), INSIDE),
Arguments.of(new GpsGeofencingActionTestCase(deviceId, true, true,
new EntityGeofencingState(true, tsNowMinusMinuteAndMillis, false)), INSIDE),
// default config with presenceMonitoringStrategyOnEachMessage true and msgInside false
Arguments.of(new GpsGeofencingActionTestCase(deviceId, false, true,
new EntityGeofencingState(false, 0, false)), LEFT),
Arguments.of(new GpsGeofencingActionTestCase(deviceId, false, true,
new EntityGeofencingState(false, tsNow, false)), OUTSIDE),
Arguments.of(new GpsGeofencingActionTestCase(deviceId, false, true,
new EntityGeofencingState(false, tsNowMinusMinuteAndMillis, false)), OUTSIDE)
);
}
@ParameterizedTest
@MethodSource
void givenReportPresenceStatusOnEachMessage_whenOnMsg_thenVerifyOutputMsgType(
GpsGeofencingActionTestCase gpsGeofencingActionTestCase,
String expectedOutput
) throws TbNodeException {
// GIVEN
var config = new TbGpsGeofencingActionNodeConfiguration().defaultConfiguration();
config.setReportPresenceStatusOnEachMessage(gpsGeofencingActionTestCase.isReportPresenceStatusOnEachMessage());
node.init(ctx, new TbNodeConfiguration(JacksonUtil.valueToTree(config)));
TbMsg msg = gpsGeofencingActionTestCase.isMsgInside() ?
getInsideRectangleTbMsg(gpsGeofencingActionTestCase.getEntityId()) :
getOutsideRectangleTbMsg(gpsGeofencingActionTestCase.getEntityId());
when(ctx.getAttributesService()).thenReturn(attributesService);
ReflectionTestUtils.setField(node, "entityStates", gpsGeofencingActionTestCase.getEntityStates());
// WHEN
node.onMsg(ctx, msg);
// THEN
verify(ctx.getAttributesService(), never()).find(any(), any(), any(), anyString());
verify(ctx, never()).tellFailure(any(), any(Throwable.class));
verify(ctx, never()).enqueueForTellNext(any(), eq(expectedOutput), any(), any());
verify(ctx, never()).ack(any());
if (SUCCESS.equals(expectedOutput)) {
verify(ctx).tellSuccess(eq(msg));
} else {
verify(ctx).tellNext(eq(msg), eq(expectedOutput));
}
}
private TbMsg getOutsideRectangleTbMsg(EntityId entityId) {
return getTbMsg(entityId, getMetadataForNewVersionPolygonPerimeter(),
GeoUtilTest.POINT_OUTSIDE_SIMPLE_RECT.getLatitude(),
GeoUtilTest.POINT_OUTSIDE_SIMPLE_RECT.getLongitude());
}
private TbMsg getInsideRectangleTbMsg(EntityId entityId) {
return getTbMsg(entityId, getMetadataForNewVersionPolygonPerimeter(),
GeoUtilTest.POINT_INSIDE_SIMPLE_RECT_CENTER.getLatitude(),
GeoUtilTest.POINT_INSIDE_SIMPLE_RECT_CENTER.getLongitude());
}
private TbMsg getTbMsg(EntityId entityId, TbMsgMetaData metadata, double latitude, double longitude) {
String data = "{\"latitude\": " + latitude + ", \"longitude\": " + longitude + "}";
return TbMsg.newMsg(TbMsgType.POST_ATTRIBUTES_REQUEST, entityId, metadata, data);
}
private TbMsgMetaData getMetadataForNewVersionPolygonPerimeter() {
var metadata = new TbMsgMetaData();
metadata.putValue("ss_perimeter", GeoUtilTest.SIMPLE_RECT);
return metadata;
}
// Rule nodes upgrade
private static Stream<Arguments> givenFromVersionAndConfig_whenUpgrade_thenVerifyHasChangesAndConfig() {
return Stream.of(
// default config for version 0
Arguments.of(0,
"{\n" +
" \"minInsideDuration\": 1,\n" +
" \"minOutsideDuration\": 1,\n" +
" \"minInsideDurationTimeUnit\": \"MINUTES\",\n" +
" \"minOutsideDurationTimeUnit\": \"MINUTES\",\n" +
" \"latitudeKeyName\": \"latitude\",\n" +
" \"longitudeKeyName\": \"longitude\",\n" +
" \"perimeterType\": \"POLYGON\",\n" +
" \"fetchPerimeterInfoFromMessageMetadata\": true,\n" +
" \"perimeterKeyName\": \"ss_perimeter\",\n" +
" \"polygonsDefinition\": null,\n" +
" \"centerLatitude\": null,\n" +
" \"centerLongitude\": null,\n" +
" \"range\": null,\n" +
" \"rangeUnit\": null\n" +
"}\n",
true,
"{\n" +
" \"minInsideDuration\": 1,\n" +
" \"minOutsideDuration\": 1,\n" +
" \"minInsideDurationTimeUnit\": \"MINUTES\",\n" +
" \"minOutsideDurationTimeUnit\": \"MINUTES\",\n" +
" \"reportPresenceStatusOnEachMessage\": false,\n" +
" \"latitudeKeyName\": \"latitude\",\n" +
" \"longitudeKeyName\": \"longitude\",\n" +
" \"perimeterType\": \"POLYGON\",\n" +
" \"fetchPerimeterInfoFromMessageMetadata\": true,\n" +
" \"perimeterKeyName\": \"ss_perimeter\",\n" +
" \"polygonsDefinition\": null,\n" +
" \"centerLatitude\": null,\n" +
" \"centerLongitude\": null,\n" +
" \"range\": null,\n" +
" \"rangeUnit\": null\n" +
"}\n"),
// default config for version 1 with upgrade from version 0
Arguments.of(0,
"{\n" +
" \"minInsideDuration\": 1,\n" +
" \"minOutsideDuration\": 1,\n" +
" \"minInsideDurationTimeUnit\": \"MINUTES\",\n" +
" \"minOutsideDurationTimeUnit\": \"MINUTES\",\n" +
" \"reportPresenceStatusOnEachMessage\": false,\n" +
" \"latitudeKeyName\": \"latitude\",\n" +
" \"longitudeKeyName\": \"longitude\",\n" +
" \"perimeterType\": \"POLYGON\",\n" +
" \"fetchPerimeterInfoFromMessageMetadata\": true,\n" +
" \"perimeterKeyName\": \"ss_perimeter\",\n" +
" \"polygonsDefinition\": null,\n" +
" \"centerLatitude\": null,\n" +
" \"centerLongitude\": null,\n" +
" \"range\": null,\n" +
" \"rangeUnit\": null\n" +
"}\n",
false,
"{\n" +
" \"minInsideDuration\": 1,\n" +
" \"minOutsideDuration\": 1,\n" +
" \"minInsideDurationTimeUnit\": \"MINUTES\",\n" +
" \"minOutsideDurationTimeUnit\": \"MINUTES\",\n" +
" \"reportPresenceStatusOnEachMessage\": false,\n" +
" \"latitudeKeyName\": \"latitude\",\n" +
" \"longitudeKeyName\": \"longitude\",\n" +
" \"perimeterType\": \"POLYGON\",\n" +
" \"fetchPerimeterInfoFromMessageMetadata\": true,\n" +
" \"perimeterKeyName\": \"ss_perimeter\",\n" +
" \"polygonsDefinition\": null,\n" +
" \"centerLatitude\": null,\n" +
" \"centerLongitude\": null,\n" +
" \"range\": null,\n" +
" \"rangeUnit\": null\n" +
"}\n")
);
}
@Override
protected TbNode getTestNode() {
return node;
}
}

18
rule-engine/rule-engine-components/src/test/java/org/thingsboard/rule/engine/util/EntitiesFieldsAsyncLoaderTest.java

@ -37,10 +37,12 @@ import org.thingsboard.server.common.data.Tenant;
import org.thingsboard.server.common.data.User;
import org.thingsboard.server.common.data.alarm.Alarm;
import org.thingsboard.server.common.data.asset.Asset;
import org.thingsboard.server.common.data.edge.Edge;
import org.thingsboard.server.common.data.id.AlarmId;
import org.thingsboard.server.common.data.id.AssetId;
import org.thingsboard.server.common.data.id.CustomerId;
import org.thingsboard.server.common.data.id.DeviceId;
import org.thingsboard.server.common.data.id.EdgeId;
import org.thingsboard.server.common.data.id.EntityId;
import org.thingsboard.server.common.data.id.EntityIdFactory;
import org.thingsboard.server.common.data.id.EntityViewId;
@ -52,6 +54,7 @@ import org.thingsboard.server.common.data.rule.RuleChain;
import org.thingsboard.server.dao.asset.AssetService;
import org.thingsboard.server.dao.customer.CustomerService;
import org.thingsboard.server.dao.device.DeviceService;
import org.thingsboard.server.dao.edge.EdgeService;
import org.thingsboard.server.dao.entityview.EntityViewService;
import org.thingsboard.server.dao.rule.RuleChainService;
import org.thingsboard.server.dao.tenant.TenantService;
@ -95,6 +98,8 @@ public class EntitiesFieldsAsyncLoaderTest {
private RuleChainService ruleChainServiceMock;
@Mock
private EntityViewService entityViewServiceMock;
@Mock
private EdgeService edgeServiceMock;
@BeforeAll
public static void setup() {
@ -108,7 +113,8 @@ public class EntitiesFieldsAsyncLoaderTest {
EntityType.DEVICE,
EntityType.ALARM,
EntityType.RULE_CHAIN,
EntityType.ENTITY_VIEW
EntityType.ENTITY_VIEW,
EntityType.EDGE
);
}
@ -228,6 +234,14 @@ public class EntitiesFieldsAsyncLoaderTest {
when(ctxMock.getEntityViewService()).thenReturn(entityViewServiceMock);
doReturn(entityView).when(entityViewServiceMock).findEntityViewByIdAsync(eq(TENANT_ID), any());
break;
case EDGE:
var edge = Futures.immediateFuture(entityDoesNotExist ? null : new Edge(new EdgeId(RANDOM_UUID)));
when(ctxMock.getDbCallbackExecutor()).thenReturn(DB_EXECUTOR);
when(ctxMock.getEdgeService()).thenReturn(edgeServiceMock);
doReturn(edge).when(edgeServiceMock).findEdgeByIdAsync(eq(TENANT_ID), any());
break;
default:
throw new RuntimeException("Unexpected EntityType: " + entityType);
@ -252,6 +266,8 @@ public class EntitiesFieldsAsyncLoaderTest {
return new RuleChain((RuleChainId) entityId);
case ENTITY_VIEW:
return new EntityView((EntityViewId) entityId);
case EDGE:
return new Edge((EdgeId) entityId);
default:
throw new RuntimeException("Unexpected EntityType: " + entityId.getEntityType());
}

1
ui-ngx/src/app/core/api/widget-api.models.ts

@ -82,6 +82,7 @@ export interface RpcApi {
export interface IWidgetUtils {
formatValue: (value: any, dec?: number, units?: string, showZeroDecimals?: boolean) => string | undefined;
getEntityDetailsPageURL: (id: string, entityType: EntityType) => string;
}
export interface WidgetActionsApi {

37
ui-ngx/src/app/core/http/image.service.ts

@ -18,7 +18,7 @@ import { Injectable } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { PageLink } from '@shared/models/page/page-link';
import { defaultHttpOptionsFromConfig, defaultHttpUploadOptions, RequestConfig } from '@core/http/http-utils';
import { Observable, of } from 'rxjs';
import { Observable, of, ReplaySubject } from 'rxjs';
import { PageData } from '@shared/models/page/page-data';
import {
NO_IMAGE_DATA_URI,
@ -36,6 +36,9 @@ import { ResourcesService } from '@core/services/resources.service';
providedIn: 'root'
})
export class ImageService {
private imagesLoading: { [url: string]: ReplaySubject<Blob> } = {};
constructor(
private http: HttpClient,
private sanitizer: DomSanitizer,
@ -95,12 +98,34 @@ export class ImageService {
parts[parts.length - 1] = encodeURIComponent(key);
const encodedUrl = parts.join('/');
const imageLink = preview ? (encodedUrl + '/preview') : encodedUrl;
const options = defaultHttpOptionsFromConfig({ignoreLoading: true, ignoreErrors: true});
return this.http
.get(imageLink, {...options, ...{ responseType: 'blob' } }).pipe(
return this.loadImageDataUrl(imageLink, asString, emptyUrl);
}
private loadImageDataUrl(imageLink: string, asString = false, emptyUrl = NO_IMAGE_DATA_URI): Observable<SafeUrl | string> {
let request: ReplaySubject<Blob>;
if (this.imagesLoading[imageLink]) {
request = this.imagesLoading[imageLink];
} else {
request = new ReplaySubject<Blob>(1);
this.imagesLoading[imageLink] = request;
const options = defaultHttpOptionsFromConfig({ignoreLoading: true, ignoreErrors: true});
this.http.get(imageLink, {...options, ...{ responseType: 'blob' } }).subscribe({
next: (value) => {
request.next(value);
request.complete();
},
error: err => {
request.error(err);
},
complete: () => {
delete this.imagesLoading[imageLink];
}
});
}
return request.pipe(
switchMap(val => blobToBase64(val).pipe(
map((dataUrl) => asString ? dataUrl : this.sanitizer.bypassSecurityTrustUrl(dataUrl))
)),
map((dataUrl) => asString ? dataUrl : this.sanitizer.bypassSecurityTrustUrl(dataUrl))
)),
catchError(() => of(asString ? emptyUrl : this.sanitizer.bypassSecurityTrustUrl(emptyUrl)))
);
}

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

@ -33,6 +33,7 @@ export class NotificationMessage {
horizontalPosition?: NotificationHorizontalPosition;
verticalPosition?: NotificationVerticalPosition;
panelClass?: string | string[];
modern?: boolean;
}
export class HideNotification {

8
ui-ngx/src/app/core/services/item-buffer.service.ts

@ -27,7 +27,7 @@ import {
widgetType
} from '@shared/models/widget.models';
import { DashboardUtilsService } from '@core/services/dashboard-utils.service';
import { deepClone, isEqual } from '@core/utils';
import { deepClone, isDefinedAndNotNull, isEqual } from '@core/utils';
import { UtilsService } from '@core/services/utils.service';
import { Observable, of, throwError } from 'rxjs';
import { map } from 'rxjs/operators';
@ -309,6 +309,12 @@ export class ItemBufferService {
if (origNode.error) {
node.error = origNode.error;
}
if (isDefinedAndNotNull(origNode.singletonMode)) {
node.singletonMode = origNode.singletonMode;
}
if (isDefinedAndNotNull(origNode.queueName)) {
node.queueName = origNode.queueName;
}
ruleNodes.nodes.push(node);
if (i === 0) {
top = node.y;

30
ui-ngx/src/app/modules/common/modules-map.ts

@ -194,6 +194,17 @@ import * as ScrollGridComponent from '@shared/components/grid/scroll-grid.compon
import * as GalleryImageInputComponent from '@shared/components/image/gallery-image-input.component';
import * as MultipleGalleryImageInputComponent from '@shared/components/image/multiple-gallery-image-input.component';
import * as CssUnitSelectComponent from '@home/components/widget/lib/settings/common/css-unit-select.component';
import * as WidgetActionsPanelComponent from '@home/components/widget/config/basic/common/widget-actions-panel.component';
import * as FontSettingsComponent from '@home/components/widget/lib/settings/common/font-settings.component';
import * as ColorSettingsComponent from '@home/components/widget/lib/settings/common/color-settings.component';
import * as DisplayColumnsPanelComponent from '@home/components/widget/lib/display-columns-panel.component';
import * as AlarmDetailsDialogComponent from '@home/components/alarm/alarm-details-dialog.component';
import * as AlarmAssigneePanelComponent from '@home/components/alarm/alarm-assignee-panel.component';
import * as AlarmCommentDialogComponent from '@home/components/alarm/alarm-comment-dialog.component';
import * as AlarmFilterConfigComponent from '@home/components/alarm/alarm-filter-config.component';
import * as DatasourceComponent from '@home/components/widget/config/datasources.component';
import * as DataKeysPanelComponent from '@home/components/widget/config/basic/common/data-keys-panel.component';
import * as AddEntityDialogComponent from '@home/components/entity/add-entity-dialog.component';
import * as EntitiesTableComponent from '@home/components/entity/entities-table.component';
import * as DetailsPanelComponent from '@home/components/details-panel.component';
@ -239,6 +250,10 @@ import * as ImportDialogCsvComponent from '@shared/import-export/import-dialog-c
import * as TableColumnsAssignmentComponent from '@shared/import-export/table-columns-assignment.component';
import * as EventContentDialogComponent from '@home/components/event/event-content-dialog.component';
import * as SharedHomeComponentsModule from '@home/components/shared-home-components.module';
import * as WidgetConfigComponentsModule from '@home/components/widget/config/widget-config-components.module';
import * as BasicWidgetConfigModule from '@home/components/widget/config/basic/basic-widget-config.module';
import * as WidgetSettingsCommonModule from '@home/components/widget/lib/settings/common/widget-settings-common.module';
import * as WidgetComponentsModule from '@home/components/widget/widget-components.module';
import * as SelectTargetLayoutDialogComponent from '@home/components/dashboard/select-target-layout-dialog.component';
import * as SelectTargetStateDialogComponent from '@home/components/dashboard/select-target-state-dialog.component';
import * as AliasesEntityAutocompleteComponent from '@home/components/alias/aliases-entity-autocomplete.component';
@ -507,6 +522,17 @@ class ModulesMap implements IModulesMap {
'@shared/components/image/gallery-image-input.component': GalleryImageInputComponent,
'@shared/components/image/multiple-gallery-image-input.component': MultipleGalleryImageInputComponent,
'@home/components/alarm/alarm-filter-config.component': AlarmFilterConfigComponent,
'@home/components/alarm/alarm-comment-dialog.component': AlarmCommentDialogComponent,
'@home/components/alarm/alarm-assignee-panel.component': AlarmAssigneePanelComponent,
'@home/components/alarm/alarm-details-dialog.component': AlarmDetailsDialogComponent,
'@home/components/widget/lib/display-columns-panel.component': DisplayColumnsPanelComponent,
'@home/components/widget/config/datasources.component': DatasourceComponent,
'@home/components/widget/config/basic/common/data-keys-panel.component': DataKeysPanelComponent,
'@home/components/widget/lib/settings/common/color-settings.component': ColorSettingsComponent,
'@home/components/widget/lib/settings/common/font-settings.component': FontSettingsComponent,
'@home/components/widget/config/basic/common/widget-actions-panel.component': WidgetActionsPanelComponent,
'@home/components/widget/lib/settings/common/css-unit-select.component': CssUnitSelectComponent,
'@home/components/entity/add-entity-dialog.component': AddEntityDialogComponent,
'@home/components/entity/entities-table.component': EntitiesTableComponent,
'@home/components/details-panel.component': DetailsPanelComponent,
@ -549,6 +575,10 @@ class ModulesMap implements IModulesMap {
'@home/components/attribute/add-widget-to-dashboard-dialog.component': AddWidgetToDashboardDialogComponent,
'@home/components/event/event-content-dialog.component': EventContentDialogComponent,
'@home/components/shared-home-components.module': SharedHomeComponentsModule,
'@home/components/widget/config/widget-config-components.module': WidgetConfigComponentsModule,
'@home/components/widget/config/basic/basic-widget-config.module': BasicWidgetConfigModule,
'@home/components/widget/lib/settings/common/widget-settings-common.module': WidgetSettingsCommonModule,
'@home/components/widget/widget-components.module': WidgetComponentsModule,
'@home/components/dashboard/select-target-layout-dialog.component': SelectTargetLayoutDialogComponent,
'@home/components/dashboard/select-target-state-dialog.component': SelectTargetStateDialogComponent,
'@home/components/alias/aliases-entity-autocomplete.component': AliasesEntityAutocompleteComponent,

8
ui-ngx/src/app/modules/home/components/dashboard/dashboard.component.ts

@ -214,7 +214,7 @@ export class DashboardComponent extends PageComponent implements IDashboardCompo
disableAutoPositionOnConflict: false,
pushItems: false,
swap: false,
maxRows: 100,
maxRows: 3000,
minCols: this.columns ? this.columns : 24,
maxCols: 3000,
maxItemCols: 1000,
@ -293,12 +293,12 @@ export class DashboardComponent extends PageComponent implements IDashboardCompo
this.dashboardTimewindowChangedSubject.next(this.dashboardTimewindow);
}
if (updateMobileOpts) {
this.updateMobileOpts();
}
if (updateLayoutOpts) {
this.updateLayoutOpts();
}
if (updateMobileOpts) {
this.updateMobileOpts();
}
if (updateEditingOpts) {
this.updateEditingOpts();
}

2
ui-ngx/src/app/modules/home/components/filter/filters-edit.component.ts

@ -119,7 +119,7 @@ export class FiltersEditComponent implements OnInit, OnDestroy {
const filteredArray = Object.entries(this.filtersInfo);
if (filteredArray.length === 1) {
const singleFilter: Filter = {id: filteredArray[0][0], ...filteredArray[0][1]};
const singleFilter: Filter = {id: filteredArray[0][0], ...deepClone(filteredArray[0][1])};
this.dialog.open<UserFilterDialogComponent, UserFilterDialogData,
Filter>(UserFilterDialogComponent, {
disableClose: true,

2
ui-ngx/src/app/modules/home/components/profile/device/snmp/snmp-device-profile-communication-config.component.html

@ -19,7 +19,7 @@
<div *ngFor="let deviceProfileCommunication of communicationConfigFormArray.controls; let $index = index;
last as isLast;" fxLayout="row" fxLayoutAlign="start center"
fxLayoutGap="8px" class="scope-row" [formGroup]="deviceProfileCommunication">
<div class="communication-config" fxFlex fxLayout="row" fxLayoutGap="8px" fxLayoutAlign="start">
<div class="communication-config">
<mat-form-field class="spec mat-block" floatLabel="always" hideRequiredMarker>
<mat-label translate>device-profile.snmp.scope</mat-label>
<mat-select formControlName="spec" required>

15
ui-ngx/src/app/modules/home/components/profile/device/snmp/snmp-device-profile-communication-config.component.scss

@ -13,12 +13,20 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
@import '../scss/constants';
:host {
.communication-config {
border: 2px groove rgba(0, 0, 0, 0.25);
border-radius: 4px;
padding: 8px;
min-width: 0;
flex-direction: column;
display: flex;
place-content: stretch flex-start;
align-items: stretch;
flex: 1;
gap: 0;
}
.scope-row {
@ -28,6 +36,13 @@
.required-text {
margin: 16px 0
}
@media #{$mat-gt-xmd} {
.communication-config {
flex-direction: row;
gap: 8px;
}
}
}
:host ::ng-deep {

6
ui-ngx/src/app/modules/home/components/profile/device/snmp/snmp-device-profile-mapping.component.html

@ -19,8 +19,8 @@
<div fxLayout="row" fxLayoutAlign="start center" fxLayoutGap="8px" fxFlex="100">
<div fxFlex fxLayout="row" fxLayoutGap="8px">
<label fxFlex="26" class="tb-title no-padding" translate>device-profile.snmp.data-type</label>
<label fxFlex="37" class="tb-title no-padding" translate>device-profile.snmp.data-key</label>
<label fxFlex="37" class="tb-title no-padding" translate>device-profile.snmp.oid</label>
<label fxFlex="37" class="tb-title tb-required no-padding" translate>device-profile.snmp.data-key</label>
<label fxFlex="37" class="tb-title tb-required no-padding" translate>device-profile.snmp.oid</label>
<span style="min-width: 40px" [fxShow]="!disabled"></span>
</div>
</div>
@ -28,7 +28,7 @@
<div *ngFor="let mappingConfig of mappingsConfigFormArray.controls; let $index = index;
last as isLast;" fxLayout="row" fxLayoutAlign="start center"
fxLayoutGap="8px" [formGroup]="mappingConfig" class="mapping-list">
<div fxFlex fxLayout="row" fxLayoutGap="8px" fxLayoutAlign="start">
<div class="tb-layout-fill" fxFlex fxLayout="row" fxLayoutGap="8px" fxLayoutAlign="start">
<mat-form-field fxFlex="26">
<mat-select formControlName="dataType" required>
<mat-option *ngFor="let dataType of dataTypes" [value]="dataType">

10
ui-ngx/src/app/modules/home/components/profile/device/snmp/snmp-device-profile-mapping.component.scss

@ -15,13 +15,9 @@
*/
:host {
.mapping-config {
min-width: 518px;
}
.mapping-list {
padding-bottom: 8px;
}
.required-text {
margin: 14px 0;
.required-text {
margin: 14px 0;
}
}
}

6
ui-ngx/src/app/modules/home/components/public-api.ts

@ -15,5 +15,11 @@
///
export * from './home-components.module';
export * from './widget/config/basic/basic-widget-config.module';
export * from './widget/lib/settings/common/widget-settings-common.module';
export * from './widget/widget-components.module';
export * from './widget/config/widget-config-components.module';
export * from './widget/config/widget-config.component.models';
export * from './widget/lib/table-widget.models';
export * from './widget/lib/flot-widget.models';

4
ui-ngx/src/app/modules/home/components/vc/repository-settings.component.html

@ -26,9 +26,9 @@
<span fxFlex></span>
<div tb-help="repositorySettings"></div>
</mat-card-header>
<mat-progress-bar color="warn" mode="indeterminate" *ngIf="isLoading$ | async">
<mat-progress-bar color="warn" mode="indeterminate" *ngIf="!hideLoadingBar && isLoading$ | async">
</mat-progress-bar>
<div style="height: 4px;" *ngIf="!(isLoading$ | async)"></div>
<div style="height: 4px;" *ngIf="hideLoadingBar || !(isLoading$ | async)"></div>
<mat-card-content style="padding-top: 16px;">
<form [formGroup]="repositorySettingsForm" #formDirective="ngForm" (ngSubmit)="save()">
<fieldset [disabled]="isLoading$ | async">

5
ui-ngx/src/app/modules/home/components/vc/repository-settings.component.ts

@ -34,6 +34,7 @@ import { selectHasRepository } from '@core/auth/auth.selectors';
import { catchError, mergeMap, take } from 'rxjs/operators';
import { of } from 'rxjs';
import { TbPopoverComponent } from '@shared/components/popover.component';
import { coerceBoolean } from '@shared/decorators/coercion';
@Component({
selector: 'tb-repository-settings',
@ -48,6 +49,10 @@ export class RepositorySettingsComponent extends PageComponent implements OnInit
@Input()
popoverComponent: TbPopoverComponent;
@Input()
@coerceBoolean()
hideLoadingBar = false;
repositorySettingsForm: UntypedFormGroup;
settings: RepositorySettings = null;

6
ui-ngx/src/app/modules/home/components/vc/version-control.component.html

@ -15,9 +15,11 @@
limitations under the License.
-->
<tb-repository-settings #repositorySettingsComponent [detailsMode]="detailsMode"
<tb-repository-settings #repositorySettingsComponent
[detailsMode]="detailsMode"
hideLoadingBar
[popoverComponent]="popoverComponent"
*ngIf="!(hasRepository$ | async); else versionsTable">
*ngIf="!(hasRepository$ | async); else versionsTable">
</tb-repository-settings>
<ng-template #versionsTable>
<tb-entity-versions-table [singleEntityMode]="singleEntityMode"

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

@ -34,7 +34,7 @@
<mat-slide-toggle class="mat-slide fixed-title-width" formControlName="showTitle">
{{ 'widgets.liquid-level-card.title' | translate }}
</mat-slide-toggle>
<div fxFlex fxLayout="row" fxLayoutAlign="start center" fxLayoutGap="8px">
<div class="tb-flex row flex-start align-center">
<mat-form-field class="flex" appearance="outline" subscriptSizing="dynamic">
<input matInput formControlName="title" placeholder="{{ 'widget-config.set' | translate }}">
</mat-form-field>
@ -49,7 +49,7 @@
<mat-slide-toggle class="mat-slide fixed-title-width" formControlName="showTitleIcon">
{{ 'widgets.liquid-level-card.icon' | translate }}
</mat-slide-toggle>
<div fxFlex fxLayout="row" fxLayoutAlign="end center" fxLayoutGap="8px">
<div class="tb-flex row flex-end align-center">
<mat-form-field appearance="outline" class="flex number" subscriptSizing="dynamic">
<input matInput type="number" min="0" formControlName="iconSize" placeholder="{{ 'widget-config.set' | translate }}">
</mat-form-field>
@ -64,7 +64,7 @@
</div>
</div>
<div fxFlex fxLayout="column" class="tb-form-row space-between">
<div fxFlex fxLayout="row" style="width: 100%;" fxLayoutAlign="space-between center">
<div class="tb-flex row space-between align-center no-gap fill-width">
<div class="fixed-title-width" translate>widgets.liquid-level-card.shape</div>
<tb-toggle-select formControlName="tankSelectionType">
<tb-toggle-option *ngFor="let type of DataSourceTypes" [value]="type">
@ -96,7 +96,7 @@
formControlName="shapeAttributeName">
</tb-string-autocomplete>
</div>
<div fxFlex fxLayout="row" fxLayoutAlign="space-between center" style="width: 100%;">
<div class="tb-flex row space-between align-center no-gap fill-width">
<div class="tb-hint" style="padding: 0;" translate>widgets.liquid-level-card.shape-by-attribute</div>
<div class="see-example"
[tb-help-popup]="'widget/lib/indicator/shape_attribute_fn'"
@ -123,16 +123,16 @@
<div class="tb-form-panel-title" translate>widgets.liquid-level-card.units</div>
<div class="tb-form-row space-between">
<div class="fixed-title-width tb-required" translate>widgets.liquid-level-card.datasource-units</div>
<div fxFlex fxLayout="row" fxLayoutAlign="end center">
<div class="tb-flex row flex-end align-center no-gap">
<tb-unit-input required style="max-width: 25%;" class="flex"
[tagFilter]="unitsType.capacity"
formControlName="datasourceUnits">
</tb-unit-input>
</div>
</div>
<div class="tb-form-row space-between" *ngIf="levelCardWidgetConfigForm.get('layout').value === LevelCardLayout.absolute">
<div class="tb-form-row space-between column-xs" *ngIf="levelCardWidgetConfigForm.get('layout').value === LevelCardLayout.absolute">
<div class="fixed-title-width tb-required" translate>widgets.liquid-level-card.widget-units</div>
<div fxFlex fxLayout="row" fxLayoutAlign="start center" fxLayoutGap="8px">
<div class="tb-flex row flex-start align-center">
<mat-form-field class="flex" appearance="outline" subscriptSizing="dynamic" style="max-width: 25%">
<mat-select formControlName="widgetUnitsSource" placeholder="{{ 'widget-config.set' | translate }}">
<mat-option *ngFor="let type of DataSourceTypes" [value]="type">
@ -154,9 +154,9 @@
</ng-template>
</div>
</div>
<div class="tb-form-row" [fxShow]="volumeInput">
<div class="tb-form-row column-xs" [fxShow]="volumeInput">
<div class="fixed-title-width tb-required" translate>widgets.liquid-level-card.total-volume</div>
<div fxFlex fxLayout="row" fxLayoutAlign="start center" fxLayoutGap="8px">
<div class="tb-flex row flex-start align-center">
<mat-form-field class="flex" appearance="outline" subscriptSizing="dynamic" style="max-width: 25%">
<mat-select formControlName="volumeSource" placeholder="{{ 'widget-config.set' | translate }}">
<mat-option *ngFor="let type of DataSourceTypes" [value]="type">
@ -184,10 +184,30 @@
formControlName="volumeAttributeName">
</tb-string-autocomplete>
</ng-template>
<tb-unit-input [tagFilter]="unitsType.capacity"
required style="max-width: 25%" class="flex"
</div>
</div>
<div class="tb-form-row space-between column-xs" [fxShow]="volumeInput">
<div class="fixed-title-width tb-required" translate>widgets.liquid-level-card.total-volume-units</div>
<div class="tb-flex row flex-start align-center">
<mat-form-field class="flex" appearance="outline" subscriptSizing="dynamic" style="max-width: 25%">
<mat-select formControlName="volumeUnitsSource" placeholder="{{ 'widget-config.set' | translate }}">
<mat-option *ngFor="let type of DataSourceTypes" [value]="type">
{{ DataSourceTypeTranslations.get(type) | translate }}
</mat-option>
</mat-select>
</mat-form-field>
<tb-unit-input *ngIf="levelCardWidgetConfigForm.get('volumeUnitsSource').value === DataSourceType.static; else selectVolumeUnitsAttributes"
class="flex" required
[tagFilter]="unitsType.capacity"
formControlName="volumeUnits">
</tb-unit-input>
<ng-template #selectVolumeUnitsAttributes>
<tb-string-autocomplete [fetchOptionsFn]="fetchOptions.bind(this)"
required style="flex: 1"
[errorText]="'widgets.liquid-level-card.attribute-name-required' | translate"
formControlName="volumeUnitsAttributeName">
</tb-string-autocomplete>
</ng-template>
</div>
</div>
</div>
@ -206,7 +226,7 @@
<div class="tb-form-row space-between"
*ngIf="levelCardWidgetConfigForm.get('layout').value !== LevelCardLayout.simple">
<div class="fixed-title-width" translate>widgets.liquid-level-card.value</div>
<div fxFlex fxLayout="row" fxLayoutAlign="end center" fxLayoutGap="8px">
<div class="tb-flex row flex-end align-center">
<mat-form-field style="max-width: 40%;" appearance="outline" class="flex number" subscriptSizing="dynamic">
<input matInput formControlName="decimals" type="number"
min="0" max="15" step="1" placeholder="{{ 'widget-config.set' | translate }}">
@ -221,7 +241,7 @@
</div>
<div class="tb-form-row" *ngIf="levelCardWidgetConfigForm.get('layout').value === LevelCardLayout.absolute">
<div class="fixed-title-width" translate>widgets.liquid-level-card.total-volume</div>
<div fxFlex fxLayout="row" fxLayoutAlign="end center" fxLayoutGap="8px">
<div class="tb-flex row flex-end align-center">
<tb-font-settings formControlName="volumeFont"
[previewText]="totalVolumeValuePreviewFn">
</tb-font-settings>
@ -247,7 +267,7 @@
<mat-slide-toggle class="mat-slide fixed-title-width" formControlName="showTooltipLevel">
{{ 'widgets.liquid-level-card.level' | translate }}
</mat-slide-toggle>
<div fxFlex fxLayout="row" fxLayoutAlign="end center" fxLayoutGap="8px">
<div class="tb-flex row flex-end align-center">
<tb-unit-input required class="flex"
[tagFilter]="unitsType.capacity"
formControlName="tooltipUnits">
@ -268,7 +288,7 @@
<mat-slide-toggle class="mat-slide fixed-title-width" formControlName="showTooltipDate">
{{ 'widgets.value-card.date' | translate }}
</mat-slide-toggle>
<div fxFlex.gt-xs fxLayout="row" fxLayoutAlign="start center" fxLayoutGap="8px">
<div class="tb-flex row flex-start align-center">
<tb-date-format-select fxFlex formControlName="tooltipDateFormat"></tb-date-format-select>
<tb-font-settings formControlName="tooltipDateFont"
[previewText]="datePreviewFn">

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

@ -194,7 +194,9 @@ export class LiquidLevelCardBasicConfigComponent extends BasicWidgetConfigCompon
volumeSource: [settings.volumeSource, []],
volumeConstant: [settings.volumeConstant, [Validators.required, Validators.min(0.1)]],
volumeAttributeName: [settings.volumeAttributeName, [Validators.required]],
volumeUnitsSource: [settings.volumeUnitsSource, []],
volumeUnits: [settings.volumeUnits, [Validators.required]],
volumeUnitsAttributeName: [settings.volumeUnitsAttributeName, [Validators.required]],
volumeFont: [settings.volumeFont, []],
volumeColor: [settings.volumeColor, []],
units: [settings.units, [Validators.required]],
@ -260,6 +262,8 @@ export class LiquidLevelCardBasicConfigComponent extends BasicWidgetConfigCompon
this.widgetConfig.config.settings.volumeSource = config.volumeSource;
this.widgetConfig.config.settings.volumeConstant = config.volumeConstant;
this.widgetConfig.config.settings.volumeAttributeName = config.volumeAttributeName;
this.widgetConfig.config.settings.volumeUnitsSource = config.volumeUnitsSource;
this.widgetConfig.config.settings.volumeUnitsAttributeName = config.volumeUnitsAttributeName;
this.widgetConfig.config.settings.volumeUnits = config.volumeUnits;
this.widgetConfig.config.settings.volumeFont = config.volumeFont;
this.widgetConfig.config.settings.volumeColor = config.volumeColor;
@ -294,7 +298,7 @@ export class LiquidLevelCardBasicConfigComponent extends BasicWidgetConfigCompon
protected validatorTriggers(): string[] {
return [
'showTooltip', 'showTooltipLevel', 'tankSelectionType', 'datasourceUnits', 'showTitleIcon', 'volumeSource',
'showTooltipDate', 'layout', 'showTitle', 'widgetUnitsSource'
'showTooltipDate', 'layout', 'showTitle', 'widgetUnitsSource', 'volumeUnitsSource'
];
}

2
ui-ngx/src/app/modules/home/components/widget/lib/cards/aggregated-value-card-widget.component.ts

@ -241,8 +241,8 @@ export class AggregatedValueCardWidgetComponent implements OnInit, AfterViewInit
} else {
aggValue.value = 'N/A';
}
aggValue.color.update(value);
const numeric = formatNumberValue(value, (aggValue.key.decimals || this.ctx.decimals));
aggValue.color.update(numeric);
if (aggValue.showArrow && isDefined(numeric)) {
aggValue.upArrow = numeric > 0;
aggValue.downArrow = numeric < 0;

8
ui-ngx/src/app/modules/home/components/widget/lib/gateway/gateway-configuration.component.ts

@ -127,11 +127,11 @@ export class GatewayConfigurationComponent implements OnInit {
type: [StorageTypes.MEMORY, [Validators.required]],
read_records_count: [100, [Validators.min(1), Validators.pattern(/^-?[0-9]+$/), Validators.required, Validators.pattern(/^[^.\s]+$/)]],
max_records_count: [100000, [Validators.min(1), Validators.pattern(/^-?[0-9]+$/), Validators.required, Validators.pattern(/^[^.\s]+$/)]],
data_folder_path: ['./data/', [Validators.pattern(/^[^\s]+$/)]],
data_folder_path: ['./data/', [Validators.required]],
max_file_count: [10, [Validators.min(1), Validators.pattern(/^-?[0-9]+$/)]],
max_read_records_count: [10, [Validators.min(1), Validators.pattern(/^-?[0-9]+$/)]],
max_records_per_file: [10000, [Validators.min(1), Validators.pattern(/^-?[0-9]+$/)]],
data_file_path: ['./data/data.db', [Validators.pattern(/^[^\s]+$/)]],
data_file_path: ['./data/data.db', [Validators.required]],
messages_ttl_check_in_hours: [1, [Validators.min(1), Validators.pattern(/^-?[0-9]+$/)]],
messages_ttl_in_days: [7, [Validators.min(1), Validators.pattern(/^-?[0-9]+$/)]],
@ -240,7 +240,7 @@ export class GatewayConfigurationComponent implements OnInit {
storageGroup.get('read_records_count').updateValueAndValidity({emitEvent: false});
storageGroup.get('max_records_count').updateValueAndValidity({emitEvent: false});
} else if (type === StorageTypes.FILE) {
storageGroup.get('data_folder_path').addValidators([Validators.required, Validators.pattern(/^[^.\s]+$/)]);
storageGroup.get('data_folder_path').addValidators([Validators.required]);
storageGroup.get('max_file_count').addValidators(
[Validators.min(1), Validators.pattern(/^-?[0-9]+$/), Validators.required]);
storageGroup.get('max_read_records_count').addValidators(
@ -252,7 +252,7 @@ export class GatewayConfigurationComponent implements OnInit {
storageGroup.get('max_read_records_count').updateValueAndValidity({emitEvent: false});
storageGroup.get('max_records_per_file').updateValueAndValidity({emitEvent: false});
} else if (type === StorageTypes.SQLITE) {
storageGroup.get('data_file_path').addValidators([Validators.required, Validators.pattern(/^[^.\s]+$/)]);
storageGroup.get('data_file_path').addValidators([Validators.required]);
storageGroup.get('messages_ttl_check_in_hours').addValidators(
[Validators.min(1), Validators.pattern(/^-?[0-9]+$/), Validators.required]);
storageGroup.get('messages_ttl_in_days').addValidators(

2
ui-ngx/src/app/modules/home/components/widget/lib/gateway/gateway-connectors.component.html

@ -80,7 +80,7 @@
</ng-container>
<ng-container matColumnDef="actions" stickyEnd>
<mat-header-cell *matHeaderCellDef
[ngStyle.gt-md]="{ minWidth: '144px', maxWidth: '144px', width: '144px'}">
[ngStyle.gt-md]="{ minWidth: '144px', maxWidth: '144px', width: '144px', textAlign: 'center'}">
{{ 'gateway.connectors-table-actions' | translate }}
</mat-header-cell>
<mat-cell *matCellDef="let attribute"

7
ui-ngx/src/app/modules/home/components/widget/lib/gateway/gateway-connectors.component.scss

@ -30,6 +30,13 @@
flex-direction: column;
}
& > section:not(.table-section) {
max-width: unset;
@media #{$mat-gt-md} {
max-width: 50%;
}
}
.table-section {
min-height: 35vh;
overflow: hidden;

3
ui-ngx/src/app/modules/home/components/widget/lib/gateway/gateway-connectors.component.ts

@ -366,7 +366,7 @@ export class GatewayConnectorComponent extends PageComponent implements AfterVie
type: 'success',
duration: 1000,
verticalPosition: 'top',
horizontalPosition: 'right',
horizontalPosition: 'left',
target: 'dashboardRoot',
forceDismiss: true
}));
@ -381,6 +381,7 @@ export class GatewayConnectorComponent extends PageComponent implements AfterVie
if ($event) {
$event.stopPropagation();
}
this.initialConnector = attribute.value;
const title = `Delete connector ${attribute.key}?`;
const content = `All connector data will be deleted.`;
this.dialogService.confirm(title, content, 'Cancel', 'Delete').subscribe(result => {

63
ui-ngx/src/app/modules/home/components/widget/lib/indicator/liquid-level-widget.component.ts

@ -106,6 +106,7 @@ export class LiquidLevelWidgetComponent implements OnInit {
private volume: number;
private tooltipContent: string;
private widgetUnits: string;
private volumeUnits: string;
private capacityUnits = Object.values(CapacityUnits);
@ -128,7 +129,7 @@ export class LiquidLevelWidgetComponent implements OnInit {
this.getData().subscribe(data => {
if (data) {
const { svg, volume, units } = data;
const { svg, volume, units, volumeUnits } = data;
if (svg && isNotEmptyStr(svg) && this.liquidLevelContent.nativeElement) {
const jQueryContainerElement = $(this.liquidLevelContent.nativeElement);
jQueryContainerElement.html(svg);
@ -145,6 +146,10 @@ export class LiquidLevelWidgetComponent implements OnInit {
this.volume = Number(volume);
}
if (volumeUnits) {
this.volumeUnits = volumeUnits;
}
if (units) {
this.widgetUnits = units;
}
@ -164,7 +169,7 @@ export class LiquidLevelWidgetComponent implements OnInit {
this.tooltipDateFormat = DateFormatProcessor.fromSettings(this.ctx.$injector, this.settings.tooltipDateFormat);
}
private getData(): Observable<{ svg: string; volume: number; units: string }> {
private getData(): Observable<{ svg: string; volume: number; units: string; volumeUnits: string}> {
if (this.ctx.datasources?.length) {
const entityId: EntityId = {
entityType: this.ctx.datasources[0].entityType,
@ -308,7 +313,7 @@ export class LiquidLevelWidgetComponent implements OnInit {
.pipe(map(attributes => {
const shape = extractValue<Shapes>(attributes, this.settings.shapeAttributeName);
if (!shape || !svgMapping.has(shape)) {
this.createdErrorMgs(this.settings.shapeAttributeName, isUndefinedOrNull(shape) || isEmptyStr(shape));
this.createdErrorMsg(this.settings.shapeAttributeName, isUndefinedOrNull(shape) || isEmptyStr(shape));
return this.settings.selectedShape;
}
return shape;
@ -318,12 +323,15 @@ export class LiquidLevelWidgetComponent implements OnInit {
return of(this.settings.selectedShape);
}
private getTankersParams(entityId: EntityId): Observable<{ volume: number; units: string }> {
private getTankersParams(entityId: EntityId): Observable<{ volume: number; units: string; volumeUnits: string }> {
const isVolumeStatic = this.settings.layout !== LevelCardLayout.absolute
&& this.settings.datasourceUnits === CapacityUnits.percent
|| this.settings.volumeSource === LiquidWidgetDataSourceType.static;
const isUnitStatic = this.settings.layout !== LevelCardLayout.absolute ||
this.settings.widgetUnitsSource === LiquidWidgetDataSourceType.static;
const isVolumeUnitStatic = this.settings.layout !== LevelCardLayout.absolute
&& this.settings.datasourceUnits === CapacityUnits.percent
|| this.settings.volumeUnitsSource === LiquidWidgetDataSourceType.static;
const attributeKeys: string[] = [];
@ -335,20 +343,29 @@ export class LiquidLevelWidgetComponent implements OnInit {
attributeKeys.push(this.settings.widgetUnitsAttributeName);
}
if (!isVolumeUnitStatic) {
attributeKeys.push(this.settings.volumeUnitsAttributeName);
}
if (!attributeKeys.length || entityId.id === NULL_UUID) {
return of({
volume: this.settings.volumeConstant,
volumeUnits: this.settings.volumeUnits,
units: this.settings.units
});
}
return this.ctx.attributeService.getEntityAttributes(entityId, null, attributeKeys).pipe(
map(attributes => {
let volume = isVolumeStatic ? this.settings.volumeConstant : extractValue<number>(attributes, this.settings.volumeAttributeName);
let units = isUnitStatic ? this.settings.units : extractValue<string>(attributes, this.settings.widgetUnitsAttributeName);
let volume = isVolumeStatic ? this.settings.volumeConstant :
extractValue<number>(attributes, this.settings.volumeAttributeName);
let volumeUnits = isVolumeUnitStatic ? this.settings.volumeUnits :
extractValue<string>(attributes, this.settings.volumeUnitsAttributeName);
let units = isUnitStatic ? this.settings.units :
extractValue<string>(attributes, this.settings.widgetUnitsAttributeName);
if (!isVolumeStatic && (!volume || !isNumeric(volume) || volume < 0.1)) {
this.createdErrorMgs(this.settings.volumeAttributeName, isUndefinedOrNull(volume) || isEmptyStr(volume));
this.createdErrorMsg(this.settings.volumeAttributeName, isUndefinedOrNull(volume) || isEmptyStr(volume));
volume = this.settings.volumeConstant;
}
@ -358,20 +375,33 @@ export class LiquidLevelWidgetComponent implements OnInit {
units = this.capacityUnits.find(unit => unit.normalize() === normalizeUnits);
}
if (isUndefinedOrNull(units) || !isNotEmptyStr(units)) {
this.createdErrorMgs(this.settings.widgetUnitsAttributeName, isUndefinedOrNull(units) || isEmptyStr(units));
this.createdErrorMsg(this.settings.widgetUnitsAttributeName, isUndefinedOrNull(units) || isEmptyStr(units));
units = this.settings.units;
}
}
if (!isVolumeUnitStatic) {
if (isNotEmptyStr(volumeUnits)) {
const normalizeUnits = volumeUnits.normalize().trim();
volumeUnits = this.capacityUnits.find(unit => unit.normalize() === normalizeUnits);
}
if (isUndefinedOrNull(volumeUnits) || !isNotEmptyStr(volumeUnits)) {
this.createdErrorMsg(this.settings.widgetUnitsAttributeName,
isUndefinedOrNull(volumeUnits) || isEmptyStr(volumeUnits));
volumeUnits = this.settings.volumeUnits;
}
}
return {
volume,
volumeUnits,
units
};
})
);
}
private createdErrorMgs(attributeName: string, isEmpty = false) {
private createdErrorMsg(attributeName: string, isEmpty = false) {
if (isEmpty) {
this.errorsMsg.push(this.translate.instant('widgets.liquid-level-card.attribute-key-not-set', {attributeName}));
} else {
@ -474,9 +504,14 @@ export class LiquidLevelWidgetComponent implements OnInit {
}
if (this.settings.layout === LevelCardLayout.absolute) {
const volumeInLiters: number = convertLiters(this.volume, this.settings.volumeUnits as CapacityUnits, ConversionType.to);
const volume = convertLiters(volumeInLiters, this.widgetUnits as CapacityUnits, ConversionType.from)
.toFixed(this.settings.decimals || 0);
let volume: number | string;
if (this.widgetUnits !== CapacityUnits.percent) {
const volumeInLiters: number = convertLiters(this.volume, this.volumeUnits as CapacityUnits, ConversionType.to);
volume = convertLiters(volumeInLiters, this.widgetUnits as CapacityUnits, ConversionType.from)
.toFixed(this.settings.decimals || 0);
} else {
volume = this.volume.toFixed(this.settings.decimals || 0);
}
const volumeTextStyle = cssTextFromInlineStyle({...inlineTextStyle(this.settings.volumeFont),
color: this.settings.volumeColor});
@ -553,7 +588,7 @@ export class LiquidLevelWidgetComponent implements OnInit {
private convertInputData(value: any): number {
if (this.settings.datasourceUnits !== CapacityUnits.percent) {
return (convertLiters(Number(value), this.settings.datasourceUnits, ConversionType.to) /
convertLiters(this.volume, this.settings.volumeUnits, ConversionType.to)) * 100;
convertLiters(this.volume, this.volumeUnits as CapacityUnits, ConversionType.to)) * 100;
}
return Number(value);
@ -561,7 +596,7 @@ export class LiquidLevelWidgetComponent implements OnInit {
private convertOutputData(value: number): number {
if (this.widgetUnits !== CapacityUnits.percent) {
return convertLiters(this.volume * (value / 100), this.settings.volumeUnits, ConversionType.to);
return convertLiters(this.volume * (value / 100), this.volumeUnits as CapacityUnits, ConversionType.to);
}
return value;

31
ui-ngx/src/app/modules/home/components/widget/lib/indicator/liquid-level-widget.models.ts

@ -56,6 +56,8 @@ export interface LevelCardWidgetSettings extends WidgetConfig {
volumeSource: LiquidWidgetDataSourceType;
volumeConstant: number;
volumeAttributeName: string;
volumeUnitsSource: LiquidWidgetDataSourceType;
volumeUnitsAttributeName: string;
volumeUnits: CapacityUnits;
volumeFont: Font;
volumeColor: string;
@ -257,8 +259,10 @@ export const levelCardDefaultSettings: LevelCardWidgetSettings = {
iconColor: '#5469FF',
volumeSource: LiquidWidgetDataSourceType.static,
volumeConstant: 500,
volumeUnits: CapacityUnits.liters,
volumeAttributeName: 'volume',
volumeUnitsSource: LiquidWidgetDataSourceType.static,
volumeUnitsAttributeName: 'volumeUnits',
volumeUnits: CapacityUnits.liters,
volumeFont: {
family: 'Roboto',
size: 14,
@ -375,9 +379,7 @@ export const convertLiters = (value: number, units: CapacityUnits, conversionTyp
return conversionType === ConversionType.to ? value / factor : value * factor;
};
export const extractValue = <T>(attributes: Array<AttributeData>, attributeName: string): T | undefined => {
return attributes.find(attr => attr.key === attributeName)?.value;
};
export const extractValue = <T>(attributes: Array<AttributeData>, attributeName: string): T | undefined => attributes.find(attr => attr.key === attributeName)?.value;
export const valueContainerStyleDefaults = cssTextFromInlineStyle({
width: '100%',
@ -494,6 +496,7 @@ export const updatedFormSettingsValidators = (formGroup: FormGroup) => {
const datasourceUnits: string = formGroup.get('datasourceUnits').value;
const layout: LevelCardLayout = formGroup.get('layout').value;
const volumeSource: LiquidWidgetDataSourceType = formGroup.get('volumeSource').value;
const volumeUnitsSource: LiquidWidgetDataSourceType = formGroup.get('volumeUnitsSource').value;
const widgetUnitsSource: LiquidWidgetDataSourceType = formGroup.get('widgetUnitsSource').value;
const showTooltipLevel: boolean = formGroup.get('showTooltipLevel').value;
const showTooltipDate: boolean = formGroup.get('showTooltipDate').value;
@ -517,7 +520,7 @@ export const updatedFormSettingsValidators = (formGroup: FormGroup) => {
if (datasourceUnits !== CapacityUnits.percent) {
formGroup.get('volumeSource').enable({emitEvent: false});
formGroup.get('volumeUnits').enable({emitEvent: false});
formGroup.get('volumeUnitsSource').enable({emitEvent: false});
if (volumeSource === LiquidWidgetDataSourceType.static) {
formGroup.get('volumeConstant').enable({emitEvent: false});
formGroup.get('volumeAttributeName').disable({emitEvent: false});
@ -525,11 +528,20 @@ export const updatedFormSettingsValidators = (formGroup: FormGroup) => {
formGroup.get('volumeConstant').disable({emitEvent: false});
formGroup.get('volumeAttributeName').enable({emitEvent: false});
}
if (volumeUnitsSource === LiquidWidgetDataSourceType.static) {
formGroup.get('volumeUnits').enable({emitEvent: false});
formGroup.get('volumeUnitsAttributeName').disable({emitEvent: false});
} else {
formGroup.get('volumeUnits').disable({emitEvent: false});
formGroup.get('volumeUnitsAttributeName').enable({emitEvent: false});
}
} else {
formGroup.get('volumeSource').disable({emitEvent: false});
formGroup.get('volumeConstant').disable({emitEvent: false});
formGroup.get('volumeAttributeName').disable({emitEvent: false});
formGroup.get('volumeUnitsSource').disable({emitEvent: false});
formGroup.get('volumeUnits').disable({emitEvent: false});
formGroup.get('volumeUnitsAttributeName').disable({emitEvent: false});
}
if (layout === LevelCardLayout.simple) {
@ -557,7 +569,7 @@ export const updatedFormSettingsValidators = (formGroup: FormGroup) => {
}
formGroup.get('volumeSource').enable({emitEvent: false});
formGroup.get('volumeUnits').enable({emitEvent: false});
formGroup.get('volumeUnitsSource').enable({emitEvent: false});
if (volumeSource === LiquidWidgetDataSourceType.static) {
formGroup.get('volumeConstant').enable({emitEvent: false});
formGroup.get('volumeAttributeName').disable({emitEvent: false});
@ -565,6 +577,13 @@ export const updatedFormSettingsValidators = (formGroup: FormGroup) => {
formGroup.get('volumeConstant').disable({emitEvent: false});
formGroup.get('volumeAttributeName').enable({emitEvent: false});
}
if (volumeUnitsSource === LiquidWidgetDataSourceType.static) {
formGroup.get('volumeUnits').enable({emitEvent: false});
formGroup.get('volumeUnitsAttributeName').disable({emitEvent: false});
} else {
formGroup.get('volumeUnits').disable({emitEvent: false});
formGroup.get('volumeUnitsAttributeName').enable({emitEvent: false});
}
if (formGroup.get('decimals')) {
formGroup.get('decimals').enable({emitEvent: false});

23
ui-ngx/src/app/modules/home/components/widget/lib/maps/leaflet-map.ts

@ -32,7 +32,7 @@ import {
WidgetUnitedMapSettings
} from './map-models';
import { Marker } from './markers';
import { map, Observable, of, switchMap } from 'rxjs';
import { map, Observable, of } from 'rxjs';
import { Polyline } from './polyline';
import { Polygon } from './polygon';
import { Circle } from './circle';
@ -64,6 +64,7 @@ import { MatDialog } from '@angular/material/dialog';
import { FormattedData, ReplaceInfo } from '@shared/models/widget.models';
import ITooltipsterInstance = JQueryTooltipster.ITooltipsterInstance;
import { ImagePipe } from '@shared/pipe/image.pipe';
import { take, tap } from 'rxjs/operators';
export default abstract class LeafletMap {
@ -940,7 +941,12 @@ export default abstract class LeafletMap {
this.markersData = markersData;
if (this.options.useClusterMarkers) {
if (createdMarkers.length) {
this.markersCluster.addLayers(createdMarkers.map(marker => marker.leafletMarker));
createdMarkers.forEach((marker) => {
marker.createMarkerIconSubject.pipe(
tap(() => this.markersCluster.addLayer(marker.leafletMarker)),
take(1)
).subscribe();
});
}
if (updatedMarkers.length) {
this.markersCluster.refreshClusters(updatedMarkers.map(marker => marker.leafletMarker));
@ -971,10 +977,15 @@ export default abstract class LeafletMap {
}
this.markers.set(key, newMarker);
if (!this.options.useClusterMarkers) {
this.map.addLayer(newMarker.leafletMarker);
if (this.map.pm.globalDragModeEnabled() && newMarker.leafletMarker.pm) {
newMarker.leafletMarker.pm.enableLayerDrag();
}
newMarker.createMarkerIconSubject.pipe(
tap(() => {
this.map.addLayer(newMarker.leafletMarker);
if (this.map.pm.globalDragModeEnabled() && newMarker.leafletMarker.pm) {
newMarker.leafletMarker.pm.enableLayerDrag();
}
}),
take(1)
).subscribe();
}
return newMarker;
}

3
ui-ngx/src/app/modules/home/components/widget/lib/maps/markers.ts

@ -23,6 +23,7 @@ import { fillDataPattern, isDefined, isDefinedAndNotNull, processDataPattern, sa
import LeafletMap from './leaflet-map';
import { FormattedData } from '@shared/models/widget.models';
import { ImagePipe } from '@shared/pipe/image.pipe';
import { ReplaySubject } from 'rxjs';
export class Marker {
@ -33,6 +34,7 @@ export class Marker {
tooltipOffset: L.LatLngTuple;
markerOffset: L.LatLngTuple;
tooltip: L.Popup;
createMarkerIconSubject = new ReplaySubject<MarkerIconInfo>();
constructor(private map: LeafletMap,
private location: L.LatLng,
@ -148,6 +150,7 @@ export class Marker {
this.labelOffset = [0, -iconInfo.size[1] * this.markerOffset[1] + 10];
}
this.updateMarkerLabel(settings);
this.createMarkerIconSubject.next(iconInfo);
});
}

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

@ -80,6 +80,22 @@
</mat-form-field>
</div>
</ng-template>
<ng-template [ngSwitchCase]="widgetActionType.openURL">
<div class="tb-form-row">
<div class="tb-required">{{ 'widget-action.URL' | translate }}</div>
<mat-form-field fxFlex style="margin-left: 9px" appearance="outline" subscriptSizing="dynamic">
<input matInput placeholder="{{ 'widget-config.set' | translate }}" formControlName="url">
<mat-icon matSuffix
matTooltipPosition="above"
matTooltipClass="tb-error-tooltip"
[matTooltip]="'widget-action.url-required' | translate"
*ngIf="actionTypeFormGroup.get('url').invalid && actionTypeFormGroup.get('url').touched"
class="tb-error">
warning
</mat-icon>
</mat-form-field>
</div>
</ng-template>
<ng-template [ngSwitchCase]="widgetActionFormGroup.get('type').value === widgetActionType.openDashboardState ||
widgetActionFormGroup.get('type').value === widgetActionType.updateDashboardState ?
widgetActionFormGroup.get('type').value : ''">
@ -89,7 +105,8 @@
</mat-slide-toggle>
</div>
</ng-template>
<ng-template [ngSwitchCase]="widgetActionFormGroup.get('type').value === widgetActionType.openDashboard ?
<ng-template [ngSwitchCase]="widgetActionFormGroup.get('type').value === widgetActionType.openDashboard ||
widgetActionFormGroup.get('type').value === widgetActionType.openURL ?
widgetActionFormGroup.get('type').value : ''">
<div class="tb-form-row">
<mat-slide-toggle class="mat-slide" formControlName="openNewBrowserTab">

10
ui-ngx/src/app/modules/home/components/widget/lib/settings/common/action/widget-action.component.ts

@ -279,6 +279,16 @@ export class WidgetActionComponent implements ControlValueAccessor, OnInit, Vali
this.fb.control(action ? action.mobileAction : null, [Validators.required])
);
break;
case WidgetActionType.openURL:
this.actionTypeFormGroup.addControl(
'openNewBrowserTab',
this.fb.control(action ? action.openNewBrowserTab : false, [])
);
this.actionTypeFormGroup.addControl(
'url',
this.fb.control(action ? action.url : null, [Validators.required])
);
break;
}
}
this.actionTypeFormGroupSubscriptions.push(

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

@ -19,8 +19,8 @@
<div class="tb-form-panel no-padding no-border">
<div class="tb-form-panel">
<div fxFlex fxLayout="column" class="tb-form-row space-between">
<div fxFlex fxLayout="row" style="width: 100%;" fxLayoutAlign="space-between center">
<div class="fixed-title-width" translate>widgets.liquid-level-card.shape</div>
<div class="tb-flex row space-between align-center no-gap fill-width">
<div class="fixed-title-width" translate>widgets.liquid-level-card.shape</div>
<tb-toggle-select formControlName="tankSelectionType">
<tb-toggle-option *ngFor="let type of DataSourceTypes" [value]="type">
{{ DataSourceTypeTranslations.get(type) | translate }}
@ -51,7 +51,7 @@
formControlName="shapeAttributeName">
</tb-string-autocomplete>
</div>
<div fxFlex fxLayout="row" fxLayoutAlign="space-between center" style="width: 100%;">
<div class="tb-flex row space-between align-center no-gap fill-width">
<div class="tb-hint" style="padding: 0;" translate>widgets.liquid-level-card.shape-by-attribute</div>
<div class="see-example"
[tb-help-popup]="'widget/lib/indicator/shape_attribute_fn'"
@ -87,9 +87,9 @@
formControlName="datasourceUnits">
</tb-unit-input>
</div>
<div class="tb-form-row space-between" *ngIf="levelCardWidgetSettingsForm.get('layout').value === LevelCardLayout.absolute">
<div class="tb-form-row space-between column-xs" *ngIf="levelCardWidgetSettingsForm.get('layout').value === LevelCardLayout.absolute">
<div class="fixed-title-width tb-required" translate>widgets.liquid-level-card.widget-units</div>
<div fxFlex fxLayout="row" fxLayoutAlign="start center" fxLayoutGap="8px">
<div class="tb-flex row flex-start align-center">
<mat-form-field class="flex" appearance="outline" subscriptSizing="dynamic" style="max-width: 25%">
<mat-select formControlName="widgetUnitsSource" placeholder="{{ 'widget-config.set' | translate }}">
<mat-option *ngFor="let type of DataSourceTypes" [value]="type">
@ -111,9 +111,9 @@
</ng-template>
</div>
</div>
<div class="tb-form-row" *ngIf="volumeInput">
<div class="tb-form-row column-xs" *ngIf="volumeInput">
<div class="fixed-title-width tb-required" translate>widgets.liquid-level-card.total-volume</div>
<div fxFlex fxLayout="row" fxLayoutAlign="start center" fxLayoutGap="8px">
<div class="tb-flex row flex-start align-center">
<mat-form-field class="flex" appearance="outline" subscriptSizing="dynamic" style="max-width: 25%">
<mat-select formControlName="volumeSource" placeholder="{{ 'widget-config.set' | translate }}">
<mat-option *ngFor="let type of DataSourceTypes" [value]="type">
@ -141,10 +141,30 @@
formControlName="volumeAttributeName">
</tb-string-autocomplete>
</ng-template>
<tb-unit-input [tagFilter]="unitsType.capacity"
required style="max-width: 25%" class="flex"
</div>
</div>
<div class="tb-form-row space-between column-xs" [fxShow]="volumeInput">
<div class="fixed-title-width tb-required" translate>widgets.liquid-level-card.total-volume-units</div>
<div class="tb-flex row flex-start align-center">
<mat-form-field class="flex" appearance="outline" subscriptSizing="dynamic" style="max-width: 25%">
<mat-select formControlName="volumeUnitsSource" placeholder="{{ 'widget-config.set' | translate }}">
<mat-option *ngFor="let type of DataSourceTypes" [value]="type">
{{ DataSourceTypeTranslations.get(type) | translate }}
</mat-option>
</mat-select>
</mat-form-field>
<tb-unit-input *ngIf="levelCardWidgetSettingsForm.get('volumeUnitsSource').value === DataSourceType.static; else selectVolumeUnitsAttributes"
class="flex" required
[tagFilter]="unitsType.capacity"
formControlName="volumeUnits">
</tb-unit-input>
<ng-template #selectVolumeUnitsAttributes>
<tb-string-autocomplete [fetchOptionsFn]="fetchOptions.bind(this)"
required style="flex: 1"
[errorText]="'widgets.liquid-level-card.attribute-name-required' | translate"
formControlName="volumeUnitsAttributeName">
</tb-string-autocomplete>
</ng-template>
</div>
</div>
</div>
@ -168,7 +188,7 @@
<div class="tb-form-row space-between"
*ngIf="levelCardWidgetSettingsForm.get('layout').value !== LevelCardLayout.simple">
<div class="fixed-title-width" translate>widgets.liquid-level-card.value</div>
<div fxFlex fxLayout="row" fxLayoutAlign="end center" fxLayoutGap="8px">
<div class="tb-flex row flex-end align-center">
<tb-font-settings formControlName="valueFont"
[previewText]="valuePreviewFn">
</tb-font-settings>
@ -178,7 +198,7 @@
</div>
<div class="tb-form-row" *ngIf="levelCardWidgetSettingsForm.get('layout').value === LevelCardLayout.absolute">
<div class="fixed-title-width" translate>widgets.liquid-level-card.total-volume</div>
<div fxFlex fxLayout="row" fxLayoutAlign="end center" fxLayoutGap="8px">
<div class="tb-flex row flex-end align-center">
<tb-font-settings formControlName="volumeFont"
[previewText]="totalVolumeValuePreviewFn">
</tb-font-settings>
@ -212,7 +232,7 @@
<mat-slide-toggle class="mat-slide fixed-title-width" formControlName="showTooltipLevel">
{{ 'widgets.liquid-level-card.level' | translate }}
</mat-slide-toggle>
<div fxFlex fxLayout="row" fxLayoutAlign="start center" fxLayoutGap="8px">
<div class="tb-flex row flex-start align-center">
<tb-unit-input [tagFilter]="unitsType.capacity"
required class="flex" formControlName="tooltipUnits">
</tb-unit-input>
@ -232,7 +252,7 @@
<mat-slide-toggle class="mat-slide fixed-title-width" formControlName="showTooltipDate">
{{ 'widgets.value-card.date' | translate }}
</mat-slide-toggle>
<div fxFlex.gt-xs fxLayout="row" fxLayoutAlign="start center" fxLayoutGap="8px">
<div class="tb-flex row flex-start align-center">
<tb-date-format-select fxFlex formControlName="tooltipDateFormat"></tb-date-format-select>
<tb-font-settings formControlName="tooltipDateFont"
[previewText]="datePreviewFn">

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

@ -158,7 +158,9 @@ export class LiquidLevelCardWidgetSettingsComponent extends WidgetSettingsCompon
volumeSource: [settings.volumeSource, []],
volumeConstant: [settings.volumeConstant, [Validators.required, Validators.min(0.1)]],
volumeAttributeName: [settings.volumeAttributeName, [Validators.required]],
volumeUnitsSource: [settings.volumeUnitsSource, []],
volumeUnits: [settings.volumeUnits, [Validators.required]],
volumeUnitsAttributeName: [settings.volumeUnitsAttributeName, [Validators.required]],
volumeFont: [settings.volumeFont, []],
volumeColor: [settings.volumeColor, []],
valueFont: [settings.valueFont, []],
@ -195,7 +197,7 @@ export class LiquidLevelCardWidgetSettingsComponent extends WidgetSettingsCompon
protected validatorTriggers(): string[] {
return [
'showBackgroundOverlay', 'showTooltip', 'showTooltipLevel', 'tankSelectionType', 'datasourceUnits',
'showTooltipDate', 'layout', 'volumeSource', 'widgetUnitsSource'
'showTooltipDate', 'layout', 'volumeSource', 'widgetUnitsSource', 'volumeUnitsSource'
];
}

3
ui-ngx/src/app/modules/home/components/widget/widget.component.ts

@ -1089,6 +1089,9 @@ export class WidgetComponent extends PageComponent implements OnInit, AfterViewI
this.router.navigateByUrl(url);
}
break;
case WidgetActionType.openURL:
window.open(descriptor.url, descriptor.openNewBrowserTab ? '_blank' : '_self');
break;
case WidgetActionType.custom:
const customFunction = descriptor.customFunction;
if (customFunction && customFunction.length > 0) {

19
ui-ngx/src/app/modules/home/home.component.ts

@ -15,7 +15,7 @@
///
import { AfterViewInit, Component, ElementRef, Inject, OnDestroy, OnInit, ViewChild } from '@angular/core';
import { startWith, skip, Subject } from 'rxjs';
import { skip, startWith, Subject } from 'rxjs';
import { Store } from '@ngrx/store';
import { debounceTime, distinctUntilChanged, takeUntil } from 'rxjs/operators';
@ -32,6 +32,8 @@ import { instanceOfSearchableComponent, ISearchableComponent } from '@home/model
import { ActiveComponentService } from '@core/services/active-component.service';
import { RouterTabsComponent } from '@home/components/router-tabs.component';
import { FormBuilder } from '@angular/forms';
import { ActivatedRoute } from '@angular/router';
import { isDefined, isDefinedAndNotNull } from '@core/utils';
@Component({
selector: 'tb-home',
@ -70,8 +72,8 @@ export class HomeComponent extends PageComponent implements AfterViewInit, OnIni
constructor(protected store: Store<AppState>,
@Inject(WINDOW) private window: Window,
private activeComponentService: ActiveComponentService,
public breakpointObserver: BreakpointObserver,
private fb: FormBuilder) {
private fb: FormBuilder,
public breakpointObserver: BreakpointObserver) {
super(store);
}
@ -144,9 +146,18 @@ export class HomeComponent extends PageComponent implements AfterViewInit, OnIni
private updateActiveComponent(activeComponent: any) {
this.showSearch = false;
this.hideLoadingBar = false;
this.textSearch.reset('', {emitEvent: false});
this.activeComponent = activeComponent;
this.hideLoadingBar = activeComponent && activeComponent instanceof RouterTabsComponent;
if (activeComponent && activeComponent instanceof RouterTabsComponent
&& isDefinedAndNotNull(this.activeComponent.activatedRoute?.snapshot?.data?.showMainLoadingBar)) {
this.hideLoadingBar = !this.activeComponent.activatedRoute.snapshot.data.showMainLoadingBar;
} else if (activeComponent && activeComponent instanceof PageComponent
&& isDefinedAndNotNull(this.activeComponent?.showMainLoadingBar)) {
this.hideLoadingBar = !this.activeComponent.showMainLoadingBar;
}
if (this.activeComponent && instanceOfSearchableComponent(this.activeComponent)) {
this.searchEnabled = true;
this.searchableComponent = this.activeComponent;

25
ui-ngx/src/app/modules/home/models/widget-component.models.ts

@ -57,6 +57,7 @@ import {
createLabelFromDatasource,
createLabelFromSubscriptionEntityInfo,
formatValue,
getEntityDetailsPageURL,
hasDatasourceLabelsVariables,
isDefined
} from '@core/utils';
@ -253,7 +254,8 @@ export class WidgetContext {
};
utils: IWidgetUtils = {
formatValue
formatValue,
getEntityDetailsPageURL
};
$widgetElement: JQuery<HTMLElement>;
@ -347,35 +349,35 @@ export class WidgetContext {
showSuccessToast(message: string, duration: number = 1000,
verticalPosition: NotificationVerticalPosition = 'bottom',
horizontalPosition: NotificationHorizontalPosition = 'left',
target: string = 'dashboardRoot') {
this.showToast('success', message, duration, verticalPosition, horizontalPosition, target);
target: string = 'dashboardRoot', modern = false) {
this.showToast('success', message, duration, verticalPosition, horizontalPosition, target, modern);
}
showInfoToast(message: string,
verticalPosition: NotificationVerticalPosition = 'bottom',
horizontalPosition: NotificationHorizontalPosition = 'left',
target: string = 'dashboardRoot') {
this.showToast('info', message, undefined, verticalPosition, horizontalPosition, target);
target: string = 'dashboardRoot', modern = false) {
this.showToast('info', message, undefined, verticalPosition, horizontalPosition, target, modern);
}
showWarnToast(message: string,
verticalPosition: NotificationVerticalPosition = 'bottom',
horizontalPosition: NotificationHorizontalPosition = 'left',
target: string = 'dashboardRoot') {
this.showToast('warn', message, undefined, verticalPosition, horizontalPosition, target);
target: string = 'dashboardRoot', modern = false) {
this.showToast('warn', message, undefined, verticalPosition, horizontalPosition, target, modern);
}
showErrorToast(message: string,
verticalPosition: NotificationVerticalPosition = 'bottom',
horizontalPosition: NotificationHorizontalPosition = 'left',
target: string = 'dashboardRoot') {
this.showToast('error', message, undefined, verticalPosition, horizontalPosition, target);
target: string = 'dashboardRoot', modern = false) {
this.showToast('error', message, undefined, verticalPosition, horizontalPosition, target, modern);
}
showToast(type: NotificationType, message: string, duration: number,
verticalPosition: NotificationVerticalPosition = 'bottom',
horizontalPosition: NotificationHorizontalPosition = 'left',
target: string = 'dashboardRoot') {
target: string = 'dashboardRoot', modern = false) {
this.store.dispatch(new ActionNotificationShow(
{
message,
@ -385,7 +387,8 @@ export class WidgetContext {
horizontalPosition,
target,
panelClass: this.widgetNamespace,
forceDismiss: true
forceDismiss: true,
modern
}));
}

1
ui-ngx/src/app/modules/home/pages/account/account-routing.module.ts

@ -33,6 +33,7 @@ const routes: Routes = [
component: RouterTabsComponent,
data: {
auth: [Authority.SYS_ADMIN, Authority.TENANT_ADMIN, Authority.CUSTOMER_USER],
showMainLoadingBar: false,
breadcrumb: {
label: 'account.account',
icon: 'account_circle'

1
ui-ngx/src/app/modules/home/pages/admin/admin-routing.module.ts

@ -136,6 +136,7 @@ const routes: Routes = [
component: RouterTabsComponent,
data: {
auth: [Authority.SYS_ADMIN, Authority.TENANT_ADMIN],
showMainLoadingBar: false,
breadcrumb: {
label: 'admin.settings',
icon: 'settings'

17
ui-ngx/src/app/modules/home/pages/admin/oauth2-settings.component.ts

@ -15,7 +15,14 @@
///
import { Component, Inject, OnDestroy, OnInit } from '@angular/core';
import { AbstractControl, UntypedFormArray, UntypedFormBuilder, UntypedFormGroup, ValidationErrors, Validators } from '@angular/forms';
import {
AbstractControl,
UntypedFormArray,
UntypedFormBuilder,
UntypedFormGroup,
ValidationErrors,
Validators
} from '@angular/forms';
import {
ClientAuthenticationMethod,
DomainSchema,
@ -26,9 +33,11 @@ import {
MapperConfigType,
OAuth2ClientRegistrationTemplate,
OAuth2DomainInfo,
OAuth2Info, OAuth2MobileInfo,
OAuth2Info,
OAuth2MobileInfo,
OAuth2ParamsInfo,
OAuth2RegistrationInfo, PlatformType,
OAuth2RegistrationInfo,
PlatformType,
platformTypeTranslations,
TenantNameStrategy
} from '@shared/models/oauth2.models';
@ -105,6 +114,8 @@ export class OAuth2SettingsComponent extends PageComponent implements OnInit, Ha
templateProvider = ['Custom'];
showMainLoadingBar = false;
private loginProcessingUrl: string = this.route.snapshot.data.loginProcessingUrl;
private static validateScope(control: AbstractControl): ValidationErrors | null {

10
ui-ngx/src/app/modules/home/pages/admin/security-settings.component.ts

@ -23,7 +23,8 @@ import {
AbstractControl,
UntypedFormBuilder,
UntypedFormControl,
UntypedFormGroup, ValidationErrors,
UntypedFormGroup,
ValidationErrors,
ValidatorFn,
Validators
} from '@angular/forms';
@ -35,10 +36,7 @@ import { randomAlphanumeric } from '@core/utils';
import { AuthService } from '@core/auth/auth.service';
import { DialogService } from '@core/services/dialog.service';
import { TranslateService } from '@ngx-translate/core';
import { forkJoin, Observable, of } from 'rxjs';
import { MatCheckboxChange } from '@angular/material/checkbox';
import { AlarmInfo } from '@shared/models/alarm.models';
import { QueueProcessingStrategyTypes, QueueProcessingStrategyTypesMap } from '@shared/models/queue.models';
import { Observable, of } from 'rxjs';
@Component({
selector: 'tb-security-settings',
@ -50,6 +48,8 @@ export class SecuritySettingsComponent extends PageComponent implements HasConfi
securitySettingsFormGroup: UntypedFormGroup;
jwtSecuritySettingsFormGroup: UntypedFormGroup;
showMainLoadingBar = false;
private securitySettings: SecuritySettings;
private jwtSettings: JwtSettings;

2
ui-ngx/src/app/modules/home/pages/admin/two-factor-auth-settings.component.ts

@ -47,6 +47,8 @@ export class TwoFactorAuthSettingsComponent extends PageComponent implements OnI
twoFactorAuthProviderType = TwoFactorAuthProviderType;
twoFactorAuthProvidersData = twoFactorAuthProvidersData;
showMainLoadingBar = false;
@ViewChildren(MatExpansionPanel) expansionPanel: QueryList<MatExpansionPanel>;
constructor(protected store: Store<AppState>,

1
ui-ngx/src/app/modules/home/pages/home-pages.models.ts

@ -30,4 +30,3 @@ export const entityDetailsPageBreadcrumbLabelFunction: BreadCrumbLabelFunction<E
return component.entity?.name;
}
});

2
ui-ngx/src/app/shared/components/dashboard-autocomplete.component.html

@ -65,7 +65,7 @@
<mat-error *ngIf="!inlineField">
<ng-content select="[tb-error]"></ng-content>
</mat-error>
<mat-hint *ngIf="!inlineField">>
<mat-hint *ngIf="!inlineField">
<ng-content select="[tb-hint]"></ng-content>
</mat-hint>
</mat-form-field>

1
ui-ngx/src/app/shared/components/json-object-edit.component.scss

@ -52,6 +52,7 @@
width: 100%;
min-width: 200px;
height: 100%;
min-height: 300px;
&:not(.fill-height) {
min-height: 200px;

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

@ -29,6 +29,8 @@ export abstract class PageComponent implements OnDestroy {
loadingSubscription: Subscription;
disabledOnLoadFormControls: Array<AbstractControl> = [];
showMainLoadingBar = true;
protected constructor(protected store: Store<AppState>) {
this.isLoading$ = this.store.pipe(delay(0), select(selectIsLoading), share());
}

29
ui-ngx/src/app/shared/components/snack-bar-component.html

@ -15,15 +15,30 @@
limitations under the License.
-->
<div fxLayout="row" fxLayoutAlign="start center" class="tb-toast"
<div fxLayout="row" fxLayoutAlign="start center"
[@showHideAnimation]="{value: animationState, params: animationParams}"
(@showHideAnimation.done)="onHideFinished($event)"
[ngClass]="{
'error-toast': notification.type === 'error',
'warn-toast': notification.type === 'warn',
'success-toast': notification.type === 'success',
'info-toast': notification.type === 'info'
'tb-toast': !notification.modern,
'tb-modern-toast': notification.modern,
'error-toast': notification.type === 'error',
'warn-toast': notification.type === 'warn',
'success-toast': notification.type === 'success',
'info-toast': notification.type === 'info'
}">
<div class="toast-text" [innerHTML]="notification.message"></div>
<button #actionButton type="button" mat-button (click)="action($event)">{{ 'action.close' | translate }}</button>
<ng-container *ngIf="!notification.modern; else modern">
<div class="toast-text" [innerHTML]="notification.message | safe: 'html'"></div>
<button #actionButton type="button" mat-button (click)="action($event)">{{ 'action.close' | translate }}</button>
</ng-container>
<ng-template #modern>
<div class="tb-modern-toast-panel" [ngClass]="{
'error-toast': notification.type === 'error',
'warn-toast': notification.type === 'warn',
'success-toast': notification.type === 'success',
'info-toast': notification.type === 'info'
}">
<div class="toast-text" [innerHTML]="notification.message | safe: 'html'"></div>
<button #actionButton class="tb-mat-20" mat-icon-button (click)="action($event)"><mat-icon>close</mat-icon></button>
</div>
</ng-template>
</div>

38
ui-ngx/src/app/shared/components/snack-bar-component.scss

@ -71,4 +71,42 @@
background: #008000;
}
}
.tb-modern-toast {
display: flex;
flex-direction: column;
z-index: 1;
&-panel {
display: flex;
padding: 4px 4px 4px 12px;
justify-content: center;
align-items: center;
gap: 4px;
border-radius: 4px;
box-shadow: -2px 2px 4px 0px rgba(0,0,0,0.2);
&.info-toast {
background: #e6e6e6;
color: rgba(50, 50, 50, 1);
}
&.warn-toast {
background: #fff3eb;
color: rgba(220, 109, 27, 1);
}
&.error-toast {
background-color: #fff2f3;
color: rgba(209, 39, 48, 1);
}
&.success-toast {
background: #ebfcec;
color: rgba(0, 128, 0, 1);
}
}
.toast-text {
font-size: 12px;
font-style: normal;
font-weight: 400;
line-height: 16px;
}
}
}

1
ui-ngx/src/app/shared/components/time/timewindow.component.scss

@ -58,6 +58,7 @@
.mdc-button__label {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
}
}

2
ui-ngx/src/app/shared/components/toast.directive.ts

@ -280,7 +280,7 @@ export type ToastAnimationState = 'default' | 'opened' | 'closing';
})
export class TbSnackBarComponent implements AfterViewInit, OnDestroy {
@ViewChild('actionButton', {static: true}) actionButton: MatButton;
@ViewChild('actionButton') actionButton: MatButton;
@HostBinding('class')
get panelClass(): string[] {

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

@ -538,7 +538,8 @@ export enum WidgetActionType {
openDashboard = 'openDashboard',
custom = 'custom',
customPretty = 'customPretty',
mobileAction = 'mobileAction'
mobileAction = 'mobileAction',
openURL = 'openURL'
}
export enum WidgetMobileActionType {
@ -559,7 +560,8 @@ export const widgetActionTypeTranslationMap = new Map<WidgetActionType, string>(
[ WidgetActionType.openDashboard, 'widget-action.open-dashboard' ],
[ WidgetActionType.custom, 'widget-action.custom' ],
[ WidgetActionType.customPretty, 'widget-action.custom-pretty' ],
[ WidgetActionType.mobileAction, 'widget-action.mobile-action' ]
[ WidgetActionType.mobileAction, 'widget-action.mobile-action' ],
[ WidgetActionType.openURL, 'widget-action.open-URL' ]
]
);
@ -671,6 +673,7 @@ export interface WidgetAction extends CustomActionDescriptor {
setEntityId?: boolean;
stateEntityParamName?: string;
mobileAction?: WidgetMobileActionDescriptor;
url?: string;
}
export interface WidgetActionDescriptor extends WidgetAction {

2
ui-ngx/src/assets/help/en_US/rulenode/common_node_fields_templatization.md

@ -1,4 +1,4 @@
Fields templatization feature allows you to process the incoming messages with dynamic configuration
by substitution of templates specified in the configuration fields with values from message or message metadata.
For more detailed information, please refer to the ThingsBoard [documentation](https://thingsboard.io/docs/user-guide/templatization/)
For more detailed information, please refer to the ThingsBoard [documentation](${siteBaseUrl}/docs/user-guide/templatization/)

2
ui-ngx/src/assets/help/en_US/rulenode/common_node_script_args.md

@ -8,4 +8,4 @@
</ul>
Enable 'debug mode' for your rule node to see the messages that arrive in near real-time.
See <a href="https://thingsboard.io/docs/user-guide/rule-engine-2-0/overview/#debugging" target="_blank">Debugging</a> for more information.
See <a href="${siteBaseUrl}/docs/user-guide/rule-engine-2-0/overview/#debugging" target="_blank">Debugging</a> for more information.

2
ui-ngx/src/assets/help/en_US/rulenode/switch_node_script_fn.md

@ -15,7 +15,7 @@ JavaScript function computing **an array of Link names** to forward the incoming
Should return an array of `string` values presenting **link names** that the Rule Engine should use to further route the incoming Message.<br>
If the result is an empty array - message will not be routed to any Node and will be immediately
<a href="https://thingsboard.io/docs/user-guide/rule-engine-2-0/overview/#message-processing-result" target="_blank">acknowledged</a>.
<a href="${siteBaseUrl}/docs/user-guide/rule-engine-2-0/overview/#message-processing-result" target="_blank">acknowledged</a>.
<div class="divider"></div>

2
ui-ngx/src/assets/help/en_US/rulenode/tbel/common_node_script_args.md

@ -8,4 +8,4 @@
</ul>
Enable 'debug mode' for your rule node to see the messages that arrive in near real-time.
See <a href="https://thingsboard.io/docs/user-guide/rule-engine-2-0/overview/#debugging" target="_blank">Debugging</a> for more information.
See <a href="${siteBaseUrl}/docs/user-guide/rule-engine-2-0/overview/#debugging" target="_blank">Debugging</a> for more information.

2
ui-ngx/src/assets/help/en_US/rulenode/tbel/switch_node_script_fn.md

@ -15,7 +15,7 @@
Should return an array of `string` values presenting **link names** that the Rule Engine should use to further route the incoming Message.<br>
If the result is an empty array - message will not be routed to any Node and will be immediately
<a href="https://thingsboard.io/docs/user-guide/rule-engine-2-0/overview/#message-processing-result" target="_blank">acknowledged</a>.
<a href="${siteBaseUrl}/docs/user-guide/rule-engine-2-0/overview/#message-processing-result" target="_blank">acknowledged</a>.
<div class="divider"></div>

54
ui-ngx/src/assets/locale/locale.constant-de_DE.json

@ -67,10 +67,10 @@
"next-with-label": "Nächste: {{label}}",
"read-more": "Mehr dazu",
"hide": "Verstecken",
"done": "Erledigt",
"print": "Drucken",
"restore": "Wiederherstellen",
"confirm": "Bestätigen",
"done": "Erledigt",
"print": "Drucken",
"restore": "Wiederherstellen",
"confirm": "Bestätigen",
"more": "Mehr",
"less": "Weniger",
"skip": "Überspringen",
@ -107,6 +107,23 @@
"base-url-required": "Basis-URL ist erforderlich.",
"prohibit-different-url": "Prohibit to use hostname from the client request headers",
"prohibit-different-url-hint": "This setting should be enabled for production environments. May cause security issues when disabled",
"device-connectivity": {
"device-connectivity": "Geräte Konnektivität",
"http-s": "HTTP(s)",
"mqtt-s": "MQTT(s)",
"coap-s": "COAP(s)",
"http": "HTTP",
"https": "HTTPs",
"mqtt": "MQTT",
"mqtts": "MQTTs",
"coap": "COAP",
"coaps": "COAPs",
"hint": "Falls Host und Port leer sind, werden die Standardwerte des Protokolls verwendet",
"host": "Host",
"port": "Port",
"port-pattern": "Port muss einen positiven Wert haben",
"port-range": "Port sollte im Bereich 1 und 65535 liegen."
},
"mail-from": "E-Mail von",
"mail-from-required": "E-Mail von ist erforderlich.",
"smtp-protocol": "SMTP Protokoll",
@ -138,7 +155,16 @@
"mail-template": "E-Mail Vorlage",
"test": "E-Mail Nachricht testen",
"activation": "Nachricht Benutzerkontoaktivierung",
"account-activated": "Nachricht Benutzerkonto aktiviert"
"account-activated": "Nachricht Benutzerkonto aktiviert",
"account-lockout": "Nachricht zur Kontosperrung",
"reset-password": "Nachricht zum Zurücksetzen des Passworts",
"password-was-reset": "Nachricht für Passwort wurde zurückgesetzt",
"user-activated": "Nachricht für Benutzerkonto wurde aktiviert",
"user-registered": "Nachricht für Benutzer hat sich registriert",
"api-usage-state-enabled": "API-Nutzungsstatus wurde aktiviert",
"api-usage-state-warning": "Warnung zum API-Nutzungsstatus",
"api-usage-state-disabled": "API-Nutzungsstatus wurde deaktiviert",
"two-fa-verification": "2FA-Bestätigungsnachricht"
},
"mail-subject": "E-Mail Betreff",
"mail-body": "E-Mail Nachricht",
@ -186,17 +212,17 @@
"alarm-status-list": "Alarm Statusliste",
"any-status": "Jeder Status",
"search-status": {
"ANY": "Jeder",
"ACTIVE": "Aktiv",
"CLEARED": "Gelöscht",
"ACK": "Bestätigt",
"UNACK": "Nicht bestätigt"
"ANY": "Jeder",
"ACTIVE": "Aktiv",
"CLEARED": "Gelöscht",
"ACK": "Bestätigt",
"UNACK": "Nicht bestätigt"
},
"display-status": {
"ACTIVE_UNACK": "Nicht bestätigt aktiv",
"ACTIVE_ACK": "Bestätigt aktiv",
"CLEARED_UNACK": "Nicht bestätigt",
"CLEARED_ACK": "Bestätigung gelöscht"
"ACTIVE_UNACK": "Nicht bestätigt aktiv",
"ACTIVE_ACK": "Bestätigt aktiv",
"CLEARED_UNACK": "Nicht bestätigt",
"CLEARED_ACK": "Bestätigung gelöscht"
},
"no-alarms-prompt": "Keine Alarme gefunden",
"created-time": "Erstellungszeit",

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

@ -5036,6 +5036,9 @@
"popover-height": "Popover height",
"popover-style": "Popover style",
"open-new-browser-tab": "Open in a new browser tab",
"open-URL": "Open URL",
"URL": "URL",
"url-required": "URL is required.",
"mobile": {
"action-type": "Mobile action type",
"select-action-type": "Select mobile action type",
@ -6361,6 +6364,7 @@
"layout": "Layout",
"background-overlay": "Value background overlay",
"total-volume": "Total volume",
"total-volume-units": "Total volume units",
"tank": "Tank",
"shape": "Shape",
"datasource-units": "Source units",
@ -6895,6 +6899,7 @@
"ko_KR": "한국어",
"lv_LV": "Latviešu",
"nl_BE": "Koninkrijk België",
"pl_PL": "Polski",
"pt_BR": "Português do Brasil",
"ro_RO": "Română",
"sl_SI": "Slovenščina",

6703
ui-ngx/src/assets/locale/locale.constant-pl_PL.json

File diff suppressed because it is too large

570
ui-ngx/src/assets/locale/locale.constant-zh_CN.json

@ -77,7 +77,10 @@
"show-more": "显示更多",
"dont-show-again": "不再显示",
"see-documentation": "查看文档",
"clear": "清除"
"clear": "清除",
"upload": "上传",
"delete-anyway": "仍要删除",
"delete-selected": "删除所选"
},
"aggregation": {
"aggregation": "聚合",
@ -470,7 +473,12 @@
"notifications-settings": "通知设置",
"slack-api-token": "Slack API令牌",
"slack": "Slack",
"slack-settings": "Slack 设置"
"slack-settings": "Slack 设置",
"maximum-password-length": "密码最大长度",
"maximum-password-length-min": "密码最大长度应至少为6个字符",
"maximum-password-length-less-min": "密码最大长度应大于最小长度",
"force-reset-password-if-no-valid": "如果密码不可用则强制重置密码",
"force-reset-password-if-no-valid-hint": "启用此功能时请小心:它会要求使用不可用密码的用户通过电子邮件重置其密码。"
},
"alarm": {
"alarm": "告警",
@ -598,7 +606,7 @@
"filter-type-single-entity": "单个实体",
"filter-type-entity-list": "实体列表",
"filter-type-entity-name": "实体名称",
"filter-type-entity-type": "Entity type",
"filter-type-entity-type": "实体类型",
"filter-type-state-entity": "仪表板状态实体",
"filter-type-state-entity-description": "仪表板实体令牌状态参数",
"filter-type-asset-type": "资产类型",
@ -1188,7 +1196,9 @@
"assign-dashboard-to-edge": "将仪表板分配给边缘",
"assign-dashboard-to-edge-text": "请选择要分配给边缘的仪表板",
"non-existent-dashboard-state-error": "找不到ID为 '{{ stateId }}' 的仪表板状态。",
"edit-mode": "编辑模式"
"edit-mode": "编辑模式",
"state-controller-default": "静态(已弃用)",
"duplicate-state-action": "复制状态"
},
"datakey": {
"settings": "设置",
@ -1202,13 +1212,13 @@
"data-generation-func": "数据生成功能",
"use-data-post-processing-func": "使用数据后处理功能",
"configuration": "数据键配置",
"timeseries": "Timeseries",
"timeseries": "时间序列",
"attributes": "属性",
"entity-field": "实体字段",
"alarm": "告警字段",
"timeseries-required": "实体 Timeseries 必填。",
"timeseries-or-attributes-required": "实体 Timeseries/属性必填。",
"alarm-fields-timeseries-or-attributes-required": "告警字段或实体 Timeseries/属性必填。",
"timeseries-required": "实体时间序列必填。",
"timeseries-or-attributes-required": "实体时间序列/属性必填。",
"alarm-fields-timeseries-or-attributes-required": "告警字段或实体时间序列/属性必填。",
"maximum-timeseries-or-attributes": "最多允许 { count, plural, =1 {1 个 timeseries/属性。} other {# 个 timeseries/属性。} }",
"alarm-fields-required": "告警字段必填。",
"function-types": "函数类型",
@ -1226,10 +1236,10 @@
"latest-key": "最新值数据键",
"latest-key-functions": "最新值数据键函数",
"latest-key-function": "最新值数据键函数",
"timeseries-keys": "Timeseries 数据键",
"timeseries-key": "Timeseries 数据键",
"timeseries-key-functions": "Timeseries数据键函数",
"timeseries-key-function": "Timeseries数据键函数",
"timeseries-keys": "时间序列数据键",
"timeseries-key": "时间序列数据键",
"timeseries-key-functions": "时间序列数据键函数",
"timeseries-key-function": "时间序列数据键函数",
"maximum-function-types": "最多允许 { count, plural, =1 {1 个函数类型} other {# 个函数类型} }",
"time-description": "当前值的时间戳;",
"value-description": "当前值;",
@ -1578,9 +1588,9 @@
"mqtt-send-ack-on-validation-exception": "发布消息验证失败时发送PUBACK",
"mqtt-send-ack-on-validation-exception-hint": "默认情况下平台将关闭相关消息验证失败的MQTT会话,启用后平台将发布确认而不是关闭会话。",
"snmp-add-mapping": "添加SNMP映射",
"snmp-mapping-not-configured": "OID到Timeseries/遥测的映射未配置",
"snmp-timseries-or-attribute-name": "用于映射的Timeseries/属性名称",
"snmp-timseries-or-attribute-type": "用于映射的Timeseries/属性类型",
"snmp-mapping-not-configured": "OID到时间序列/遥测的映射未配置",
"snmp-timseries-or-attribute-name": "用于映射的时间序列/属性名称",
"snmp-timseries-or-attribute-type": "用于映射的时间序列/属性类型",
"snmp-method-pdu-type-get-request": "GetRequest",
"snmp-method-pdu-type-get-next-request": "GetNextRequest",
"snmp-oid": "OID",
@ -2017,7 +2027,8 @@
"sync-process-started-successfully": "同步处理开始成功!",
"missing-related-rule-chains-title": "边缘缺少关联规则链",
"missing-related-rule-chains-text": "分配给边缘的规则链使用规则节点将消息转发给未分配给当前边缘的规则链。 <br><br> 缺少的规则链列表: <br> {{missingRuleChains}}",
"widget-datasource-error": "组件只支持边缘实体数据源"
"widget-datasource-error": "组件只支持边缘实体数据源",
"upgrade-instructions": "升级说明"
},
"edge-event": {
"type-dashboard": "仪表板",
@ -2046,7 +2057,7 @@
"action-type-post-attributes": "推送属性",
"action-type-attributes-updated": "属性更新",
"action-type-attributes-deleted": "属性删除",
"action-type-timeseries-updated": "时序更新",
"action-type-timeseries-updated": "时间序列更新",
"action-type-credentials-updated": "认证更新",
"action-type-assigned-to-customer": "分配给客户",
"action-type-unassigned-from-customer": "取消分配客户",
@ -2211,7 +2222,9 @@
"type-notification-request": "通知请求",
"type-notification-template": "通知模板",
"type-notification-templates": "通知模板",
"list-of-notification-templates": "{ count, plural, =1 {1 个通知模板} other {# 个通知模板} }"
"list-of-notification-templates": "{ count, plural, =1 {1 个通知模板} other {# 个通知模板} }",
"type-tb-resources": "资源",
"list-of-tb-resources": "{ count, plural, =1 {1 个资源} other {# 个资源} }"
},
"entity-field": {
"created-time": "创建时间",
@ -2328,7 +2341,7 @@
"attributes-propagation": "属性传播",
"attributes-propagation-hint": "每次保存或更新这个实体视图时,实体视图将自动从目标实体复制指定的属性。由于性能原因,目标实体属性不会在每次属性更改时传递到实体视图。您可以通过配置\"copy to view\"规则链中的规则节点,并将\"Post attributes\"和\"attributes Updated\"消息链接到新规则节点,从而启用自动传递。",
"timeseries-data": "时间序列数据",
"timeseries-data-hint": "配置目标实体的 Timeseries 数据键,以便实体视图可以访问这些键。此 Timeseries 数据是只读的。",
"timeseries-data-hint": "配置目标实体的时间序列数据键,以便实体视图可以访问这些键。此时间序列数据是只读的。",
"search": "查找实体视图",
"selected-entity-views": "已选择 { count, plural, =1 {1 个实体视图} other {# 个实体视图} }",
"make-public-entity-view-title": "确定要将实体视图 '{{entityViewName}}' 设为公开吗?",
@ -2427,8 +2440,8 @@
"attributes": "属性",
"add-attribute": "添加属性",
"add-map": "添加映射元素",
"timeseries": "Timeseries",
"add-timeseries": "添加 Timeseries",
"timeseries": "时间序列",
"add-timeseries": "添加时间序列",
"field-required": "必填字段",
"brokers": "代理服务器组",
"add-broker": "添加代理服务器",
@ -2523,7 +2536,7 @@
"modbus-device-name": "设备名称",
"modbus-poll-period": "轮询周期 (毫秒)",
"modbus-attributes-poll-period": "轮询属性周期 (毫秒)",
"modbus-timeseries-poll-period": "Timeseries 轮询周期 (毫秒)",
"modbus-timeseries-poll-period": "时间序列数据轮询周期 (毫秒)",
"modbus-poll-period-range": "轮询周期应为正值。",
"modbus-tag": "标签",
"modbus-function": "函数",
@ -2613,7 +2626,7 @@
"key-type": {
"key-type": "键类型",
"attribute": "属性",
"timeseries": "Timeseries",
"timeseries": "时间序列",
"entity-field": "实体",
"constant": "常量",
"client-attribute": "客户端属性",
@ -2908,7 +2921,13 @@
"grpc-max-pings-without-data": "在没有接收到任何数据之前,服务器可以发送的keepalive ping消息的最大数量,然后将连接视为死亡",
"grpc-min-ping-interval-without-data": "在没有发送或接收数据时,服务器在发送keepalive ping消息之间应等待的最小时间量",
"permit-without-calls": "允许服务器在没有活动RPC调用时保持GRPC连接活动"
}
},
"docker-label": "使用以下指令在 Docker compose 中运行 IoT 网关,并为选定的设备提供凭据",
"install-docker-compose": "使用以下说明下载、安装和设置 Docker Compose",
"download-configuration-file": "下载配置文件",
"download-docker-compose": "下载您的网关的 docker-compose.yml 文件",
"launch-gateway": "启动网关",
"launch-docker-compose": "在包含 docker-compose.yml 文件的文件夹中,使用以下命令在终端中启动网关"
},
"grid": {
"delete-item-title": "确定要删除此项吗?",
@ -2939,6 +2958,59 @@
"browse-file": "浏览文件",
"browse-files": "浏览文件"
},
"image": {
"gallery": "图像库",
"search": "搜索图像",
"selected-images": "已选择 { count, plural, =1 {1 个图像} other {# 个图像} }",
"created-time": "创建时间",
"name": "名称",
"name-required": "名称不能为空。",
"resolution": "分辨率",
"size": "大小",
"system": "系统",
"download-image": "下载图像",
"export-image": "导出图像为JSON",
"import-image": "从JSON导入图像",
"upload-image": "上传图像",
"edit-image": "编辑图像",
"image-details": "图像详情",
"no-images": "未找到图像",
"delete-image": "删除图像",
"delete-image-title": "确定要删除图像 '{{imageTitle}}' 吗?",
"delete-image-text": "请注意,确认后图像将无法恢复。",
"delete-images-title": "确定要删除 { count, plural, =1 {1 个图像} other {# 个图像} } 吗?",
"delete-images-text": "请注意,确认后所有选定的图像都将被删除,并且所有相关数据将无法恢复。",
"list-mode": "列表视图",
"grid-mode": "网格视图",
"image-preview": "图像预览",
"update-image": "更新图像",
"export-failed-error": "无法导出图像:{{error}}",
"image-json-file": "图像JSON文件",
"invalid-image-json-file-error": "无法从JSON导入图像:无效的图像JSON数据结构。",
"image-is-in-use": "图像被其他实体使用",
"images-are-in-use": "图像被其他实体使用",
"image-is-in-use-text": "无法删除图像<b>'{{title}}'</b>,因为它被以下实体使用:",
"images-are-in-use-text": "由于图像被其他实体使用,无法删除所有图像。</br>您可以通过单击相应图像行上的<b>引用</b>按钮查看引用的实体。</br>如果仍然要删除这些图像,请在下方的表格中选择它们,然后点击<b>删除所选</b>按钮。",
"delete-image-in-use-text": "如果仍然要删除该图像,请点击<b>无论如何删除</b>按钮。",
"system-entities": "系统实体:",
"entities": "实体:",
"references": "引用",
"include-system-images": "包含系统图像",
"clear-image": "清除图像",
"no-image": "无图像",
"no-image-selected": "未选择图像",
"browse-from-gallery": "从图像库浏览",
"set-link": "设置链接",
"image-link": "图像链接",
"link": "链接",
"copy-image-link": "复制图像链接",
"embed-image": "嵌入图像",
"embed-to-html": "嵌入到HTML",
"embed-to-html-hint": "此功能将使链接对任何未经授权的用户可用。",
"embed-to-html-text": "使用以下代码片段,您可以将图像嵌入到基于纯HTML的组件中。<br/>此类组件包括HTML卡片小部件、单元格内容函数等。",
"embed-to-angular-template": "嵌入到Angular HTML模板",
"embed-to-angular-template-text": "使用以下代码片段,您可以将图像嵌入到Angular HTML模板中。<br/>此类组件包括Markdown小部件、小部件编辑器中的HTML部分、自定义操作等。"
},
"image-input": {
"drop-images-or": "拖放一张或多张图片",
"drag-and-drop": "拖放",
@ -2972,7 +3044,7 @@
"client-attribute": "客户端属性",
"shared-attribute": "共享属性",
"server-attribute": "服务器属性",
"timeseries": "Timeseries",
"timeseries": "时间序列",
"entity-field": "实体字段",
"access-token": "访问令牌",
"x509": "X.509",
@ -3192,8 +3264,8 @@
"email-preview": "Email 通知预览",
"slack": "Slack",
"slack-preview": "Slack 通知预览",
"microsoft-teams" : "Microsoft Teams",
"microsoft-teams-preview" : "Microsoft Teams 通知预览",
"microsoft-teams": "Microsoft Teams",
"microsoft-teams-preview": "Microsoft Teams 通知预览",
"sms": "SMS",
"sms-preview": "SMS 通知预览",
"web": "Web",
@ -3459,7 +3531,8 @@
"version-tag": "版本标签",
"version-tag-hint": "自定义标签应与您设备报告的软件包版本相匹配。",
"version-max-length": "版本长度应该少于256个字符",
"warning-after-save-no-edit": "上传包后,您将无法修改标题、版本、设备配置和包类型。"
"warning-after-save-no-edit": "上传包后,您将无法修改标题、版本、设备配置和包类型。",
"package-file": "包文件"
},
"position": {
"top": "顶部",
@ -3557,7 +3630,8 @@
"password-requirements": "密码要求",
"password-should-difference": "新密码应与当前密码不同",
"special-character": "{ count, plural, =1 {1 位特殊字符} other {# 位特殊字符} }",
"uppercase-letter": "{ count, plural, =1 {1 位大写字母} other {# 位大写字母} }"
"uppercase-letter": "{ count, plural, =1 {1 位大写字母} other {# 位大写字母} }",
"at-most": "最多:"
}
},
"relation": {
@ -3640,7 +3714,9 @@
"js-module": "JS 模块",
"lwm2m-model": "LWM2M 模型",
"pkcs-12": "PKCS #12"
}
},
"resource-file": "资源文件",
"resource-files": "资源文件"
},
"rulechain": {
"rulechain": "规则链",
@ -4104,7 +4180,10 @@
"transport-device-telemetry-msg": "传输设备遥测消息",
"transport-device-telemetry-data-points": "传输设备遥测数据点",
"sec": "秒"
}
},
"maximum-resource-size": "最大资源文件大小(字节)",
"maximum-resource-size-required": "最大资源文件大小是必需的",
"maximum-resource-size-range": "最大资源文件大小不能为负数"
},
"timeinterval": {
"seconds-interval": "{ seconds, plural, =1 {1 秒} other {# 秒} }",
@ -4205,409 +4284,6 @@
"background-color": "背景颜色",
"background-blur": "背景模糊"
},
"unit": {
"millimeter": "mm",
"centimeter": "cm",
"angstrom": "Å",
"nanometer": "nm",
"micrometer": "μm",
"meter": "m",
"kilometer": "km",
"inch": "in",
"foot": "ft",
"yard": "yd",
"mile": "mi",
"nautical-mile": "nmi",
"astronomical-unit": "au",
"reciprocal-metre": "m⁻¹",
"meter-per-meter": "m/m",
"steradian": "sr",
"thou": "th",
"barleycorn": "bc",
"hand": "hd",
"chain": "ch",
"furlong": "fur",
"league": "lea",
"fathom": "fath",
"cable": "cb",
"link": "li",
"rod": "rd",
"nanogram": "ng",
"microgram": "μg",
"milligram": "mg",
"gram": "g",
"kilogram": "kg",
"tonne": "t",
"ounce": "oz",
"pound": "lb",
"stone": "st",
"hundredweight-count": "cwt",
"short-tons": "s.t.",
"dalton": "Da",
"grain": "gr",
"drachm": "dr",
"quarter": "qr",
"slug": "slug",
"carat": "ct",
"cubic-millimeter": "mm³",
"cubic-centimeter": "cm³",
"cubic-meter": "m³",
"cubic-kilometer": "km³",
"microliter": "μL",
"milliliter": "mL",
"liter": "L",
"hectoliter": "hL",
"cubic-inch": "in³",
"cubic-foot": "ft³",
"cubic-yard": "yd³",
"fluid-ounce": "fl oz",
"pint": "pt",
"quart": "qt",
"gallon": "gal",
"oil-barrels": "bbl",
"cubic-meter-per-kilogram": "m³/kg",
"gill": "gi",
"hogshead": "hhd",
"teaspoon": "tsp",
"tablespoon": "tbsp",
"cup": "cup",
"celsius": "°C",
"kelvin": "K",
"rankine": "°R",
"fahrenheit": "°F",
"percent": "%",
"meter-per-second": "m/s",
"kilometer-per-hour": "km/h",
"foot-per-second": "ft/s",
"mile-per-hour": "mph",
"knot": "kn",
"millimeters-per-minute": "mm/min",
"kilometer-per-hour-squared": "km/h²",
"foot-per-second-squared": "ft/s²",
"pascal": "Pa",
"kilopascal": "kPa",
"megapascal": "MPa",
"gigapascal": "GPa",
"millibar": "mbar",
"bar": "bar",
"kilobar": "kbar",
"newton": "N",
"newton-meter": "N·m",
"foot-pounds": "ft·lbf",
"inch-pounds": "in·lbf",
"newton-per-meter": "N/m",
"atmospheres": "atm",
"pounds-per-square-inch": "psi",
"torr": "Torr",
"inches-of-mercury": "inHg",
"pascal-per-square-meter": "Pa/m²",
"pound-per-square-inch": "psi",
"newton-per-square-meter": "N/m²",
"kilogram-force-per-square-meter": "kgf/m²",
"pascal-per-square-centimeter": "Pa/cm²",
"ton-force-per-square-inch": "tonf/in²",
"kilonewton-per-square-meter": "kN/m²",
"newton-per-square-millimeter": "N/mm²",
"microjoule": "μJ",
"millijoule": "mJ",
"joule": "J",
"kilojoule": "kJ",
"megajoule": "MJ",
"gigajoule": "GJ",
"watt-hour": "Wh",
"kilowatt-hour": "kWh",
"electron-volts": "eV",
"joules-per-coulomb": "J/C",
"british-thermal-unit": "BTU",
"foot-pound": "ft·lb",
"calorie": "cal",
"small-calorie": "cal",
"kilocalorie": "kcal",
"joule-per-kelvin": "J/K",
"joule-per-kilogram-kelvin": "J/(kg·K)",
"joule-per-kilogram": "J/kg",
"watt-per-meter-kelvin": "W/(m·K)",
"joule-per-cubic-meter": "J/m³",
"therm": "thm",
"electric-dipole-moment": "Debye",
"magnetic-dipole-moment": "Am²",
"debye": "D",
"coulomb-per-square-meter-per-volt": "C/(m²·V)",
"milliwatt": "mW",
"microwatt": "μW",
"watt": "W",
"kilowatt": "kW",
"megawatt": "MW",
"gigawatt": "GW",
"metric-horsepower": "PS",
"milliwatt-per-square-centimeter": "mW/cm²",
"watt-per-square-centimeter": "W/cm²",
"kilowatt-per-square-centimeter": "kW/cm²",
"milliwatt-per-square-meter": "mW/m²",
"watt-per-square-meter": "W/m²",
"kilowatt-per-square-meter": "kW/m²",
"watt-per-square-inch": "W/in²",
"kilowatt-per-square-inch": "kW/in²",
"horsepower": "hp",
"btu-per-hour": "BTU/h",
"coulomb": "C",
"millicoulomb": "mC",
"microcoulomb": "μC",
"picocoulomb": "pC",
"coulomb-per-meter": "C/m",
"coulomb-per-cubic-meter": "C/m³",
"coulomb-per-square-meter": "C/m²",
"square-millimeter": "mm²",
"square-centimeter": "cm²",
"square-meter": "m²",
"hectare": "ha",
"square-kilometer": "km²",
"square-inch": "in²",
"square-foot": "ft²",
"square-yard": "yd²",
"acre": "ac",
"square-mile": "mi²",
"are": "a",
"barn": "b",
"circular-inch": "c in²",
"milliampere-hour": "mAh",
"ampere-hours": "Ah",
"kiloampere-hours": "kAh",
"nanoampere": "nA",
"picoampere": "pA",
"microampere": "μA",
"milliampere": "mA",
"ampere": "A",
"kiloamperes": "kA",
"microampere-per-square-centimeter": "μA/cm²",
"ampere-per-square-meter": "A/m²",
"ampere-per-meter": "A/m",
"oersted": "Oe",
"bohr-magneton": "μB",
"ampere-meter-squared": "A·m²",
"ampere-meter": "A·m",
"nanovolt": "nV",
"picovolt": "pV",
"millivolts": "mV",
"microvolts": "μV",
"volt": "V",
"kilovolts": "kV",
"dbmV": "dBmV",
"dbm": "dBm",
"volt-meter": "V·m",
"kilovolt-meter": "kV·m",
"megavolt-meter": "MV·m",
"microvolt-meter": "μV·m",
"millivolt-meter": "mV·m",
"nanovolt-meter": "nV·m",
"ohm": "Ω",
"microohm": "μΩ",
"milliohm": "mΩ",
"kilohm": "kΩ",
"megohm": "MΩ",
"gigohm": "GΩ",
"hertz": "Hz",
"kilohertz": "kHz",
"megahertz": "MHz",
"gigahertz": "GHz",
"rpm": "rpm",
"candela-per-square-meter": "cd/m²",
"candela": "cd",
"lumen": "lm",
"lux": "lx",
"foot-candle": "fc",
"lumen-per-square-meter": "lm/m²",
"lux-second": "lx·s",
"lumen-second": "lm·s",
"lumens-per-watt": "lm/W",
"absorbance": "AU",
"mole": "mol",
"nanomole": "nmol",
"micromole": "μmol",
"millimole": "mmol",
"kilomole": "kmol",
"mole-per-cubic-meter": "mol/m³",
"rssi": "RSSI",
"ppm": "ppm",
"ppb": "ppb",
"micrograms-per-cubic-meter": "μg/m³",
"aqi": "AQI",
"gram-per-cubic-meter": "g/m³",
"gram-per-kilogram": "g/kg",
"millimeters-per-second": "mm/s",
"neper": "Np",
"bel": "B",
"decibel": "dB",
"meters-per-second-squared": "m/s²",
"becquerel": "Bq",
"curie": "Ci",
"gray": "Gy",
"sievert": "Sv",
"roentgen": "R",
"cps": "cps",
"rad": "rad",
"rem": "rem",
"dps": "dps",
"rutherford": "Rd",
"coulombs-per-kilogram": "C/kg",
"becquerels-per-cubic-meter": "Bq/m³",
"curies-per-liter": "Ci/L",
"becquerels-per-second": "Bq/s",
"curies-per-second": "Ci/s",
"gy-per-second": "Gy/s",
"watt-per-steradian": "W/sr",
"watt-per-square-metre-steradian": "W/(m²·sr)",
"ph-level": "pH",
"turbidity": "NTU",
"mg-per-liter": "mg/L",
"microsiemens-per-centimeter": "μS/cm",
"millisiemens-per-meter": "mS/m",
"siemens-per-meter": "S/m",
"kilogram-per-cubic-meter": "kg/m³",
"gram-per-cubic-centimeter": "g/cm³",
"kilogram-per-square-meter": "kg/m²",
"milligram-per-milliliter": "mg/mL",
"pound-per-cubic-foot": "lb/ft³",
"ounces-per-cubic-inch": "oz/in³",
"tons-per-cubic-yard": "ton/yd³",
"particle-density": "PD",
"kilometers-per-liter": "km/L",
"miles-per-gallon": "mpg",
"liters-per-100-km": "L/100 km",
"gallons-per-mile": "gal/mi",
"liters-per-hour": "L/h",
"gallons-per-hour": "gal/h",
"beats-per-minute": "bpm",
"millimeters-of-mercury": "mmHg",
"milligrams-per-deciliter": "mg/dL",
"g-force": "G",
"kilonewton": "kN",
"kilogram-force": "kgf",
"pound-force": "lbf",
"kilopound-force": "kip",
"dyne": "dyn",
"poundal": "pdl",
"kip": "kip",
"gal": "Gal",
"gravity": "g₀",
"hectopascal": "hPa",
"atmosphere": "atm",
"millibars": "mbar",
"inch-of-mercury": "inHg",
"richter-scale": "Richter",
"second": "s",
"minute": "min",
"hour": "hr",
"day": "day",
"week": "wk",
"month": "mo",
"year": "yr",
"cubic-foot-per-minute": "ft³/min",
"cubic-meters-per-hour": "m³/h",
"cubic-meters-per-second": "m³/s",
"liter-per-second": "L/s",
"liter-per-minute": "L/min",
"gallons-per-minute": "GPM",
"cubic-foot-per-second": "ft³/s",
"milliliters-per-minute": "mL/min",
"bit": "bit",
"byte": "B",
"kilobyte": "KB",
"megabyte": "MB",
"gigabyte": "GB",
"terabyte": "TB",
"petabyte": "PB",
"exabyte": "EB",
"zettabyte": "ZB",
"yottabyte": "YB",
"bit-per-second": "bps",
"kilobit-per-second": "Kbps",
"megabit-per-second": "Mbps",
"gigabit-per-second": "Gbps",
"terabit-per-second": "Tbps",
"byte-per-second": "B/s",
"kilobyte-per-second": "KB/s",
"megabyte-per-second": "MB/s",
"gigabyte-per-second": "GB/s",
"degree": "°",
"radian": "rad",
"gradian": "grad",
"mil": "mil",
"revolution": "rev",
"siemens": "S",
"millisiemens": "mS",
"microsiemens": "μS",
"kilosiemens": "kS",
"megasiemens": "MS",
"gigasiemens": "GS",
"farad": "F",
"millifarad": "mF",
"microfarad": "μF",
"nanofarad": "nF",
"picofarad": "pF",
"kilofarad": "kF",
"megafarad": "MF",
"gigafarad": "GF",
"terfarad": "TF",
"farad-per-meter": "F/m",
"tesla": "T",
"gauss": "G",
"kilogauss": "kG",
"millitesla": "mT",
"microtesla": "μT",
"nanotesla": "nT",
"kilotesla": "kT",
"megatesla": "MT",
"millitesla-square-meters": "mT·m²",
"gamma": "γ",
"lambda": "λ",
"square-meter-per-second": "m²/s",
"square-centimeter-per-second": "cm²/s",
"stoke": "St",
"centistokes": "cSt",
"square-foot-per-second": "ft²/s",
"square-inch-per-second": "in²/s",
"pascal-second": "Pa·s",
"centipoise": "cP",
"poise": "P",
"reynolds": "Re",
"pound-per-foot-hour": "lb/(ft·hr)",
"newton-second-per-square-meter": "N·s/m²",
"dyne-second-per-square-centimeter": "dyn·s/cm²",
"kilogram-per-meter-second": "kg/(m·s)",
"tesla-square-meters": "T·m²",
"maxwell": "Mx",
"tesla-per-meter": "T/m",
"gauss-per-centimeter": "G/cm",
"weber": "Wb",
"microweber": "μWb",
"milliweber": "mWb",
"gauss-square-centimeter": "G·cm²",
"kilogauss-square-centimeter": "kG·cm²",
"henry": "H",
"millihenry": "mH",
"microhenry": "μH",
"nanohenry": "nH",
"henry-per-meter": "H/m",
"tesla-meter-per-ampere": "T·m/A",
"gauss-per-oersted": "G/Oe",
"kilogram-per-mole": "kg/mol",
"gram-per-mole": "g/mol",
"milligram-per-mole": "mg/mol",
"joule-per-mole": "J/mol",
"joule-per-mole-kelvin": "J/mol-K",
"millivolts-per-meter": "mV/m",
"volts-per-meter": "V/m",
"kilovolts-per-meter": "kV/m",
"radian-per-second": "rad/s",
"radian-per-second-squared": "rad/s^2",
"revolutions-per-minute-per-second": "rpm/s",
"revolutions-per-minute-per-second-squared": "rpm/s^2",
"deg-per-second": "deg/s",
"degrees-brix": "°Bx",
"katal": "kat",
"katal-per-cubic-metre": "kat/m^3"
},
"user": {
"user": "用户",
"users": "用户",
@ -4765,15 +4441,15 @@
"no-widgets-text": "未找到部件",
"management": "管理部件",
"editor": "部件编辑器",
"confirm-to-exit-editor-html": "You have unsaved widget settings.<br>Are you sure you want to leave this page?",
"confirm-to-exit-editor-html": "有未保存的部件设置。<br>确定要离开此页面吗?",
"widget-type-not-found": "加载部件配置出错。<br>可能关联的部件已经删除了。",
"widget-type-load-error": "由于以下错误未加载部件:",
"remove": "删除部件",
"delete": "Delete widget",
"delete": "删除部件",
"edit": "编辑部件",
"remove-widget-title": "确定要删除 '{{widgetTitle}}'部件吗?",
"remove-widget-text": "确认后,控件和所有相关数据将变得不可恢复。",
"timeseries": "Timeseries",
"timeseries": "时间序列",
"search-data": "查找数据",
"no-data-found": "未找到数据",
"latest": "最新值",
@ -5148,12 +4824,12 @@
"axis-position-top": "顶部 (默认)",
"axis-position-bottom": "底部",
"custom-legend-settings": "自定义图例设置",
"enable-custom-legend": "启用自定义图例 (这将允许您在键标签中使用属性/Timeseries值)",
"enable-custom-legend": "启用自定义图例 (这将允许您在键标签中使用属性/时间序列值)",
"key-name": "键名",
"key-name-required": "键名是必需的",
"key-type": "键类型",
"key-type-attribute": "属性",
"key-type-timeseries": "Timeseries",
"key-type-timeseries": "时间序列",
"label-keys-list": "要在标签中使用的键列表",
"no-label-keys": "未配置键",
"add-label-key": "添加新键",
@ -5552,7 +5228,7 @@
"discard-changes": "放弃更改",
"entity-attribute-required": "实体属性必填",
"entity-coordinate-required": "纬度和经度两个字段都是必需的",
"entity-timeseries-required": "实体 Timeseries 必填",
"entity-timeseries-required": "实体时间序列必填",
"get-location": "获取当前位置",
"invalid-date": "无效日期",
"latitude": "纬度",
@ -5570,19 +5246,19 @@
"enable-https-use-widget": "请启用HTTPS以使用此部件",
"no-found-your-camera": "未找到摄像机",
"no-permission-camera": "权限被用户拒绝/此站点无权使用摄像机",
"no-timeseries-selected": "未选择 Timeseries",
"no-timeseries-selected": "未选择时间序列值",
"secret-key": "密钥",
"secret-key-required": "密钥必填",
"switch-attribute-value": "切换实体属性值",
"switch-camera": "切换摄像机",
"switch-timeseries-value": "切换实体 Timeseries 值",
"switch-timeseries-value": "切换实体时间序列值",
"take-photo": "拍照",
"time": "时间",
"timeseries-not-allowed": "Timeseries 参数不能用于此部件",
"timeseries-not-allowed": "时间序列参数不能用于此部件",
"update-failed": "更新失败",
"update-successful": "更新成功",
"update-attribute": "更新属性",
"update-timeseries": "更新 Timeseries",
"update-timeseries": "更新时间序列",
"value": "数值",
"general-settings": "通用设置",
"widget-title": "部件标题",
@ -5638,7 +5314,7 @@
"attribute-settings": "属性设置",
"widget-mode": "部件模式",
"widget-mode-update-attribute": "更新属性",
"widget-mode-update-timeseries": "更新 Timeseries",
"widget-mode-update-timeseries": "更新时间序列",
"attribute-scope": "属性范围",
"attribute-scope-server": "服务器属性",
"attribute-scope-shared": "共享属性",
@ -5669,7 +5345,7 @@
"datakey-type": "数据键类型",
"datakey-type-server": "服务器属性(默认)",
"datakey-type-shared": "共享属性",
"datakey-type-timeseries": "Timeseries",
"datakey-type-timeseries": "时间序列",
"datakey-value-type": "数据键值类型",
"datakey-value-type-string": "字符串",
"datakey-value-type-double": "双精度",
@ -5827,7 +5503,7 @@
"retrieve-value-method-attribute": "订阅属性获取值",
"retrieve-value-method-timeseries": "订阅时间序列获取值",
"attribute-value-key": "属性键",
"timeseries-value-key": "Timeseries键",
"timeseries-value-key": "时间序列键",
"get-value-method": "RPC获取值方法",
"parse-value-function": "解析值的函数",
"update-value-settings": "更新值设置",

30
ui-ngx/src/form.scss

@ -233,6 +233,36 @@
}
}
.tb-flex {
display: flex;
flex: 1;
gap: 8px;
&.row {
flex-direction: row;
}
&.column {
flex-direction: column;
}
&.flex-start {
justify-content: flex-start;
}
&.flex-end {
justify-content: flex-end;
}
&.space-between {
justify-content: space-between;
}
&.align-center {
align-items: center;
}
&.no-gap {
gap: 0;
}
&.fill-width {
width: 100%;
}
}
.tb-form-panel, .tb-form-row {
.mat-slide {
margin: 0;

4
ui-ngx/src/styles.scss

@ -1252,4 +1252,8 @@ mat-icon {
.cursor-pointer {
cursor: pointer;
}
.no-wrap {
white-space: nowrap;
}
}

Loading…
Cancel
Save