diff --git a/application/src/main/java/org/thingsboard/server/controller/ControllerConstants.java b/application/src/main/java/org/thingsboard/server/controller/ControllerConstants.java index e45d041950..8817c24efe 100644 --- a/application/src/main/java/org/thingsboard/server/controller/ControllerConstants.java +++ b/application/src/main/java/org/thingsboard/server/controller/ControllerConstants.java @@ -189,6 +189,29 @@ public class ControllerConstants { protected static final String EVENT_DEBUG_RULE_CHAIN_FILTER_OBJ = MARKDOWN_CODE_BLOCK_START + "{\n" + " \"eventType\":\"DEBUG_RULE_CHAIN\",\n" + DEBUG_FILTER_OBJ + MARKDOWN_CODE_BLOCK_END; + protected static final String EVENT_DEBUG_CALCULATED_FIELD_FILTER_OBJ = MARKDOWN_CODE_BLOCK_START + "{\n" + + " \"eventType\":\"DEBUG_CALCULATED_FIELD\",\n" + + " \"server\":\"ip-172-31-24-152\",\n" + + " \"isError\":\"false\",\n" + + " \"errorStr\":\"Error Message\"\n" + + " \"entityId\":\"cf4b8741-f618-471f-ae08-d881ca7f9fe9\",\n" + + " \"msgId\":\"5cf7d3a0-aee7-40dd-a737-ade05528e7eb\",\n" + + " \"msgType\":\"POST_TELEMETRY_REQUEST\",\n" + + " \"arguments\":\"{\n" + + " \"x\": {\n" + + " \"ts\": 1739432016629,\n" + + " \"value\": 20\n" + + " },\n" + + " \"y\": {\n" + + " \"ts\": 1739429717656,\n" + + " \"value\": 12\n" + + " }\n" + + " }\",\n" + + " \"result\":\"{\n" + + " \"x + y\": 32\n" + + " }\",\n" + + "}" + MARKDOWN_CODE_BLOCK_END; + protected static final String IS_BOOTSTRAP_SERVER_PARAM_DESCRIPTION = "A Boolean value representing the Server SecurityInfo for future Bootstrap client mode settings. Values: 'true' for Bootstrap Server; 'false' for Lwm2m Server. "; protected static final String DEVICE_WITH_DEVICE_CREDENTIALS_ACCESS_TOKEN_PARAM_DESCRIPTION = diff --git a/application/src/main/java/org/thingsboard/server/controller/EventController.java b/application/src/main/java/org/thingsboard/server/controller/EventController.java index d64f6c469d..dd27ed9c33 100644 --- a/application/src/main/java/org/thingsboard/server/controller/EventController.java +++ b/application/src/main/java/org/thingsboard/server/controller/EventController.java @@ -47,6 +47,7 @@ import static org.thingsboard.server.controller.ControllerConstants.ENTITY_ID; import static org.thingsboard.server.controller.ControllerConstants.ENTITY_ID_PARAM_DESCRIPTION; import static org.thingsboard.server.controller.ControllerConstants.ENTITY_TYPE; import static org.thingsboard.server.controller.ControllerConstants.ENTITY_TYPE_PARAM_DESCRIPTION; +import static org.thingsboard.server.controller.ControllerConstants.EVENT_DEBUG_CALCULATED_FIELD_FILTER_OBJ; import static org.thingsboard.server.controller.ControllerConstants.EVENT_DEBUG_RULE_CHAIN_FILTER_OBJ; import static org.thingsboard.server.controller.ControllerConstants.EVENT_DEBUG_RULE_NODE_FILTER_OBJ; import static org.thingsboard.server.controller.ControllerConstants.EVENT_END_TIME_DESCRIPTION; @@ -70,7 +71,7 @@ import static org.thingsboard.server.controller.ControllerConstants.TENANT_ID_PA public class EventController extends BaseController { private static final String EVENT_FILTER_DEFINITION = "# Event Filter Definition" + NEW_LINE + - "5 different eventFilter objects could be set for different event types. " + + "6 different eventFilter objects could be set for different event types. " + "The eventType field is required. Others are optional. If some of them are set, the filtering will be applied according to them. " + "See the examples below for all the fields used for each event type filtering. " + NEW_LINE + "Note," + NEW_LINE + @@ -98,6 +99,15 @@ public class EventController extends BaseController { " * 'relationType' - string value representing the type of message routing;\n" + " * 'entityId' - string value representing the entity id in the event body (originator of the message);\n" + " * 'msgType' - string value representing the message type;\n" + + " * 'isError' - boolean value to filter the errors." + NEW_LINE + + "## Debug Calculated Field Event Filter" + NEW_LINE + + EVENT_DEBUG_CALCULATED_FIELD_FILTER_OBJ + NEW_LINE + + " * 'entityId' - string value representing the entity id in the event body;\n" + + " * 'entityType' - string value representing the entity type;\n" + + " * 'msgId' - string value representing the message id in the rule engine;\n" + + " * 'msgType' - string value representing the message type;\n" + + " * 'arguments' - string value representing the arguments that were used in the calculation performed;\n" + + " * 'result' - string value representing the result of a calculation;\n" + " * 'isError' - boolean value to filter the errors." + NEW_LINE; @Autowired diff --git a/application/src/main/java/org/thingsboard/server/service/cf/DefaultCalculatedFieldCache.java b/application/src/main/java/org/thingsboard/server/service/cf/DefaultCalculatedFieldCache.java index 64487d9b3e..219a261183 100644 --- a/application/src/main/java/org/thingsboard/server/service/cf/DefaultCalculatedFieldCache.java +++ b/application/src/main/java/org/thingsboard/server/service/cf/DefaultCalculatedFieldCache.java @@ -144,6 +144,9 @@ public class DefaultCalculatedFieldCache implements CalculatedFieldCache { calculatedFieldFetchLock.lock(); try { CalculatedField calculatedField = calculatedFieldService.findById(tenantId, calculatedFieldId); + if (calculatedField == null) { + return; + } EntityId cfEntityId = calculatedField.getEntityId(); calculatedFields.put(calculatedFieldId, calculatedField); diff --git a/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/CalculatedFieldCtx.java b/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/CalculatedFieldCtx.java index fdbbb37568..674ce3726f 100644 --- a/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/CalculatedFieldCtx.java +++ b/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/CalculatedFieldCtx.java @@ -44,6 +44,8 @@ import java.util.HashMap; import java.util.List; import java.util.Map; +import static org.thingsboard.common.util.ExpressionFunctionsUtil.userDefinedFunctions; + @Data public class CalculatedFieldCtx { @@ -111,6 +113,7 @@ public class CalculatedFieldCtx { if (isValidExpression(expression)) { this.customExpression = ThreadLocal.withInitial(() -> new ExpressionBuilder(expression) + .functions(userDefinedFunctions) .implicitMultiplication(true) .variables(this.arguments.keySet()) .build() diff --git a/application/src/main/java/org/thingsboard/server/service/edge/EdgeMsgConstructorUtils.java b/application/src/main/java/org/thingsboard/server/service/edge/EdgeMsgConstructorUtils.java index b59dc096ee..9bcd8fc373 100644 --- a/application/src/main/java/org/thingsboard/server/service/edge/EdgeMsgConstructorUtils.java +++ b/application/src/main/java/org/thingsboard/server/service/edge/EdgeMsgConstructorUtils.java @@ -16,6 +16,7 @@ package org.thingsboard.server.service.edge; import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.node.ObjectNode; import com.google.gson.Gson; import com.google.gson.JsonArray; import com.google.gson.JsonElement; @@ -24,6 +25,11 @@ import com.google.gson.JsonPrimitive; import com.google.gson.reflect.TypeToken; import lombok.extern.slf4j.Slf4j; import org.thingsboard.common.util.JacksonUtil; +import org.thingsboard.rule.engine.action.TbSaveToCustomCassandraTableNode; +import org.thingsboard.rule.engine.aws.lambda.TbAwsLambdaNode; +import org.thingsboard.rule.engine.rest.TbSendRestApiCallReplyNode; +import org.thingsboard.rule.engine.telemetry.TbMsgAttributesNode; +import org.thingsboard.rule.engine.telemetry.TbMsgTimeseriesNode; import org.thingsboard.server.common.adaptor.JsonConverter; import org.thingsboard.server.common.data.Customer; import org.thingsboard.server.common.data.Dashboard; @@ -89,6 +95,7 @@ import org.thingsboard.server.gen.edge.v1.DeviceProfileUpdateMsg; import org.thingsboard.server.gen.edge.v1.DeviceRpcCallMsg; import org.thingsboard.server.gen.edge.v1.DeviceUpdateMsg; import org.thingsboard.server.gen.edge.v1.EdgeConfiguration; +import org.thingsboard.server.gen.edge.v1.EdgeVersion; import org.thingsboard.server.gen.edge.v1.EntityDataProto; import org.thingsboard.server.gen.edge.v1.EntityViewUpdateMsg; import org.thingsboard.server.gen.edge.v1.NotificationRuleUpdateMsg; @@ -113,11 +120,36 @@ import org.thingsboard.server.gen.edge.v1.WidgetTypeUpdateMsg; import org.thingsboard.server.gen.edge.v1.WidgetsBundleUpdateMsg; import org.thingsboard.server.gen.transport.TransportProtos; +import java.util.Iterator; import java.util.List; +import java.util.Map; +import java.util.Set; import java.util.UUID; @Slf4j public class EdgeMsgConstructorUtils { + public static final Map> IGNORED_PARAMS_BY_EDGE_VERSION = Map.of( + EdgeVersion.V_3_8_0, + Map.of( + TbMsgTimeseriesNode.class.getName(), "processingSettings", + TbMsgAttributesNode.class.getName(), "processingSettings", + TbSaveToCustomCassandraTableNode.class.getName(), "defaultTtl" + ), + EdgeVersion.V_3_7_0, + Map.of( + TbMsgTimeseriesNode.class.getName(), "processingSettings", + TbMsgAttributesNode.class.getName(), "processingSettings", + TbSaveToCustomCassandraTableNode.class.getName(), "defaultTtl" + ) + ); + + public static final Map> EXCLUDED_NODES_BY_EDGE_VERSION = Map.of( + EdgeVersion.V_3_7_0, + Set.of( + TbSendRestApiCallReplyNode.class.getName(), + TbAwsLambdaNode.class.getName() + ) + ); public static AlarmUpdateMsg constructAlarmUpdatedMsg(UpdateMsgType msgType, Alarm alarm) { return AlarmUpdateMsg.newBuilder().setMsgType(msgType) @@ -417,8 +449,50 @@ public class EdgeMsgConstructorUtils { .setIdLSB(ruleChainId.getId().getLeastSignificantBits()).build(); } - public static RuleChainMetadataUpdateMsg constructRuleChainMetadataUpdatedMsg(UpdateMsgType msgType, RuleChainMetaData ruleChainMetaData) { - return RuleChainMetadataUpdateMsg.newBuilder().setMsgType(msgType).setEntity(JacksonUtil.toString(ruleChainMetaData)).build(); + public static RuleChainMetadataUpdateMsg constructRuleChainMetadataUpdatedMsg(UpdateMsgType msgType, RuleChainMetaData ruleChainMetaData, EdgeVersion edgeVersion) { + String metaData = sanitizeMetadataForLegacyEdgeVersion(ruleChainMetaData, edgeVersion); + + return RuleChainMetadataUpdateMsg.newBuilder() + .setMsgType(msgType) + .setEntity(metaData) + .build(); + } + + private static String sanitizeMetadataForLegacyEdgeVersion(RuleChainMetaData ruleChainMetaData, EdgeVersion edgeVersion) { + JsonNode jsonNode = JacksonUtil.valueToTree(ruleChainMetaData); + JsonNode nodes = jsonNode.get("nodes"); + + updateNodeConfigurationsForLegacyEdge(nodes, edgeVersion); + removeExcludedNodesForLegacyEdge(nodes, edgeVersion); + + return JacksonUtil.toString(jsonNode); + } + + private static void updateNodeConfigurationsForLegacyEdge(JsonNode nodes, EdgeVersion edgeVersion) { + nodes.forEach(node -> { + if (node.isObject() && node.has("configuration")) { + String nodeType = node.get("type").asText(); + Map ignoredParams = IGNORED_PARAMS_BY_EDGE_VERSION.get(edgeVersion); + + if (ignoredParams != null && ignoredParams.containsKey(nodeType)) { + ((ObjectNode) node.get("configuration")).remove(ignoredParams.get(nodeType)); + } + } + }); + } + + private static void removeExcludedNodesForLegacyEdge(JsonNode nodes, EdgeVersion edgeVersion) { + Iterator iterator = nodes.iterator(); + + while (iterator.hasNext()) { + JsonNode node = iterator.next(); + String type = node.get("type").asText(); + Set missNodes = EXCLUDED_NODES_BY_EDGE_VERSION.get(edgeVersion); + + if (missNodes != null && missNodes.contains(type)) { + iterator.remove(); + } + } } public static EntityDataProto constructEntityDataMsg(TenantId tenantId, EntityId entityId, EdgeEventActionType actionType, JsonElement entityData) { @@ -467,7 +541,7 @@ public class EdgeMsgConstructorUtils { AttributeDeleteMsg.Builder attributeDeleteMsg = AttributeDeleteMsg.newBuilder(); attributeDeleteMsg.setScope(entityData.getAsJsonObject().getAsJsonPrimitive("scope").getAsString()); JsonArray jsonArray = entityData.getAsJsonObject().getAsJsonArray("keys"); - List keys = new Gson().fromJson(jsonArray.toString(), new TypeToken<>(){}.getType()); + List keys = new Gson().fromJson(jsonArray.toString(), new TypeToken<>() {}.getType()); attributeDeleteMsg.addAllAttributeNames(keys); attributeDeleteMsg.build(); builder.setAttributeDeleteMsg(attributeDeleteMsg); diff --git a/application/src/main/java/org/thingsboard/server/service/edge/rpc/EdgeGrpcSession.java b/application/src/main/java/org/thingsboard/server/service/edge/rpc/EdgeGrpcSession.java index a970097548..ecd0c6d9f3 100644 --- a/application/src/main/java/org/thingsboard/server/service/edge/rpc/EdgeGrpcSession.java +++ b/application/src/main/java/org/thingsboard/server/service/edge/rpc/EdgeGrpcSession.java @@ -780,7 +780,7 @@ public abstract class EdgeGrpcSession implements Closeable { return null; } - return ctx.getProcessor(edgeEvent.getType()).convertEdgeEventToDownlink(edgeEvent); + return ctx.getProcessor(edgeEvent.getType()).convertEdgeEventToDownlink(edgeEvent, edgeVersion); } public void addEventToHighPriorityQueue(EdgeEvent edgeEvent) { diff --git a/application/src/main/java/org/thingsboard/server/service/edge/rpc/processor/EdgeProcessor.java b/application/src/main/java/org/thingsboard/server/service/edge/rpc/processor/EdgeProcessor.java index 0f17949437..dd6221c62b 100644 --- a/application/src/main/java/org/thingsboard/server/service/edge/rpc/processor/EdgeProcessor.java +++ b/application/src/main/java/org/thingsboard/server/service/edge/rpc/processor/EdgeProcessor.java @@ -20,13 +20,14 @@ import org.thingsboard.server.common.data.edge.EdgeEvent; import org.thingsboard.server.common.data.edge.EdgeEventType; import org.thingsboard.server.common.data.id.TenantId; import org.thingsboard.server.gen.edge.v1.DownlinkMsg; +import org.thingsboard.server.gen.edge.v1.EdgeVersion; import org.thingsboard.server.gen.transport.TransportProtos; public interface EdgeProcessor { ListenableFuture processEntityNotification(TenantId tenantId, TransportProtos.EdgeNotificationMsgProto msg); - default DownlinkMsg convertEdgeEventToDownlink(EdgeEvent edgeEvent) { + default DownlinkMsg convertEdgeEventToDownlink(EdgeEvent edgeEvent, EdgeVersion edgeVersion) { return null; } diff --git a/application/src/main/java/org/thingsboard/server/service/edge/rpc/processor/alarm/AlarmEdgeProcessor.java b/application/src/main/java/org/thingsboard/server/service/edge/rpc/processor/alarm/AlarmEdgeProcessor.java index df9af20f84..6c4cbe7a94 100644 --- a/application/src/main/java/org/thingsboard/server/service/edge/rpc/processor/alarm/AlarmEdgeProcessor.java +++ b/application/src/main/java/org/thingsboard/server/service/edge/rpc/processor/alarm/AlarmEdgeProcessor.java @@ -30,6 +30,7 @@ import org.thingsboard.server.common.data.id.EdgeId; import org.thingsboard.server.common.data.id.TenantId; import org.thingsboard.server.gen.edge.v1.AlarmUpdateMsg; import org.thingsboard.server.gen.edge.v1.DownlinkMsg; +import org.thingsboard.server.gen.edge.v1.EdgeVersion; import org.thingsboard.server.gen.edge.v1.UpdateMsgType; import org.thingsboard.server.gen.transport.TransportProtos; import org.thingsboard.server.queue.util.TbCoreComponent; @@ -55,7 +56,7 @@ public class AlarmEdgeProcessor extends BaseAlarmProcessor implements AlarmProce } @Override - public DownlinkMsg convertEdgeEventToDownlink(EdgeEvent edgeEvent) { + public DownlinkMsg convertEdgeEventToDownlink(EdgeEvent edgeEvent, EdgeVersion edgeVersion) { AlarmUpdateMsg alarmUpdateMsg = convertAlarmEventToAlarmMsg(edgeEvent); if (alarmUpdateMsg != null) { return DownlinkMsg.newBuilder() diff --git a/application/src/main/java/org/thingsboard/server/service/edge/rpc/processor/alarm/comment/AlarmCommentEdgeProcessor.java b/application/src/main/java/org/thingsboard/server/service/edge/rpc/processor/alarm/comment/AlarmCommentEdgeProcessor.java index 449f4867d1..4a76a3527b 100644 --- a/application/src/main/java/org/thingsboard/server/service/edge/rpc/processor/alarm/comment/AlarmCommentEdgeProcessor.java +++ b/application/src/main/java/org/thingsboard/server/service/edge/rpc/processor/alarm/comment/AlarmCommentEdgeProcessor.java @@ -31,6 +31,7 @@ import org.thingsboard.server.common.data.id.EdgeId; import org.thingsboard.server.common.data.id.TenantId; import org.thingsboard.server.gen.edge.v1.AlarmCommentUpdateMsg; import org.thingsboard.server.gen.edge.v1.DownlinkMsg; +import org.thingsboard.server.gen.edge.v1.EdgeVersion; import org.thingsboard.server.gen.edge.v1.UpdateMsgType; import org.thingsboard.server.gen.transport.TransportProtos; import org.thingsboard.server.queue.util.TbCoreComponent; @@ -57,7 +58,7 @@ public class AlarmCommentEdgeProcessor extends BaseAlarmProcessor implements Ala } @Override - public DownlinkMsg convertEdgeEventToDownlink(EdgeEvent edgeEvent) { + public DownlinkMsg convertEdgeEventToDownlink(EdgeEvent edgeEvent, EdgeVersion edgeVersion) { UpdateMsgType msgType = getUpdateMsgType(edgeEvent.getAction()); switch (edgeEvent.getAction()) { case ADDED_COMMENT: diff --git a/application/src/main/java/org/thingsboard/server/service/edge/rpc/processor/asset/AssetEdgeProcessor.java b/application/src/main/java/org/thingsboard/server/service/edge/rpc/processor/asset/AssetEdgeProcessor.java index e7a9720419..47f4c11362 100644 --- a/application/src/main/java/org/thingsboard/server/service/edge/rpc/processor/asset/AssetEdgeProcessor.java +++ b/application/src/main/java/org/thingsboard/server/service/edge/rpc/processor/asset/AssetEdgeProcessor.java @@ -36,6 +36,7 @@ import org.thingsboard.server.common.msg.TbMsgMetaData; import org.thingsboard.server.dao.exception.DataValidationException; import org.thingsboard.server.gen.edge.v1.AssetUpdateMsg; import org.thingsboard.server.gen.edge.v1.DownlinkMsg; +import org.thingsboard.server.gen.edge.v1.EdgeVersion; import org.thingsboard.server.gen.edge.v1.UpdateMsgType; import org.thingsboard.server.queue.util.TbCoreComponent; import org.thingsboard.server.service.edge.EdgeMsgConstructorUtils; @@ -107,7 +108,7 @@ public class AssetEdgeProcessor extends BaseAssetProcessor implements AssetProce } @Override - public DownlinkMsg convertEdgeEventToDownlink(EdgeEvent edgeEvent) { + public DownlinkMsg convertEdgeEventToDownlink(EdgeEvent edgeEvent, EdgeVersion edgeVersion) { AssetId assetId = new AssetId(edgeEvent.getEntityId()); switch (edgeEvent.getAction()) { case ADDED, UPDATED, ASSIGNED_TO_EDGE, ASSIGNED_TO_CUSTOMER, UNASSIGNED_FROM_CUSTOMER -> { diff --git a/application/src/main/java/org/thingsboard/server/service/edge/rpc/processor/asset/profile/AssetProfileEdgeProcessor.java b/application/src/main/java/org/thingsboard/server/service/edge/rpc/processor/asset/profile/AssetProfileEdgeProcessor.java index 7a0ae0ccc6..e05b6f41c3 100644 --- a/application/src/main/java/org/thingsboard/server/service/edge/rpc/processor/asset/profile/AssetProfileEdgeProcessor.java +++ b/application/src/main/java/org/thingsboard/server/service/edge/rpc/processor/asset/profile/AssetProfileEdgeProcessor.java @@ -36,6 +36,7 @@ import org.thingsboard.server.common.msg.TbMsgMetaData; import org.thingsboard.server.dao.exception.DataValidationException; import org.thingsboard.server.gen.edge.v1.AssetProfileUpdateMsg; import org.thingsboard.server.gen.edge.v1.DownlinkMsg; +import org.thingsboard.server.gen.edge.v1.EdgeVersion; import org.thingsboard.server.gen.edge.v1.UpdateMsgType; import org.thingsboard.server.queue.util.TbCoreComponent; import org.thingsboard.server.service.edge.EdgeMsgConstructorUtils; @@ -94,7 +95,7 @@ public class AssetProfileEdgeProcessor extends BaseAssetProfileProcessor impleme } @Override - public DownlinkMsg convertEdgeEventToDownlink(EdgeEvent edgeEvent) { + public DownlinkMsg convertEdgeEventToDownlink(EdgeEvent edgeEvent, EdgeVersion edgeVersion) { AssetProfileId assetProfileId = new AssetProfileId(edgeEvent.getEntityId()); switch (edgeEvent.getAction()) { case ADDED, UPDATED -> { diff --git a/application/src/main/java/org/thingsboard/server/service/edge/rpc/processor/customer/CustomerEdgeProcessor.java b/application/src/main/java/org/thingsboard/server/service/edge/rpc/processor/customer/CustomerEdgeProcessor.java index 40cbe3985f..784d10b26c 100644 --- a/application/src/main/java/org/thingsboard/server/service/edge/rpc/processor/customer/CustomerEdgeProcessor.java +++ b/application/src/main/java/org/thingsboard/server/service/edge/rpc/processor/customer/CustomerEdgeProcessor.java @@ -32,6 +32,7 @@ import org.thingsboard.server.common.data.id.TenantId; import org.thingsboard.server.common.data.page.PageDataIterable; import org.thingsboard.server.gen.edge.v1.CustomerUpdateMsg; import org.thingsboard.server.gen.edge.v1.DownlinkMsg; +import org.thingsboard.server.gen.edge.v1.EdgeVersion; import org.thingsboard.server.gen.edge.v1.UpdateMsgType; import org.thingsboard.server.gen.transport.TransportProtos; import org.thingsboard.server.queue.util.TbCoreComponent; @@ -48,7 +49,7 @@ import java.util.UUID; public class CustomerEdgeProcessor extends BaseEdgeProcessor { @Override - public DownlinkMsg convertEdgeEventToDownlink(EdgeEvent edgeEvent) { + public DownlinkMsg convertEdgeEventToDownlink(EdgeEvent edgeEvent, EdgeVersion edgeVersion) { CustomerId customerId = new CustomerId(edgeEvent.getEntityId()); switch (edgeEvent.getAction()) { case ADDED, UPDATED -> { diff --git a/application/src/main/java/org/thingsboard/server/service/edge/rpc/processor/dashboard/DashboardEdgeProcessor.java b/application/src/main/java/org/thingsboard/server/service/edge/rpc/processor/dashboard/DashboardEdgeProcessor.java index f9a7311f31..e1259a7e0e 100644 --- a/application/src/main/java/org/thingsboard/server/service/edge/rpc/processor/dashboard/DashboardEdgeProcessor.java +++ b/application/src/main/java/org/thingsboard/server/service/edge/rpc/processor/dashboard/DashboardEdgeProcessor.java @@ -33,6 +33,7 @@ import org.thingsboard.server.common.msg.TbMsgMetaData; import org.thingsboard.server.dao.exception.DataValidationException; import org.thingsboard.server.gen.edge.v1.DashboardUpdateMsg; import org.thingsboard.server.gen.edge.v1.DownlinkMsg; +import org.thingsboard.server.gen.edge.v1.EdgeVersion; import org.thingsboard.server.gen.edge.v1.UpdateMsgType; import org.thingsboard.server.queue.util.TbCoreComponent; import org.thingsboard.server.service.edge.EdgeMsgConstructorUtils; @@ -100,7 +101,7 @@ public class DashboardEdgeProcessor extends BaseDashboardProcessor implements Da } @Override - public DownlinkMsg convertEdgeEventToDownlink(EdgeEvent edgeEvent) { + public DownlinkMsg convertEdgeEventToDownlink(EdgeEvent edgeEvent, EdgeVersion edgeVersion) { DashboardId dashboardId = new DashboardId(edgeEvent.getEntityId()); switch (edgeEvent.getAction()) { case ADDED, UPDATED, ASSIGNED_TO_EDGE, ASSIGNED_TO_CUSTOMER, UNASSIGNED_FROM_CUSTOMER -> { diff --git a/application/src/main/java/org/thingsboard/server/service/edge/rpc/processor/device/DeviceEdgeProcessor.java b/application/src/main/java/org/thingsboard/server/service/edge/rpc/processor/device/DeviceEdgeProcessor.java index 08f6b294bf..ab01f83cd8 100644 --- a/application/src/main/java/org/thingsboard/server/service/edge/rpc/processor/device/DeviceEdgeProcessor.java +++ b/application/src/main/java/org/thingsboard/server/service/edge/rpc/processor/device/DeviceEdgeProcessor.java @@ -49,6 +49,7 @@ import org.thingsboard.server.gen.edge.v1.DeviceCredentialsUpdateMsg; import org.thingsboard.server.gen.edge.v1.DeviceRpcCallMsg; import org.thingsboard.server.gen.edge.v1.DeviceUpdateMsg; import org.thingsboard.server.gen.edge.v1.DownlinkMsg; +import org.thingsboard.server.gen.edge.v1.EdgeVersion; import org.thingsboard.server.gen.edge.v1.UpdateMsgType; import org.thingsboard.server.queue.TbQueueCallback; import org.thingsboard.server.queue.TbQueueMsgMetadata; @@ -222,7 +223,7 @@ public class DeviceEdgeProcessor extends BaseDeviceProcessor implements DevicePr } @Override - public DownlinkMsg convertEdgeEventToDownlink(EdgeEvent edgeEvent) { + public DownlinkMsg convertEdgeEventToDownlink(EdgeEvent edgeEvent, EdgeVersion edgeVersion) { DeviceId deviceId = new DeviceId(edgeEvent.getEntityId()); switch (edgeEvent.getAction()) { case ADDED: diff --git a/application/src/main/java/org/thingsboard/server/service/edge/rpc/processor/device/profile/DeviceProfileEdgeProcessor.java b/application/src/main/java/org/thingsboard/server/service/edge/rpc/processor/device/profile/DeviceProfileEdgeProcessor.java index 35857d8edb..3355bc819f 100644 --- a/application/src/main/java/org/thingsboard/server/service/edge/rpc/processor/device/profile/DeviceProfileEdgeProcessor.java +++ b/application/src/main/java/org/thingsboard/server/service/edge/rpc/processor/device/profile/DeviceProfileEdgeProcessor.java @@ -36,6 +36,7 @@ import org.thingsboard.server.common.msg.TbMsgMetaData; import org.thingsboard.server.dao.exception.DataValidationException; import org.thingsboard.server.gen.edge.v1.DeviceProfileUpdateMsg; import org.thingsboard.server.gen.edge.v1.DownlinkMsg; +import org.thingsboard.server.gen.edge.v1.EdgeVersion; import org.thingsboard.server.gen.edge.v1.UpdateMsgType; import org.thingsboard.server.queue.util.TbCoreComponent; import org.thingsboard.server.service.edge.EdgeMsgConstructorUtils; @@ -94,7 +95,7 @@ public class DeviceProfileEdgeProcessor extends BaseDeviceProfileProcessor imple } @Override - public DownlinkMsg convertEdgeEventToDownlink(EdgeEvent edgeEvent) { + public DownlinkMsg convertEdgeEventToDownlink(EdgeEvent edgeEvent, EdgeVersion edgeVersion) { DeviceProfileId deviceProfileId = new DeviceProfileId(edgeEvent.getEntityId()); switch (edgeEvent.getAction()) { case ADDED, UPDATED -> { diff --git a/application/src/main/java/org/thingsboard/server/service/edge/rpc/processor/edge/EdgeEntityProcessor.java b/application/src/main/java/org/thingsboard/server/service/edge/rpc/processor/edge/EdgeEntityProcessor.java index 77fa31c028..5d4674ba1f 100644 --- a/application/src/main/java/org/thingsboard/server/service/edge/rpc/processor/edge/EdgeEntityProcessor.java +++ b/application/src/main/java/org/thingsboard/server/service/edge/rpc/processor/edge/EdgeEntityProcessor.java @@ -32,6 +32,7 @@ import org.thingsboard.server.common.data.id.TenantId; import org.thingsboard.server.common.data.page.PageData; import org.thingsboard.server.common.data.page.PageLink; import org.thingsboard.server.gen.edge.v1.DownlinkMsg; +import org.thingsboard.server.gen.edge.v1.EdgeVersion; import org.thingsboard.server.gen.transport.TransportProtos; import org.thingsboard.server.queue.util.TbCoreComponent; import org.thingsboard.server.service.edge.EdgeMsgConstructorUtils; @@ -101,7 +102,7 @@ public class EdgeEntityProcessor extends BaseEdgeProcessor { } @Override - public DownlinkMsg convertEdgeEventToDownlink(EdgeEvent edgeEvent) { + public DownlinkMsg convertEdgeEventToDownlink(EdgeEvent edgeEvent, EdgeVersion edgeVersion) { EdgeId edgeId = new EdgeId(edgeEvent.getEntityId()); switch (edgeEvent.getAction()) { case ASSIGNED_TO_CUSTOMER, UNASSIGNED_FROM_CUSTOMER -> { diff --git a/application/src/main/java/org/thingsboard/server/service/edge/rpc/processor/entityview/EntityViewEdgeProcessor.java b/application/src/main/java/org/thingsboard/server/service/edge/rpc/processor/entityview/EntityViewEdgeProcessor.java index a8844e7397..56785f9ac0 100644 --- a/application/src/main/java/org/thingsboard/server/service/edge/rpc/processor/entityview/EntityViewEdgeProcessor.java +++ b/application/src/main/java/org/thingsboard/server/service/edge/rpc/processor/entityview/EntityViewEdgeProcessor.java @@ -34,6 +34,7 @@ import org.thingsboard.server.common.data.msg.TbMsgType; import org.thingsboard.server.common.msg.TbMsgMetaData; import org.thingsboard.server.dao.exception.DataValidationException; import org.thingsboard.server.gen.edge.v1.DownlinkMsg; +import org.thingsboard.server.gen.edge.v1.EdgeVersion; import org.thingsboard.server.gen.edge.v1.EntityViewUpdateMsg; import org.thingsboard.server.gen.edge.v1.UpdateMsgType; import org.thingsboard.server.queue.util.TbCoreComponent; @@ -106,7 +107,7 @@ public class EntityViewEdgeProcessor extends BaseEntityViewProcessor implements } @Override - public DownlinkMsg convertEdgeEventToDownlink(EdgeEvent edgeEvent) { + public DownlinkMsg convertEdgeEventToDownlink(EdgeEvent edgeEvent, EdgeVersion edgeVersion) { EntityViewId entityViewId = new EntityViewId(edgeEvent.getEntityId()); switch (edgeEvent.getAction()) { case ADDED, UPDATED, ASSIGNED_TO_EDGE, ASSIGNED_TO_CUSTOMER, UNASSIGNED_FROM_CUSTOMER -> { diff --git a/application/src/main/java/org/thingsboard/server/service/edge/rpc/processor/notification/NotificationRuleEdgeProcessor.java b/application/src/main/java/org/thingsboard/server/service/edge/rpc/processor/notification/NotificationRuleEdgeProcessor.java index 77f97e0775..918ca024cd 100644 --- a/application/src/main/java/org/thingsboard/server/service/edge/rpc/processor/notification/NotificationRuleEdgeProcessor.java +++ b/application/src/main/java/org/thingsboard/server/service/edge/rpc/processor/notification/NotificationRuleEdgeProcessor.java @@ -25,6 +25,7 @@ import org.thingsboard.server.common.data.id.NotificationRuleId; import org.thingsboard.server.common.data.notification.rule.NotificationRule; import org.thingsboard.server.dao.notification.NotificationRuleService; import org.thingsboard.server.gen.edge.v1.DownlinkMsg; +import org.thingsboard.server.gen.edge.v1.EdgeVersion; import org.thingsboard.server.gen.edge.v1.NotificationRuleUpdateMsg; import org.thingsboard.server.gen.edge.v1.UpdateMsgType; import org.thingsboard.server.queue.util.TbCoreComponent; @@ -40,7 +41,7 @@ public class NotificationRuleEdgeProcessor extends BaseEdgeProcessor { private NotificationRuleService notificationRuleService; @Override - public DownlinkMsg convertEdgeEventToDownlink(EdgeEvent edgeEvent) { + public DownlinkMsg convertEdgeEventToDownlink(EdgeEvent edgeEvent, EdgeVersion edgeVersion) { NotificationRuleId notificationRuleId = new NotificationRuleId(edgeEvent.getEntityId()); DownlinkMsg downlinkMsg = null; switch (edgeEvent.getAction()) { diff --git a/application/src/main/java/org/thingsboard/server/service/edge/rpc/processor/notification/NotificationTargetEdgeProcessor.java b/application/src/main/java/org/thingsboard/server/service/edge/rpc/processor/notification/NotificationTargetEdgeProcessor.java index 5dfad971b0..0f95248c74 100644 --- a/application/src/main/java/org/thingsboard/server/service/edge/rpc/processor/notification/NotificationTargetEdgeProcessor.java +++ b/application/src/main/java/org/thingsboard/server/service/edge/rpc/processor/notification/NotificationTargetEdgeProcessor.java @@ -25,6 +25,7 @@ import org.thingsboard.server.common.data.id.NotificationTargetId; import org.thingsboard.server.common.data.notification.targets.NotificationTarget; import org.thingsboard.server.dao.notification.NotificationTargetService; import org.thingsboard.server.gen.edge.v1.DownlinkMsg; +import org.thingsboard.server.gen.edge.v1.EdgeVersion; import org.thingsboard.server.gen.edge.v1.NotificationTargetUpdateMsg; import org.thingsboard.server.gen.edge.v1.UpdateMsgType; import org.thingsboard.server.queue.util.TbCoreComponent; @@ -40,7 +41,7 @@ public class NotificationTargetEdgeProcessor extends BaseEdgeProcessor { private NotificationTargetService notificationTargetService; @Override - public DownlinkMsg convertEdgeEventToDownlink(EdgeEvent edgeEvent) { + public DownlinkMsg convertEdgeEventToDownlink(EdgeEvent edgeEvent, EdgeVersion edgeVersion) { NotificationTargetId notificationTargetId = new NotificationTargetId(edgeEvent.getEntityId()); DownlinkMsg downlinkMsg = null; switch (edgeEvent.getAction()) { diff --git a/application/src/main/java/org/thingsboard/server/service/edge/rpc/processor/notification/NotificationTemplateEdgeProcessor.java b/application/src/main/java/org/thingsboard/server/service/edge/rpc/processor/notification/NotificationTemplateEdgeProcessor.java index b79f4ab4b8..f3ae939514 100644 --- a/application/src/main/java/org/thingsboard/server/service/edge/rpc/processor/notification/NotificationTemplateEdgeProcessor.java +++ b/application/src/main/java/org/thingsboard/server/service/edge/rpc/processor/notification/NotificationTemplateEdgeProcessor.java @@ -25,6 +25,7 @@ import org.thingsboard.server.common.data.id.NotificationTemplateId; import org.thingsboard.server.common.data.notification.template.NotificationTemplate; import org.thingsboard.server.dao.notification.NotificationTemplateService; import org.thingsboard.server.gen.edge.v1.DownlinkMsg; +import org.thingsboard.server.gen.edge.v1.EdgeVersion; import org.thingsboard.server.gen.edge.v1.NotificationTemplateUpdateMsg; import org.thingsboard.server.gen.edge.v1.UpdateMsgType; import org.thingsboard.server.queue.util.TbCoreComponent; @@ -40,7 +41,7 @@ public class NotificationTemplateEdgeProcessor extends BaseEdgeProcessor { private NotificationTemplateService notificationTemplateService; @Override - public DownlinkMsg convertEdgeEventToDownlink(EdgeEvent edgeEvent) { + public DownlinkMsg convertEdgeEventToDownlink(EdgeEvent edgeEvent, EdgeVersion edgeVersion) { NotificationTemplateId notificationTemplateId = new NotificationTemplateId(edgeEvent.getEntityId()); DownlinkMsg downlinkMsg = null; switch (edgeEvent.getAction()) { diff --git a/application/src/main/java/org/thingsboard/server/service/edge/rpc/processor/oauth2/DomainEdgeProcessor.java b/application/src/main/java/org/thingsboard/server/service/edge/rpc/processor/oauth2/DomainEdgeProcessor.java index 6b3d429a2f..07247d946c 100644 --- a/application/src/main/java/org/thingsboard/server/service/edge/rpc/processor/oauth2/DomainEdgeProcessor.java +++ b/application/src/main/java/org/thingsboard/server/service/edge/rpc/processor/oauth2/DomainEdgeProcessor.java @@ -26,6 +26,7 @@ import org.thingsboard.server.common.data.id.DomainId; import org.thingsboard.server.common.data.oauth2.OAuth2Client; import org.thingsboard.server.dao.domain.DomainService; import org.thingsboard.server.gen.edge.v1.DownlinkMsg; +import org.thingsboard.server.gen.edge.v1.EdgeVersion; import org.thingsboard.server.gen.edge.v1.OAuth2ClientUpdateMsg; import org.thingsboard.server.gen.edge.v1.OAuth2DomainUpdateMsg; import org.thingsboard.server.gen.edge.v1.UpdateMsgType; @@ -42,7 +43,7 @@ public class DomainEdgeProcessor extends BaseEdgeProcessor { private DomainService domainService; @Override - public DownlinkMsg convertEdgeEventToDownlink(EdgeEvent edgeEvent) { + public DownlinkMsg convertEdgeEventToDownlink(EdgeEvent edgeEvent, EdgeVersion edgeVersion) { DomainId domainId = new DomainId(edgeEvent.getEntityId()); switch (edgeEvent.getAction()) { case ADDED, UPDATED -> { diff --git a/application/src/main/java/org/thingsboard/server/service/edge/rpc/processor/oauth2/OAuth2ClientEdgeProcessor.java b/application/src/main/java/org/thingsboard/server/service/edge/rpc/processor/oauth2/OAuth2ClientEdgeProcessor.java index 20bd5b3f7e..08b0405702 100644 --- a/application/src/main/java/org/thingsboard/server/service/edge/rpc/processor/oauth2/OAuth2ClientEdgeProcessor.java +++ b/application/src/main/java/org/thingsboard/server/service/edge/rpc/processor/oauth2/OAuth2ClientEdgeProcessor.java @@ -23,6 +23,7 @@ import org.thingsboard.server.common.data.edge.EdgeEventType; import org.thingsboard.server.common.data.id.OAuth2ClientId; import org.thingsboard.server.common.data.oauth2.OAuth2Client; import org.thingsboard.server.gen.edge.v1.DownlinkMsg; +import org.thingsboard.server.gen.edge.v1.EdgeVersion; import org.thingsboard.server.gen.edge.v1.OAuth2ClientUpdateMsg; import org.thingsboard.server.gen.edge.v1.UpdateMsgType; import org.thingsboard.server.queue.util.TbCoreComponent; @@ -35,7 +36,7 @@ import org.thingsboard.server.service.edge.rpc.processor.BaseEdgeProcessor; public class OAuth2ClientEdgeProcessor extends BaseEdgeProcessor { @Override - public DownlinkMsg convertEdgeEventToDownlink(EdgeEvent edgeEvent) { + public DownlinkMsg convertEdgeEventToDownlink(EdgeEvent edgeEvent, EdgeVersion edgeVersion) { OAuth2ClientId oAuth2ClientId = new OAuth2ClientId(edgeEvent.getEntityId()); switch (edgeEvent.getAction()) { diff --git a/application/src/main/java/org/thingsboard/server/service/edge/rpc/processor/ota/OtaPackageEdgeProcessor.java b/application/src/main/java/org/thingsboard/server/service/edge/rpc/processor/ota/OtaPackageEdgeProcessor.java index b2080e8176..e22ed3bafa 100644 --- a/application/src/main/java/org/thingsboard/server/service/edge/rpc/processor/ota/OtaPackageEdgeProcessor.java +++ b/application/src/main/java/org/thingsboard/server/service/edge/rpc/processor/ota/OtaPackageEdgeProcessor.java @@ -23,6 +23,7 @@ import org.thingsboard.server.common.data.edge.EdgeEvent; import org.thingsboard.server.common.data.edge.EdgeEventType; import org.thingsboard.server.common.data.id.OtaPackageId; import org.thingsboard.server.gen.edge.v1.DownlinkMsg; +import org.thingsboard.server.gen.edge.v1.EdgeVersion; import org.thingsboard.server.gen.edge.v1.OtaPackageUpdateMsg; import org.thingsboard.server.gen.edge.v1.UpdateMsgType; import org.thingsboard.server.queue.util.TbCoreComponent; @@ -35,7 +36,7 @@ import org.thingsboard.server.service.edge.rpc.processor.BaseEdgeProcessor; public class OtaPackageEdgeProcessor extends BaseEdgeProcessor { @Override - public DownlinkMsg convertEdgeEventToDownlink(EdgeEvent edgeEvent) { + public DownlinkMsg convertEdgeEventToDownlink(EdgeEvent edgeEvent, EdgeVersion edgeVersion) { OtaPackageId otaPackageId = new OtaPackageId(edgeEvent.getEntityId()); switch (edgeEvent.getAction()) { case ADDED, UPDATED -> { diff --git a/application/src/main/java/org/thingsboard/server/service/edge/rpc/processor/queue/QueueEdgeProcessor.java b/application/src/main/java/org/thingsboard/server/service/edge/rpc/processor/queue/QueueEdgeProcessor.java index ebea0c8255..f5fcac3342 100644 --- a/application/src/main/java/org/thingsboard/server/service/edge/rpc/processor/queue/QueueEdgeProcessor.java +++ b/application/src/main/java/org/thingsboard/server/service/edge/rpc/processor/queue/QueueEdgeProcessor.java @@ -23,6 +23,7 @@ import org.thingsboard.server.common.data.edge.EdgeEventType; import org.thingsboard.server.common.data.id.QueueId; import org.thingsboard.server.common.data.queue.Queue; import org.thingsboard.server.gen.edge.v1.DownlinkMsg; +import org.thingsboard.server.gen.edge.v1.EdgeVersion; import org.thingsboard.server.gen.edge.v1.QueueUpdateMsg; import org.thingsboard.server.gen.edge.v1.UpdateMsgType; import org.thingsboard.server.queue.util.TbCoreComponent; @@ -35,7 +36,7 @@ import org.thingsboard.server.service.edge.rpc.processor.BaseEdgeProcessor; public class QueueEdgeProcessor extends BaseEdgeProcessor { @Override - public DownlinkMsg convertEdgeEventToDownlink(EdgeEvent edgeEvent) { + public DownlinkMsg convertEdgeEventToDownlink(EdgeEvent edgeEvent, EdgeVersion edgeVersion) { QueueId queueId = new QueueId(edgeEvent.getEntityId()); switch (edgeEvent.getAction()) { case ADDED, UPDATED -> { diff --git a/application/src/main/java/org/thingsboard/server/service/edge/rpc/processor/relation/RelationEdgeProcessor.java b/application/src/main/java/org/thingsboard/server/service/edge/rpc/processor/relation/RelationEdgeProcessor.java index da6f749269..70666e8b0e 100644 --- a/application/src/main/java/org/thingsboard/server/service/edge/rpc/processor/relation/RelationEdgeProcessor.java +++ b/application/src/main/java/org/thingsboard/server/service/edge/rpc/processor/relation/RelationEdgeProcessor.java @@ -21,7 +21,6 @@ import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Component; import org.thingsboard.common.util.JacksonUtil; import org.thingsboard.server.common.data.EdgeUtils; -import org.thingsboard.server.common.data.EntityType; import org.thingsboard.server.common.data.edge.Edge; import org.thingsboard.server.common.data.edge.EdgeEvent; import org.thingsboard.server.common.data.edge.EdgeEventActionType; @@ -30,6 +29,7 @@ import org.thingsboard.server.common.data.id.EdgeId; import org.thingsboard.server.common.data.id.TenantId; import org.thingsboard.server.common.data.relation.EntityRelation; import org.thingsboard.server.gen.edge.v1.DownlinkMsg; +import org.thingsboard.server.gen.edge.v1.EdgeVersion; import org.thingsboard.server.gen.edge.v1.RelationUpdateMsg; import org.thingsboard.server.gen.edge.v1.UpdateMsgType; import org.thingsboard.server.gen.transport.TransportProtos; @@ -85,7 +85,7 @@ public class RelationEdgeProcessor extends BaseRelationProcessor implements Rela } @Override - public DownlinkMsg convertEdgeEventToDownlink(EdgeEvent edgeEvent) { + public DownlinkMsg convertEdgeEventToDownlink(EdgeEvent edgeEvent, EdgeVersion edgeVersion) { EntityRelation entityRelation = JacksonUtil.convertValue(edgeEvent.getBody(), EntityRelation.class); UpdateMsgType msgType = getUpdateMsgType(edgeEvent.getAction()); RelationUpdateMsg relationUpdateMsg = EdgeMsgConstructorUtils.constructRelationUpdatedMsg(msgType, entityRelation); diff --git a/application/src/main/java/org/thingsboard/server/service/edge/rpc/processor/resource/ResourceEdgeProcessor.java b/application/src/main/java/org/thingsboard/server/service/edge/rpc/processor/resource/ResourceEdgeProcessor.java index 8d2c06025a..de31bf6a9c 100644 --- a/application/src/main/java/org/thingsboard/server/service/edge/rpc/processor/resource/ResourceEdgeProcessor.java +++ b/application/src/main/java/org/thingsboard/server/service/edge/rpc/processor/resource/ResourceEdgeProcessor.java @@ -29,6 +29,7 @@ import org.thingsboard.server.common.data.id.TbResourceId; import org.thingsboard.server.common.data.id.TenantId; import org.thingsboard.server.dao.exception.DataValidationException; import org.thingsboard.server.gen.edge.v1.DownlinkMsg; +import org.thingsboard.server.gen.edge.v1.EdgeVersion; import org.thingsboard.server.gen.edge.v1.ResourceUpdateMsg; import org.thingsboard.server.gen.edge.v1.UpdateMsgType; import org.thingsboard.server.queue.util.TbCoreComponent; @@ -73,7 +74,7 @@ public class ResourceEdgeProcessor extends BaseResourceProcessor implements Reso } @Override - public DownlinkMsg convertEdgeEventToDownlink(EdgeEvent edgeEvent) { + public DownlinkMsg convertEdgeEventToDownlink(EdgeEvent edgeEvent, EdgeVersion edgeVersion) { TbResourceId tbResourceId = new TbResourceId(edgeEvent.getEntityId()); switch (edgeEvent.getAction()) { case ADDED, UPDATED -> { diff --git a/application/src/main/java/org/thingsboard/server/service/edge/rpc/processor/rule/RuleChainEdgeProcessor.java b/application/src/main/java/org/thingsboard/server/service/edge/rpc/processor/rule/RuleChainEdgeProcessor.java index 06fb4c37a2..b949660e70 100644 --- a/application/src/main/java/org/thingsboard/server/service/edge/rpc/processor/rule/RuleChainEdgeProcessor.java +++ b/application/src/main/java/org/thingsboard/server/service/edge/rpc/processor/rule/RuleChainEdgeProcessor.java @@ -33,6 +33,7 @@ import org.thingsboard.server.common.data.rule.RuleChainType; import org.thingsboard.server.common.msg.TbMsgMetaData; import org.thingsboard.server.dao.exception.DataValidationException; import org.thingsboard.server.gen.edge.v1.DownlinkMsg; +import org.thingsboard.server.gen.edge.v1.EdgeVersion; import org.thingsboard.server.gen.edge.v1.RuleChainMetadataUpdateMsg; import org.thingsboard.server.gen.edge.v1.RuleChainUpdateMsg; import org.thingsboard.server.gen.edge.v1.UpdateMsgType; @@ -134,7 +135,7 @@ public class RuleChainEdgeProcessor extends BaseRuleChainProcessor { } @Override - public DownlinkMsg convertEdgeEventToDownlink(EdgeEvent edgeEvent) { + public DownlinkMsg convertEdgeEventToDownlink(EdgeEvent edgeEvent, EdgeVersion edgeVersion) { RuleChainId ruleChainId = new RuleChainId(edgeEvent.getEntityId()); DownlinkMsg downlinkMsg = null; switch (edgeEvent.getAction()) { @@ -161,7 +162,7 @@ public class RuleChainEdgeProcessor extends BaseRuleChainProcessor { RuleChainMetaData ruleChainMetaData = edgeCtx.getRuleChainService().loadRuleChainMetaData(edgeEvent.getTenantId(), ruleChainId); RuleChainMetadataUpdateMsg ruleChainMetadataUpdateMsg = EdgeMsgConstructorUtils - .constructRuleChainMetadataUpdatedMsg(msgType, ruleChainMetaData); + .constructRuleChainMetadataUpdatedMsg(msgType, ruleChainMetaData, edgeVersion); builder.addRuleChainMetadataUpdateMsg(ruleChainMetadataUpdateMsg); downlinkMsg = builder.build(); } diff --git a/application/src/main/java/org/thingsboard/server/service/edge/rpc/processor/rule/RuleChainMetadataEdgeProcessor.java b/application/src/main/java/org/thingsboard/server/service/edge/rpc/processor/rule/RuleChainMetadataEdgeProcessor.java index fe9a66c456..845d2a7204 100644 --- a/application/src/main/java/org/thingsboard/server/service/edge/rpc/processor/rule/RuleChainMetadataEdgeProcessor.java +++ b/application/src/main/java/org/thingsboard/server/service/edge/rpc/processor/rule/RuleChainMetadataEdgeProcessor.java @@ -24,6 +24,7 @@ import org.thingsboard.server.common.data.id.RuleChainId; import org.thingsboard.server.common.data.rule.RuleChain; import org.thingsboard.server.common.data.rule.RuleChainMetaData; import org.thingsboard.server.gen.edge.v1.DownlinkMsg; +import org.thingsboard.server.gen.edge.v1.EdgeVersion; import org.thingsboard.server.gen.edge.v1.RuleChainMetadataUpdateMsg; import org.thingsboard.server.gen.edge.v1.UpdateMsgType; import org.thingsboard.server.queue.util.TbCoreComponent; @@ -36,14 +37,14 @@ import org.thingsboard.server.service.edge.rpc.processor.BaseEdgeProcessor; public class RuleChainMetadataEdgeProcessor extends BaseEdgeProcessor { @Override - public DownlinkMsg convertEdgeEventToDownlink(EdgeEvent edgeEvent) { + public DownlinkMsg convertEdgeEventToDownlink(EdgeEvent edgeEvent, EdgeVersion edgeVersion) { RuleChainId ruleChainId = new RuleChainId(edgeEvent.getEntityId()); RuleChain ruleChain = edgeCtx.getRuleChainService().findRuleChainById(edgeEvent.getTenantId(), ruleChainId); if (ruleChain != null) { RuleChainMetaData ruleChainMetaData = edgeCtx.getRuleChainService().loadRuleChainMetaData(edgeEvent.getTenantId(), ruleChainId); UpdateMsgType msgType = getUpdateMsgType(edgeEvent.getAction()); RuleChainMetadataUpdateMsg ruleChainMetadataUpdateMsg = EdgeMsgConstructorUtils - .constructRuleChainMetadataUpdatedMsg(msgType, ruleChainMetaData); + .constructRuleChainMetadataUpdatedMsg(msgType, ruleChainMetaData, edgeVersion); return DownlinkMsg.newBuilder() .setDownlinkMsgId(EdgeUtils.nextPositiveInt()) .addRuleChainMetadataUpdateMsg(ruleChainMetadataUpdateMsg) diff --git a/application/src/main/java/org/thingsboard/server/service/edge/rpc/processor/settings/AdminSettingsEdgeProcessor.java b/application/src/main/java/org/thingsboard/server/service/edge/rpc/processor/settings/AdminSettingsEdgeProcessor.java index 6028273dc2..f2f82fe9de 100644 --- a/application/src/main/java/org/thingsboard/server/service/edge/rpc/processor/settings/AdminSettingsEdgeProcessor.java +++ b/application/src/main/java/org/thingsboard/server/service/edge/rpc/processor/settings/AdminSettingsEdgeProcessor.java @@ -24,6 +24,7 @@ import org.thingsboard.server.common.data.edge.EdgeEvent; import org.thingsboard.server.common.data.edge.EdgeEventType; import org.thingsboard.server.gen.edge.v1.AdminSettingsUpdateMsg; import org.thingsboard.server.gen.edge.v1.DownlinkMsg; +import org.thingsboard.server.gen.edge.v1.EdgeVersion; import org.thingsboard.server.queue.util.TbCoreComponent; import org.thingsboard.server.service.edge.rpc.processor.BaseEdgeProcessor; @@ -33,7 +34,7 @@ import org.thingsboard.server.service.edge.rpc.processor.BaseEdgeProcessor; public class AdminSettingsEdgeProcessor extends BaseEdgeProcessor { @Override - public DownlinkMsg convertEdgeEventToDownlink(EdgeEvent edgeEvent) { + public DownlinkMsg convertEdgeEventToDownlink(EdgeEvent edgeEvent, EdgeVersion edgeVersion) { AdminSettings adminSettings = JacksonUtil.convertValue(edgeEvent.getBody(), AdminSettings.class); if (adminSettings == null) { return null; diff --git a/application/src/main/java/org/thingsboard/server/service/edge/rpc/processor/tenant/TenantEdgeProcessor.java b/application/src/main/java/org/thingsboard/server/service/edge/rpc/processor/tenant/TenantEdgeProcessor.java index e21ed0b4ea..f0f41b5cc6 100644 --- a/application/src/main/java/org/thingsboard/server/service/edge/rpc/processor/tenant/TenantEdgeProcessor.java +++ b/application/src/main/java/org/thingsboard/server/service/edge/rpc/processor/tenant/TenantEdgeProcessor.java @@ -25,6 +25,7 @@ import org.thingsboard.server.common.data.edge.EdgeEventActionType; import org.thingsboard.server.common.data.edge.EdgeEventType; import org.thingsboard.server.common.data.id.TenantId; import org.thingsboard.server.gen.edge.v1.DownlinkMsg; +import org.thingsboard.server.gen.edge.v1.EdgeVersion; import org.thingsboard.server.gen.edge.v1.TenantProfileUpdateMsg; import org.thingsboard.server.gen.edge.v1.TenantUpdateMsg; import org.thingsboard.server.gen.edge.v1.UpdateMsgType; @@ -38,7 +39,7 @@ import org.thingsboard.server.service.edge.rpc.processor.BaseEdgeProcessor; public class TenantEdgeProcessor extends BaseEdgeProcessor { @Override - public DownlinkMsg convertEdgeEventToDownlink(EdgeEvent edgeEvent) { + public DownlinkMsg convertEdgeEventToDownlink(EdgeEvent edgeEvent, EdgeVersion edgeVersion) { TenantId tenantId = TenantId.fromUUID(edgeEvent.getEntityId()); if (EdgeEventActionType.UPDATED.equals(edgeEvent.getAction())) { Tenant tenant = edgeCtx.getTenantService().findTenantById(tenantId); diff --git a/application/src/main/java/org/thingsboard/server/service/edge/rpc/processor/tenant/TenantProfileEdgeProcessor.java b/application/src/main/java/org/thingsboard/server/service/edge/rpc/processor/tenant/TenantProfileEdgeProcessor.java index bcbeeb82b3..a0bb32503e 100644 --- a/application/src/main/java/org/thingsboard/server/service/edge/rpc/processor/tenant/TenantProfileEdgeProcessor.java +++ b/application/src/main/java/org/thingsboard/server/service/edge/rpc/processor/tenant/TenantProfileEdgeProcessor.java @@ -24,6 +24,7 @@ import org.thingsboard.server.common.data.edge.EdgeEventActionType; import org.thingsboard.server.common.data.edge.EdgeEventType; import org.thingsboard.server.common.data.id.TenantProfileId; import org.thingsboard.server.gen.edge.v1.DownlinkMsg; +import org.thingsboard.server.gen.edge.v1.EdgeVersion; import org.thingsboard.server.gen.edge.v1.TenantProfileUpdateMsg; import org.thingsboard.server.gen.edge.v1.UpdateMsgType; import org.thingsboard.server.queue.util.TbCoreComponent; @@ -36,7 +37,7 @@ import org.thingsboard.server.service.edge.rpc.processor.BaseEdgeProcessor; public class TenantProfileEdgeProcessor extends BaseEdgeProcessor { @Override - public DownlinkMsg convertEdgeEventToDownlink(EdgeEvent edgeEvent) { + public DownlinkMsg convertEdgeEventToDownlink(EdgeEvent edgeEvent, EdgeVersion edgeVersion) { TenantProfileId tenantProfileId = new TenantProfileId(edgeEvent.getEntityId()); if (EdgeEventActionType.UPDATED.equals(edgeEvent.getAction())) { TenantProfile tenantProfile = edgeCtx.getTenantProfileService().findTenantProfileById(edgeEvent.getTenantId(), tenantProfileId); diff --git a/application/src/main/java/org/thingsboard/server/service/edge/rpc/processor/user/UserEdgeProcessor.java b/application/src/main/java/org/thingsboard/server/service/edge/rpc/processor/user/UserEdgeProcessor.java index c8c0b49124..fdd03d63f3 100644 --- a/application/src/main/java/org/thingsboard/server/service/edge/rpc/processor/user/UserEdgeProcessor.java +++ b/application/src/main/java/org/thingsboard/server/service/edge/rpc/processor/user/UserEdgeProcessor.java @@ -24,6 +24,7 @@ import org.thingsboard.server.common.data.edge.EdgeEventType; import org.thingsboard.server.common.data.id.UserId; import org.thingsboard.server.common.data.security.UserCredentials; import org.thingsboard.server.gen.edge.v1.DownlinkMsg; +import org.thingsboard.server.gen.edge.v1.EdgeVersion; import org.thingsboard.server.gen.edge.v1.UpdateMsgType; import org.thingsboard.server.gen.edge.v1.UserCredentialsUpdateMsg; import org.thingsboard.server.queue.util.TbCoreComponent; @@ -36,7 +37,7 @@ import org.thingsboard.server.service.edge.rpc.processor.BaseEdgeProcessor; public class UserEdgeProcessor extends BaseEdgeProcessor { @Override - public DownlinkMsg convertEdgeEventToDownlink(EdgeEvent edgeEvent) { + public DownlinkMsg convertEdgeEventToDownlink(EdgeEvent edgeEvent, EdgeVersion edgeVersion) { UserId userId = new UserId(edgeEvent.getEntityId()); switch (edgeEvent.getAction()) { case ADDED, UPDATED -> { diff --git a/application/src/main/java/org/thingsboard/server/service/edge/rpc/processor/widget/WidgetBundleEdgeProcessor.java b/application/src/main/java/org/thingsboard/server/service/edge/rpc/processor/widget/WidgetBundleEdgeProcessor.java index 4c22ddf898..52d7370470 100644 --- a/application/src/main/java/org/thingsboard/server/service/edge/rpc/processor/widget/WidgetBundleEdgeProcessor.java +++ b/application/src/main/java/org/thingsboard/server/service/edge/rpc/processor/widget/WidgetBundleEdgeProcessor.java @@ -23,6 +23,7 @@ import org.thingsboard.server.common.data.edge.EdgeEventType; import org.thingsboard.server.common.data.id.WidgetsBundleId; import org.thingsboard.server.common.data.widget.WidgetsBundle; import org.thingsboard.server.gen.edge.v1.DownlinkMsg; +import org.thingsboard.server.gen.edge.v1.EdgeVersion; import org.thingsboard.server.gen.edge.v1.UpdateMsgType; import org.thingsboard.server.gen.edge.v1.WidgetsBundleUpdateMsg; import org.thingsboard.server.queue.util.TbCoreComponent; @@ -37,7 +38,7 @@ import java.util.List; public class WidgetBundleEdgeProcessor extends BaseEdgeProcessor { @Override - public DownlinkMsg convertEdgeEventToDownlink(EdgeEvent edgeEvent) { + public DownlinkMsg convertEdgeEventToDownlink(EdgeEvent edgeEvent, EdgeVersion edgeVersion) { WidgetsBundleId widgetsBundleId = new WidgetsBundleId(edgeEvent.getEntityId()); switch (edgeEvent.getAction()) { case ADDED, UPDATED -> { diff --git a/application/src/main/java/org/thingsboard/server/service/edge/rpc/processor/widget/WidgetTypeEdgeProcessor.java b/application/src/main/java/org/thingsboard/server/service/edge/rpc/processor/widget/WidgetTypeEdgeProcessor.java index 4da508b22b..47f30afa93 100644 --- a/application/src/main/java/org/thingsboard/server/service/edge/rpc/processor/widget/WidgetTypeEdgeProcessor.java +++ b/application/src/main/java/org/thingsboard/server/service/edge/rpc/processor/widget/WidgetTypeEdgeProcessor.java @@ -23,6 +23,7 @@ import org.thingsboard.server.common.data.edge.EdgeEventType; import org.thingsboard.server.common.data.id.WidgetTypeId; import org.thingsboard.server.common.data.widget.WidgetTypeDetails; import org.thingsboard.server.gen.edge.v1.DownlinkMsg; +import org.thingsboard.server.gen.edge.v1.EdgeVersion; import org.thingsboard.server.gen.edge.v1.UpdateMsgType; import org.thingsboard.server.gen.edge.v1.WidgetTypeUpdateMsg; import org.thingsboard.server.queue.util.TbCoreComponent; @@ -35,7 +36,7 @@ import org.thingsboard.server.service.edge.rpc.processor.BaseEdgeProcessor; public class WidgetTypeEdgeProcessor extends BaseEdgeProcessor { @Override - public DownlinkMsg convertEdgeEventToDownlink(EdgeEvent edgeEvent) { + public DownlinkMsg convertEdgeEventToDownlink(EdgeEvent edgeEvent, EdgeVersion edgeVersion) { WidgetTypeId widgetTypeId = new WidgetTypeId(edgeEvent.getEntityId()); switch (edgeEvent.getAction()) { case ADDED, UPDATED -> { diff --git a/application/src/main/java/org/thingsboard/server/service/edge/rpc/utils/EdgeVersionUtils.java b/application/src/main/java/org/thingsboard/server/service/edge/rpc/utils/EdgeVersionUtils.java index 07e2024e1f..9902118eb2 100644 --- a/application/src/main/java/org/thingsboard/server/service/edge/rpc/utils/EdgeVersionUtils.java +++ b/application/src/main/java/org/thingsboard/server/service/edge/rpc/utils/EdgeVersionUtils.java @@ -24,4 +24,5 @@ public final class EdgeVersionUtils { public static boolean isEdgeVersionOlderThan(EdgeVersion currentVersion, EdgeVersion requiredVersion) { return currentVersion.ordinal() < requiredVersion.ordinal(); } + } diff --git a/application/src/main/java/org/thingsboard/server/service/edqs/EdqsSyncService.java b/application/src/main/java/org/thingsboard/server/service/edqs/EdqsSyncService.java index 79e0e60983..871e1fa5e7 100644 --- a/application/src/main/java/org/thingsboard/server/service/edqs/EdqsSyncService.java +++ b/application/src/main/java/org/thingsboard/server/service/edqs/EdqsSyncService.java @@ -161,13 +161,17 @@ public abstract class EdqsSyncService { private void processRelationBatch(List relations) { for (RelationEntity relation : relations) { - if (RelationTypeGroup.COMMON.name().equals(relation.getRelationTypeGroup())) { - EntityIdInfo entityIdInfo = entityInfoMap.get(relation.getFromId()); - if (entityIdInfo != null) { - process(entityIdInfo.tenantId(), RELATION, relation.toData()); - } else { - log.info("Relation from id not found: {} ", relation); + try { + if (RelationTypeGroup.COMMON.name().equals(relation.getRelationTypeGroup())) { + EntityIdInfo entityIdInfo = entityInfoMap.get(relation.getFromId()); + if (entityIdInfo != null) { + process(entityIdInfo.tenantId(), RELATION, relation.toData()); + } else { + log.info("Relation from id not found: {} ", relation); + } } + } catch (Exception e) { + log.error("Failed to sync relation: {}", relation, e); } } } @@ -207,19 +211,23 @@ public abstract class EdqsSyncService { private void processAttributeBatch(List batch) { for (AttributeKvEntity attribute : batch) { - attribute.setStrKey(getStrKeyOrFetchFromDb(attribute.getId().getAttributeKey())); - UUID entityId = attribute.getId().getEntityId(); - EntityIdInfo entityIdInfo = entityInfoMap.get(entityId); - if (entityIdInfo == null) { - log.debug("Skipping attribute with entity UUID {} as it is not found in entityInfoMap", entityId); - continue; + try { + attribute.setStrKey(getStrKeyOrFetchFromDb(attribute.getId().getAttributeKey())); + UUID entityId = attribute.getId().getEntityId(); + EntityIdInfo entityIdInfo = entityInfoMap.get(entityId); + if (entityIdInfo == null) { + log.debug("Skipping attribute with entity UUID {} as it is not found in entityInfoMap", entityId); + continue; + } + AttributeKv attributeKv = new AttributeKv( + EntityIdFactory.getByTypeAndUuid(entityIdInfo.entityType(), entityId), + AttributeScope.valueOf(attribute.getId().getAttributeType()), + attribute.toData(), + attribute.getVersion()); + process(entityIdInfo.tenantId(), ATTRIBUTE_KV, attributeKv); + } catch (Exception e) { + log.error("Failed to sync attribute: {}", attribute, e); } - AttributeKv attributeKv = new AttributeKv( - EntityIdFactory.getByTypeAndUuid(entityIdInfo.entityType(), entityId), - AttributeScope.valueOf(attribute.getId().getAttributeType()), - attribute.toData(), - attribute.getVersion()); - process(entityIdInfo.tenantId(), ATTRIBUTE_KV, attributeKv); } } diff --git a/application/src/main/java/org/thingsboard/server/service/subscription/DefaultTbLocalSubscriptionService.java b/application/src/main/java/org/thingsboard/server/service/subscription/DefaultTbLocalSubscriptionService.java index 59681d3daa..5716976b6f 100644 --- a/application/src/main/java/org/thingsboard/server/service/subscription/DefaultTbLocalSubscriptionService.java +++ b/application/src/main/java/org/thingsboard/server/service/subscription/DefaultTbLocalSubscriptionService.java @@ -343,15 +343,31 @@ public class DefaultTbLocalSubscriptionService implements TbLocalSubscriptionSer s -> { TbTimeSeriesSubscription sub = (TbTimeSeriesSubscription) s; List updateData = null; + Map keyStates = sub.getKeyStates(); if (sub.isAllKeys()) { - updateData = data; + if (sub.isLatestValues()) { + for (TsKvEntry kv : data) { + Long stateTs = keyStates.get(kv.getKey()); + if (stateTs == null || kv.getTs() > stateTs) { + if (updateData == null) { + updateData = new ArrayList<>(); + } + updateData.add(kv); + } + } + } else { + updateData = data; + } } else { for (TsKvEntry kv : data) { - if (sub.getKeyStates().containsKey((kv.getKey()))) { - if (updateData == null) { - updateData = new ArrayList<>(); + Long stateTs = keyStates.get(kv.getKey()); + if (stateTs != null) { + if (!sub.isLatestValues() || kv.getTs() > stateTs) { + if (updateData == null) { + updateData = new ArrayList<>(); + } + updateData.add(kv); } - updateData.add(kv); } } } diff --git a/application/src/main/java/org/thingsboard/server/service/sync/ie/DefaultEntitiesExportImportService.java b/application/src/main/java/org/thingsboard/server/service/sync/ie/DefaultEntitiesExportImportService.java index 06fe7f4036..db7e37b368 100644 --- a/application/src/main/java/org/thingsboard/server/service/sync/ie/DefaultEntitiesExportImportService.java +++ b/application/src/main/java/org/thingsboard/server/service/sync/ie/DefaultEntitiesExportImportService.java @@ -31,7 +31,6 @@ import org.thingsboard.server.common.data.relation.EntityRelation; import org.thingsboard.server.common.data.sync.ie.EntityExportData; import org.thingsboard.server.common.data.sync.ie.EntityImportResult; import org.thingsboard.server.common.data.util.ThrowingRunnable; -import org.thingsboard.server.dao.cf.CalculatedFieldService; import org.thingsboard.server.dao.exception.DataValidationException; import org.thingsboard.server.dao.relation.RelationService; import org.thingsboard.server.queue.util.TbCoreComponent; @@ -62,7 +61,6 @@ public class DefaultEntitiesExportImportService implements EntitiesExportImportS private final Map> importServices = new HashMap<>(); private final RelationService relationService; - private final CalculatedFieldService calculatedFieldService; private final RateLimitService rateLimitService; private final TbLogEntityActionService logEntityActionService; diff --git a/application/src/main/java/org/thingsboard/server/service/ws/DefaultWebSocketService.java b/application/src/main/java/org/thingsboard/server/service/ws/DefaultWebSocketService.java index 7288a8bac9..2e4fe9730a 100644 --- a/application/src/main/java/org/thingsboard/server/service/ws/DefaultWebSocketService.java +++ b/application/src/main/java/org/thingsboard/server/service/ws/DefaultWebSocketService.java @@ -105,6 +105,8 @@ import java.util.function.BiConsumer; import java.util.function.Consumer; import java.util.stream.Collectors; +import static org.thingsboard.server.common.data.DataConstants.LATEST_TELEMETRY_SCOPE; + /** * Created by ashvayka on 27.03.18. */ @@ -667,24 +669,7 @@ public class DefaultWebSocketService implements WebSocketService { data.forEach(v -> subState.put(v.getKey(), v.getTs())); Lock subLock = new ReentrantLock(); - TbTimeSeriesSubscription sub = TbTimeSeriesSubscription.builder() - .serviceId(serviceId) - .sessionId(sessionId) - .subscriptionId(registerNewSessionSubId(sessionId, sessionRef, cmd.getCmdId())) - .tenantId(sessionRef.getSecurityCtx().getTenantId()) - .entityId(entityId) - .updateProcessor((subscription, update) -> { - subLock.lock(); - try { - sendUpdate(subscription.getSessionId(), cmd.getCmdId(), update); - } finally { - subLock.unlock(); - } - }) - .queryTs(queryTs) - .allKeys(true) - .keyStates(subState) - .build(); + TbTimeSeriesSubscription sub = getTsSubscription(subState, subLock, sessionId, sessionRef, cmd, entityId, queryTs, true); subLock.lock(); try { @@ -712,6 +697,28 @@ public class DefaultWebSocketService implements WebSocketService { on(r -> Futures.addCallback(tsService.findAllLatest(sessionRef.getSecurityCtx().getTenantId(), entityId), callback, executor), callback::onFailure)); } + private TbTimeSeriesSubscription getTsSubscription(Map subState, Lock subLock, String sessionId, WebSocketSessionRef sessionRef, TimeseriesSubscriptionCmd cmd, EntityId entityId, long queryTs, boolean allKeys) { + return TbTimeSeriesSubscription.builder() + .serviceId(serviceId) + .sessionId(sessionId) + .subscriptionId(registerNewSessionSubId(sessionId, sessionRef, cmd.getCmdId())) + .tenantId(sessionRef.getSecurityCtx().getTenantId()) + .entityId(entityId) + .updateProcessor((subscription, update) -> { + subLock.lock(); + try { + sendUpdate(subscription.getSessionId(), cmd.getCmdId(), update); + } finally { + subLock.unlock(); + } + }) + .queryTs(queryTs) + .allKeys(allKeys) + .keyStates(subState) + .latestValues(LATEST_TELEMETRY_SCOPE.equals(cmd.getScope())) + .build(); + } + private FutureCallback> getSubscriptionCallback(final WebSocketSessionRef sessionRef, final TimeseriesSubscriptionCmd cmd, final String sessionId, final EntityId entityId, final long queryTs, final long startTs, final List keys) { return new FutureCallback<>() { @@ -722,24 +729,7 @@ public class DefaultWebSocketService implements WebSocketService { data.forEach(v -> subState.put(v.getKey(), v.getTs())); Lock subLock = new ReentrantLock(); - TbTimeSeriesSubscription sub = TbTimeSeriesSubscription.builder() - .serviceId(serviceId) - .sessionId(sessionId) - .subscriptionId(registerNewSessionSubId(sessionId, sessionRef, cmd.getCmdId())) - .tenantId(sessionRef.getSecurityCtx().getTenantId()) - .entityId(entityId) - .updateProcessor((subscription, update) -> { - subLock.lock(); - try { - sendUpdate(subscription.getSessionId(), cmd.getCmdId(), update); - } finally { - subLock.unlock(); - } - }) - .queryTs(queryTs) - .allKeys(false) - .keyStates(subState) - .build(); + TbTimeSeriesSubscription sub = getTsSubscription(subState, subLock, sessionId, sessionRef, cmd, entityId, queryTs, false); subLock.lock(); try { diff --git a/application/src/main/resources/thingsboard.yml b/application/src/main/resources/thingsboard.yml index 7ede65e375..37f2bcd5a7 100644 --- a/application/src/main/resources/thingsboard.yml +++ b/application/src/main/resources/thingsboard.yml @@ -1421,6 +1421,9 @@ device: port: "${DEVICE_CONNECTIVITY_COAPS_PORT:5684}" # Path to the COAP CA root certificate file pem_cert_file: "${DEVICE_CONNECTIVITY_COAPS_CA_ROOT_CERT:cafile.pem}" + gateway: + # The docker tag for thingsboard/tb-gateway image used in docker-compose file for gateway launch + image_version: "${DEVICE_CONNECTIVITY_GATEWAY_IMAGE_VERSION:latest}" # Edges parameters edges: diff --git a/application/src/test/java/org/thingsboard/server/controller/CalculatedFieldControllerTest.java b/application/src/test/java/org/thingsboard/server/controller/CalculatedFieldControllerTest.java index ee66f664cc..af43b34558 100644 --- a/application/src/test/java/org/thingsboard/server/controller/CalculatedFieldControllerTest.java +++ b/application/src/test/java/org/thingsboard/server/controller/CalculatedFieldControllerTest.java @@ -31,7 +31,6 @@ import org.thingsboard.server.common.data.cf.configuration.OutputType; import org.thingsboard.server.common.data.cf.configuration.ReferencedEntityKey; import org.thingsboard.server.common.data.cf.configuration.SimpleCalculatedFieldConfiguration; import org.thingsboard.server.common.data.id.DeviceId; -import org.thingsboard.server.common.data.id.EntityId; import org.thingsboard.server.common.data.security.Authority; import org.thingsboard.server.dao.service.DaoSqlTest; @@ -85,7 +84,7 @@ public class CalculatedFieldControllerTest extends AbstractControllerTest { assertThat(savedCalculatedField.getEntityId()).isEqualTo(calculatedField.getEntityId()); assertThat(savedCalculatedField.getType()).isEqualTo(calculatedField.getType()); assertThat(savedCalculatedField.getName()).isEqualTo(calculatedField.getName()); - assertThat(savedCalculatedField.getConfiguration()).isEqualTo(getCalculatedFieldConfig(testDevice.getId())); + assertThat(savedCalculatedField.getConfiguration()).isEqualTo(getCalculatedFieldConfig()); assertThat(savedCalculatedField.getVersion()).isEqualTo(1L); savedCalculatedField.setName("Test CF"); @@ -134,16 +133,16 @@ public class CalculatedFieldControllerTest extends AbstractControllerTest { calculatedField.setType(CalculatedFieldType.SIMPLE); calculatedField.setName("Test Calculated Field"); calculatedField.setConfigurationVersion(1); - calculatedField.setConfiguration(getCalculatedFieldConfig(null)); + calculatedField.setConfiguration(getCalculatedFieldConfig()); calculatedField.setVersion(1L); return calculatedField; } - private CalculatedFieldConfiguration getCalculatedFieldConfig(EntityId referencedEntityId) { + private CalculatedFieldConfiguration getCalculatedFieldConfig() { SimpleCalculatedFieldConfiguration config = new SimpleCalculatedFieldConfiguration(); Argument argument = new Argument(); - argument.setRefEntityId(referencedEntityId); + argument.setRefEntityId(null); ReferencedEntityKey refEntityKey = new ReferencedEntityKey("temperature", ArgumentType.TS_LATEST, null); argument.setRefEntityKey(refEntityKey); diff --git a/application/src/test/java/org/thingsboard/server/controller/DeviceConnectivityControllerTest.java b/application/src/test/java/org/thingsboard/server/controller/DeviceConnectivityControllerTest.java index 2a6c847591..cb475d7f16 100644 --- a/application/src/test/java/org/thingsboard/server/controller/DeviceConnectivityControllerTest.java +++ b/application/src/test/java/org/thingsboard/server/controller/DeviceConnectivityControllerTest.java @@ -298,7 +298,7 @@ public class DeviceConnectivityControllerTest extends AbstractControllerTest { "services:\n" + " # ThingsBoard IoT Gateway Service Configuration\n" + " tb-gateway:\n" + - " image: thingsboard/tb-gateway\n" + + " image: thingsboard/tb-gateway:latest\n" + " container_name: tb-gateway\n" + " restart: always\n" + "\n" + diff --git a/application/src/test/java/org/thingsboard/server/controller/EntityQueryControllerTest.java b/application/src/test/java/org/thingsboard/server/controller/EntityQueryControllerTest.java index 90b7ea7c7b..29a208a805 100644 --- a/application/src/test/java/org/thingsboard/server/controller/EntityQueryControllerTest.java +++ b/application/src/test/java/org/thingsboard/server/controller/EntityQueryControllerTest.java @@ -499,7 +499,8 @@ public class EntityQueryControllerTest extends AbstractControllerTest { ); EntityDataPageLink pageLink = new EntityDataPageLink(10, 0, null, sortOrder); List entityFields = Collections.singletonList(new EntityKey(EntityKeyType.ENTITY_FIELD, "name")); - List latestValues = Collections.singletonList(new EntityKey(EntityKeyType.ATTRIBUTE, "temperature")); + List latestValues = List.of(new EntityKey(EntityKeyType.ATTRIBUTE, "temperature"), + new EntityKey(EntityKeyType.ATTRIBUTE, "non-existing-attribute")); EntityDataQuery query = new EntityDataQuery(filter, pageLink, entityFields, latestValues, null); PageData data = findByQueryAndCheck(query, 67); @@ -517,6 +518,14 @@ public class EntityQueryControllerTest extends AbstractControllerTest { List deviceTemperatures = temperatures.stream().map(aLong -> Long.toString(aLong)).collect(Collectors.toList()); Assert.assertEquals(deviceTemperatures, loadedTemperatures); + // check ts value == 0, value is empty string for non-existing data points + List loadedNonExistingAttributes = loadedEntities.stream().map(entityData -> + entityData.getLatest().get(EntityKeyType.ATTRIBUTE).get("non-existing-attribute")).toList(); + loadedNonExistingAttributes.forEach(tsValue -> { + assertThat(tsValue.getTs()).isEqualTo(0L); + assertThat(tsValue.getValue()).isEqualTo(""); + }); + pageLink = new EntityDataPageLink(10, 0, null, sortOrder); KeyFilter highTemperatureFilter = new KeyFilter(); highTemperatureFilter.setKey(new EntityKey(EntityKeyType.ATTRIBUTE, "temperature")); diff --git a/application/src/test/java/org/thingsboard/server/controller/TbTestWebSocketClient.java b/application/src/test/java/org/thingsboard/server/controller/TbTestWebSocketClient.java index 959db43125..1c5b1dd154 100644 --- a/application/src/test/java/org/thingsboard/server/controller/TbTestWebSocketClient.java +++ b/application/src/test/java/org/thingsboard/server/controller/TbTestWebSocketClient.java @@ -31,6 +31,7 @@ import org.thingsboard.server.service.ws.AuthCmd; import org.thingsboard.server.service.ws.WsCmd; import org.thingsboard.server.service.ws.WsCommandsWrapper; import org.thingsboard.server.service.ws.telemetry.cmd.v1.AttributesSubscriptionCmd; +import org.thingsboard.server.service.ws.telemetry.cmd.v1.TimeseriesSubscriptionCmd; import org.thingsboard.server.service.ws.telemetry.cmd.v2.AlarmCountUpdate; import org.thingsboard.server.service.ws.telemetry.cmd.v2.EntityCountUpdate; import org.thingsboard.server.service.ws.telemetry.cmd.v2.EntityDataCmd; @@ -38,6 +39,7 @@ import org.thingsboard.server.service.ws.telemetry.cmd.v2.EntityDataUpdate; import org.thingsboard.server.service.ws.telemetry.cmd.v2.EntityHistoryCmd; import org.thingsboard.server.service.ws.telemetry.cmd.v2.LatestValueCmd; import org.thingsboard.server.service.ws.telemetry.cmd.v2.TimeSeriesCmd; +import org.thingsboard.server.service.ws.telemetry.sub.TelemetrySubscriptionUpdate; import java.net.URI; import java.nio.channels.NotYetConnectedException; @@ -271,6 +273,18 @@ public class TbTestWebSocketClient extends WebSocketClient { return sendEntityDataQuery(edq); } + public JsonNode sendTimeseriesCmd(EntityId entityId, String scope) { + log.warn("sendTimeseriesCmd entityId: {}, scope: {}", entityId, scope); + TimeseriesSubscriptionCmd cmd = new TimeseriesSubscriptionCmd(0, 0, 0, 10, null); + cmd.setEntityId(entityId.getId().toString()); + cmd.setEntityType(entityId.getEntityType().toString()); + cmd.setCmdId(1); + cmd.setScope(scope); + send(cmd); + String msg = this.waitForReply(); + return JacksonUtil.fromString(msg, JsonNode.class); + } + public void send(WsCmd... cmds) { WsCommandsWrapper cmdsWrapper = new WsCommandsWrapper(); cmdsWrapper.setCmds(List.of(cmds)); diff --git a/application/src/test/java/org/thingsboard/server/controller/WebsocketApiTest.java b/application/src/test/java/org/thingsboard/server/controller/WebsocketApiTest.java index 9801907d3b..3842cf917b 100644 --- a/application/src/test/java/org/thingsboard/server/controller/WebsocketApiTest.java +++ b/application/src/test/java/org/thingsboard/server/controller/WebsocketApiTest.java @@ -68,11 +68,13 @@ import org.thingsboard.server.service.ws.telemetry.cmd.v2.AlarmStatusUpdate; import org.thingsboard.server.service.ws.telemetry.cmd.v2.EntityCountCmd; import org.thingsboard.server.service.ws.telemetry.cmd.v2.EntityCountUpdate; import org.thingsboard.server.service.ws.telemetry.cmd.v2.EntityDataUpdate; +import org.thingsboard.server.service.ws.telemetry.sub.TelemetrySubscriptionUpdate; import java.util.ArrayList; import java.util.Arrays; import java.util.Collections; import java.util.List; +import java.util.Map; import java.util.concurrent.CountDownLatch; import java.util.concurrent.TimeUnit; @@ -584,6 +586,33 @@ public class WebsocketApiTest extends AbstractControllerTest { Assert.assertNull(msg); } + @Test + public void testTimeseriesSubscriptionCmd() throws Exception { + long now = System.currentTimeMillis() - 100; + + long lastTs = now - TimeUnit.MINUTES.toMillis(1); + TsKvEntry dataPoint1 = new BasicTsKvEntry(lastTs, new LongDataEntry("temperature", 42L)); + sendTelemetry(device, List.of(dataPoint1)); + + JsonNode update = getWsClient().sendTimeseriesCmd(device.getId(), "LATEST_TELEMETRY"); + JsonNode data = update.get("data"); + Assert.assertEquals(1, data.size()); + Assert.assertEquals(JacksonUtil.newArrayNode().add(lastTs).add("42"), data.get("temperature").get(0)); + + //Sending update from the past, while latest value has new timestamp; + TsKvEntry dataPoint4 = new BasicTsKvEntry(now - TimeUnit.MINUTES.toMillis(5), new LongDataEntry("temperature", 45L)); + getWsClient().registerWaitForUpdate(); + sendTelemetry(device, List.of(dataPoint4)); + String msg = getWsClient().waitForUpdate(TimeUnit.SECONDS.toMillis(1)); + Assert.assertNull(msg); + + //Sending duplicate update again + getWsClient().registerWaitForUpdate(); + sendTelemetry(device, List.of(dataPoint4)); + msg = getWsClient().waitForUpdate(TimeUnit.SECONDS.toMillis(1)); + Assert.assertNull(msg); + } + @Test public void testEntityDataLatestTsWsCmd() throws Exception { long now = System.currentTimeMillis(); diff --git a/application/src/test/java/org/thingsboard/server/service/edge/EdgeMsgConstructorUtilsTest.java b/application/src/test/java/org/thingsboard/server/service/edge/EdgeMsgConstructorUtilsTest.java new file mode 100644 index 0000000000..a4c2133f1c --- /dev/null +++ b/application/src/test/java/org/thingsboard/server/service/edge/EdgeMsgConstructorUtilsTest.java @@ -0,0 +1,156 @@ +/** + * Copyright © 2016-2025 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.service.edge; + +import lombok.extern.slf4j.Slf4j; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.TestInstance; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.MethodSource; +import org.thingsboard.common.util.JacksonUtil; +import org.thingsboard.rule.engine.action.TbSaveToCustomCassandraTableNode; +import org.thingsboard.rule.engine.api.NodeConfiguration; +import org.thingsboard.rule.engine.api.TbNode; +import org.thingsboard.rule.engine.aws.lambda.TbAwsLambdaNode; +import org.thingsboard.rule.engine.filter.TbCheckRelationNode; +import org.thingsboard.rule.engine.flow.TbAckNode; +import org.thingsboard.rule.engine.math.TbMathNode; +import org.thingsboard.rule.engine.metadata.CalculateDeltaNode; +import org.thingsboard.rule.engine.metadata.TbGetTelemetryNode; +import org.thingsboard.rule.engine.rest.TbSendRestApiCallReplyNode; +import org.thingsboard.rule.engine.telemetry.TbMsgAttributesNode; +import org.thingsboard.rule.engine.telemetry.TbMsgTimeseriesNode; +import org.thingsboard.server.common.data.rule.RuleChainMetaData; +import org.thingsboard.server.common.data.rule.RuleNode; +import org.thingsboard.server.gen.edge.v1.EdgeVersion; +import org.thingsboard.server.gen.edge.v1.UpdateMsgType; + +import java.lang.reflect.Constructor; +import java.util.List; +import java.util.Optional; +import java.util.stream.Stream; + +import static org.thingsboard.server.service.edge.EdgeMsgConstructorUtils.EXCLUDED_NODES_BY_EDGE_VERSION; +import static org.thingsboard.server.service.edge.EdgeMsgConstructorUtils.IGNORED_PARAMS_BY_EDGE_VERSION; + +@Slf4j +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +public class EdgeMsgConstructorUtilsTest { + + private static final int CONFIGURATION_VERSION = 5; + + static Stream provideEdgeVersions() { + return Stream.of( + EdgeVersion.V_4_0_0, + EdgeVersion.V_3_9_0, + EdgeVersion.V_3_8_0, + EdgeVersion.V_3_7_0 + ); + } + + private static final RuleChainMetaData RULE_CHAIN_META_DATA = new RuleChainMetaData(); + private static final List TEST_NODES = + List.of( + new TbSaveToCustomCassandraTableNode(), + new TbMsgAttributesNode(), + new TbMsgTimeseriesNode(), + new TbSendRestApiCallReplyNode(), + new TbAwsLambdaNode(), + + new TbMathNode(), + new CalculateDeltaNode(), + new TbAckNode(), + new TbCheckRelationNode(), + new TbGetTelemetryNode() + ); + + @BeforeAll + static void setUp() { + List ruleNodes = TEST_NODES.stream() + .map(node -> { + RuleNode ruleNode = new RuleNode(); + ruleNode.setName(node.getClass().getName()); + ruleNode.setType(node.getClass().getName()); + ruleNode.setConfigurationVersion(CONFIGURATION_VERSION); + ruleNode.setConfiguration(JacksonUtil.valueToTree(createDefaultConfiguration(node))); + return ruleNode; + }) + .toList(); + + RULE_CHAIN_META_DATA.setFirstNodeIndex(0); + RULE_CHAIN_META_DATA.setNodes(ruleNodes); + } + + private static NodeConfiguration createDefaultConfiguration(TbNode node) { + try { + org.thingsboard.rule.engine.api.RuleNode annotation = node.getClass().getAnnotation(org.thingsboard.rule.engine.api.RuleNode.class); + Constructor constructor = annotation.configClazz().getConstructor(); + NodeConfiguration configInstance = (NodeConfiguration) constructor.newInstance(); + + return configInstance.defaultConfiguration(); + } catch (Exception e) { + throw new RuntimeException("Exception during creating RuleNodeConfiguration for node - " + node, e); + } + } + + @ParameterizedTest(name = "Test Sanitize Metadata For Edge: {0}") + @MethodSource("provideEdgeVersions") + @DisplayName("Test Sanitize Metadata For Legacy Edge Version") + public void testSanitizeMetadataForLegacyEdgeVersion(EdgeVersion edgeVersion) { + // WHEN + List ruleNodes = sanitizeMetadataForLegacyEdgeVersion(edgeVersion); + + // THEN + ruleNodes.forEach(ruleNode -> { + checkUpdateNodeConfigurationsForLegacyEdge(ruleNode, edgeVersion); + checkRemoveExcludedNodesForLegacyEdge(ruleNode, edgeVersion); + }); + } + + private List sanitizeMetadataForLegacyEdgeVersion(EdgeVersion edgeVersion) { + String metadataUpdateMsg = EdgeMsgConstructorUtils.constructRuleChainMetadataUpdatedMsg( + UpdateMsgType.ENTITY_CREATED_RPC_MESSAGE, + RULE_CHAIN_META_DATA, + edgeVersion + ).getEntity(); + + RuleChainMetaData updatedMetaData = JacksonUtil.fromString(metadataUpdateMsg, RuleChainMetaData.class, true); + Assertions.assertNotNull(updatedMetaData, "RuleChainMetaData should not be null after update."); + + return updatedMetaData.getNodes(); + } + + private void checkUpdateNodeConfigurationsForLegacyEdge(RuleNode ruleNode, EdgeVersion edgeVersion) { + if (IGNORED_PARAMS_BY_EDGE_VERSION.containsKey(edgeVersion) && IGNORED_PARAMS_BY_EDGE_VERSION.get(edgeVersion).containsKey(ruleNode.getType())) { + String ignoredParam = IGNORED_PARAMS_BY_EDGE_VERSION.get(edgeVersion).get(ruleNode.getType()); + + Assertions.assertFalse(ruleNode.getConfiguration().has(ignoredParam), + String.format("RuleNode '%s' for EdgeVersion '%s' should ignore '%s' config parameter.", ruleNode.getName(), edgeVersion, ignoredParam)); + } + } + + private void checkRemoveExcludedNodesForLegacyEdge(RuleNode ruleNode, EdgeVersion edgeVersion) { + boolean isNodeExcluded = Optional.ofNullable(EXCLUDED_NODES_BY_EDGE_VERSION.get(edgeVersion)) + .map(excludedNodes -> !excludedNodes.contains(ruleNode.getType())) + .orElse(true); + + Assertions.assertTrue(isNodeExcluded, + String.format("For EdgeVersion '%s', ruleNode '%s' should not be included.", edgeVersion, ruleNode.getType())); + } + +} diff --git a/application/src/test/java/org/thingsboard/server/service/entitiy/EntityServiceTest.java b/application/src/test/java/org/thingsboard/server/service/entitiy/EntityServiceTest.java index e5c4009715..a2c3fc07c8 100644 --- a/application/src/test/java/org/thingsboard/server/service/entitiy/EntityServiceTest.java +++ b/application/src/test/java/org/thingsboard/server/service/entitiy/EntityServiceTest.java @@ -95,7 +95,6 @@ import org.thingsboard.server.dao.edge.EdgeService; import org.thingsboard.server.dao.entity.EntityService; import org.thingsboard.server.dao.entityview.EntityViewDao; import org.thingsboard.server.dao.entityview.EntityViewService; -import org.thingsboard.server.dao.model.sqlts.ts.TsKvEntity; import org.thingsboard.server.dao.relation.RelationService; import org.thingsboard.server.dao.service.DaoSqlTest; import org.thingsboard.server.dao.sql.relation.RelationRepository; @@ -1664,7 +1663,7 @@ public class EntityServiceTest extends AbstractControllerTest { List> timeseriesFutures = new ArrayList<>(); for (int i = 0; i < devices.size(); i++) { Device device = devices.get(i); - timeseriesFutures.add(saveLongTimeseries(device.getId(), "temperature", temperatures.get(i))); + timeseriesFutures.add(saveTimeseries(device.getId(), "temperature", temperatures.get(i))); } Futures.allAsList(timeseriesFutures).get(); @@ -1711,6 +1710,13 @@ public class EntityServiceTest extends AbstractControllerTest { List entitiesTelemetry = loadedEntities.stream().map(entityData -> entityData.getLatest().get(EntityKeyType.TIME_SERIES).get("temperature").getValue()).toList(); assertThat(entitiesTelemetry).containsExactlyElementsOf(expectedSortedList); + // update temperature to long value for one of device + long longTempValue = -100L; + saveTimeseries(devices.get(new Random().nextInt(66)).getId(), "temperature", longTempValue).get(); + loadedEntities = loadAllData(querySortByTemp, deviceTemperatures.size()); + entitiesTelemetry = loadedEntities.stream().map(entityData -> entityData.getLatest().get(EntityKeyType.TIME_SERIES).get("temperature").getValue()).toList(); + assertThat(entitiesTelemetry.get(0)).isEqualTo(String.valueOf(longTempValue)); + deviceService.deleteDevicesByTenantId(tenantId); } @@ -2252,15 +2258,18 @@ public class EntityServiceTest extends AbstractControllerTest { return attributesService.save(tenantId, entityId, scope, Collections.singletonList(attr)); } - private ListenableFuture saveLongTimeseries(EntityId entityId, String key, Double value) { - TsKvEntity tsKv = new TsKvEntity(); - tsKv.setStrKey(key); - tsKv.setDoubleValue(value); + private ListenableFuture saveTimeseries(EntityId entityId, String key, Double value) { KvEntry telemetryValue = new DoubleDataEntry(key, value); BasicTsKvEntry timeseries = new BasicTsKvEntry(42L, telemetryValue); return timeseriesService.save(tenantId, entityId, timeseries); } + private ListenableFuture saveTimeseries(EntityId entityId, String key, Long value) { + KvEntry telemetryValue = new LongDataEntry(key, value); + BasicTsKvEntry timeseries = new BasicTsKvEntry(42L, telemetryValue); + return timeseriesService.save(tenantId, entityId, timeseries); + } + protected void createMultiRootHierarchy(List buildings, List apartments, Map> entityNameByTypeMap, Map childParentRelationMap) throws InterruptedException { diff --git a/application/src/test/java/org/thingsboard/server/service/sync/vc/VersionControlTest.java b/application/src/test/java/org/thingsboard/server/service/sync/vc/VersionControlTest.java index 06f61ca4e5..29c3af5387 100644 --- a/application/src/test/java/org/thingsboard/server/service/sync/vc/VersionControlTest.java +++ b/application/src/test/java/org/thingsboard/server/service/sync/vc/VersionControlTest.java @@ -621,6 +621,7 @@ public class VersionControlTest extends AbstractControllerTest { assertThat(importedField.getName()).isEqualTo(deviceCalculatedField.getName()); assertThat(importedField.getType()).isEqualTo(deviceCalculatedField.getType()); assertThat(importedField.getId()).isNotEqualTo(deviceCalculatedField.getId()); + assertThat(importedField.getConfiguration().getArguments().get("T").getRefEntityId()).isEqualTo(importedAsset.getId()); }); List importedAssetCalculatedFields = findCalculatedFieldsByEntityId(importedAsset.getId()); @@ -629,6 +630,7 @@ public class VersionControlTest extends AbstractControllerTest { assertThat(importedField.getName()).isEqualTo(assetCalculatedField.getName()); assertThat(importedField.getType()).isEqualTo(assetCalculatedField.getType()); assertThat(importedField.getId()).isNotEqualTo(assetCalculatedField.getId()); + assertThat(importedField.getConfiguration().getArguments().get("T").getRefEntityId()).isEqualTo(importedDevice.getId()); }); } diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/DataConstants.java b/common/data/src/main/java/org/thingsboard/server/common/data/DataConstants.java index b53d6daec2..b2d9d59cca 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/DataConstants.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/DataConstants.java @@ -148,4 +148,6 @@ public class DataConstants { public static final String CF_QUEUE_NAME = "CalculatedFields"; public static final String CF_STATES_QUEUE_NAME = "CalculatedFieldStates"; + public static final String LATEST_TELEMETRY_SCOPE = "LATEST_TELEMETRY"; + } diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/cf/CalculatedField.java b/common/data/src/main/java/org/thingsboard/server/common/data/cf/CalculatedField.java index b86f30ca78..3b2ddf0627 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/cf/CalculatedField.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/cf/CalculatedField.java @@ -64,7 +64,7 @@ public class CalculatedField extends BaseData implements HasN @Schema(description = "Version of calculated field configuration.", example = "0") private int configurationVersion; @Schema(implementation = SimpleCalculatedFieldConfiguration.class) - private transient CalculatedFieldConfiguration configuration; + private CalculatedFieldConfiguration configuration; @Getter @Setter private Long version; @@ -112,6 +112,18 @@ public class CalculatedField extends BaseData implements HasN return super.getCreatedTime(); } + // Getter is ignored for serialization + @JsonIgnore + public boolean isDebugMode() { + return debugMode; + } + + // Setter is annotated for deserialization + @JsonSetter + public void setDebugMode(boolean debugMode) { + this.debugMode = debugMode; + } + @Override public String toString() { return new StringBuilder() @@ -128,16 +140,4 @@ public class CalculatedField extends BaseData implements HasN .toString(); } - // Getter is ignored for serialization - @JsonIgnore - public boolean isDebugMode() { - return debugMode; - } - - // Setter is annotated for deserialization - @JsonSetter - public void setDebugMode(boolean debugMode) { - this.debugMode = debugMode; - } - } diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/ScriptCalculatedFieldConfiguration.java b/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/ScriptCalculatedFieldConfiguration.java index 0971217fdf..c2dde43b8e 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/ScriptCalculatedFieldConfiguration.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/ScriptCalculatedFieldConfiguration.java @@ -16,13 +16,16 @@ package org.thingsboard.server.common.data.cf.configuration; import lombok.Data; +import lombok.EqualsAndHashCode; import org.thingsboard.server.common.data.cf.CalculatedFieldType; @Data +@EqualsAndHashCode(callSuper = true) public class ScriptCalculatedFieldConfiguration extends BaseCalculatedFieldConfiguration implements CalculatedFieldConfiguration { @Override public CalculatedFieldType getType() { return CalculatedFieldType.SCRIPT; } + } diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/SimpleCalculatedFieldConfiguration.java b/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/SimpleCalculatedFieldConfiguration.java index 79a0518ba0..5c0ce71e86 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/SimpleCalculatedFieldConfiguration.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/SimpleCalculatedFieldConfiguration.java @@ -16,13 +16,16 @@ package org.thingsboard.server.common.data.cf.configuration; import lombok.Data; +import lombok.EqualsAndHashCode; import org.thingsboard.server.common.data.cf.CalculatedFieldType; @Data +@EqualsAndHashCode(callSuper = true) public class SimpleCalculatedFieldConfiguration extends BaseCalculatedFieldConfiguration implements CalculatedFieldConfiguration { @Override public CalculatedFieldType getType() { return CalculatedFieldType.SIMPLE; } + } diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/event/CalculatedFieldDebugEventFilter.java b/common/data/src/main/java/org/thingsboard/server/common/data/event/CalculatedFieldDebugEventFilter.java index 55ce036d9e..a2cd119d32 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/event/CalculatedFieldDebugEventFilter.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/event/CalculatedFieldDebugEventFilter.java @@ -37,10 +37,9 @@ public class CalculatedFieldDebugEventFilter extends DebugEventFilter { example = "{\"x\":{\"ts\":1739432016629,\"value\":20},\"y\":{\"ts\":1739429717656,\"value\":12}}") protected String arguments; @Schema(description = "String value representing the result of a calculation", - example = "{\"x + y\":54}") + example = "{\"x + y\":32}") protected String result; - @Override public EventType getEventType() { return EventType.DEBUG_CALCULATED_FIELD; diff --git a/common/edqs/src/main/java/org/thingsboard/server/edqs/data/dp/BoolDataPoint.java b/common/edqs/src/main/java/org/thingsboard/server/edqs/data/dp/BoolDataPoint.java index 70a14917ba..adb03a5cb1 100644 --- a/common/edqs/src/main/java/org/thingsboard/server/edqs/data/dp/BoolDataPoint.java +++ b/common/edqs/src/main/java/org/thingsboard/server/edqs/data/dp/BoolDataPoint.java @@ -46,6 +46,10 @@ public class BoolDataPoint extends AbstractDataPoint { @Override public int compareTo(DataPoint dataPoint) { - return Boolean.compare(value, dataPoint.getBool()); + if (dataPoint.getType() == DataType.BOOLEAN) { + return Boolean.compare(value, dataPoint.getBool()); + } else { + return super.compareTo(dataPoint); + } } } diff --git a/common/edqs/src/main/java/org/thingsboard/server/edqs/data/dp/DoubleDataPoint.java b/common/edqs/src/main/java/org/thingsboard/server/edqs/data/dp/DoubleDataPoint.java index 2ca8f2c03a..4660b3eb56 100644 --- a/common/edqs/src/main/java/org/thingsboard/server/edqs/data/dp/DoubleDataPoint.java +++ b/common/edqs/src/main/java/org/thingsboard/server/edqs/data/dp/DoubleDataPoint.java @@ -46,6 +46,10 @@ public class DoubleDataPoint extends AbstractDataPoint { @Override public int compareTo(DataPoint dataPoint) { - return Double.compare(value, dataPoint.getDouble()); + if (dataPoint.getType() == DataType.DOUBLE || dataPoint.getType() == DataType.LONG) { + return Double.compare(value, dataPoint.getDouble()); + } else { + return super.compareTo(dataPoint); + } } } diff --git a/common/edqs/src/main/java/org/thingsboard/server/edqs/data/dp/LongDataPoint.java b/common/edqs/src/main/java/org/thingsboard/server/edqs/data/dp/LongDataPoint.java index 92c0a972e7..a1d33203b7 100644 --- a/common/edqs/src/main/java/org/thingsboard/server/edqs/data/dp/LongDataPoint.java +++ b/common/edqs/src/main/java/org/thingsboard/server/edqs/data/dp/LongDataPoint.java @@ -51,6 +51,12 @@ public class LongDataPoint extends AbstractDataPoint { @Override public int compareTo(DataPoint dataPoint) { - return Long.compare(value, dataPoint.getLong()); + if (dataPoint.getType() == DataType.DOUBLE) { + return Double.compare(getDouble(), dataPoint.getDouble()); + } else if (dataPoint.getType() == DataType.LONG) { + return Long.compare(value, dataPoint.getLong()); + } else { + return super.compareTo(dataPoint); + } } } diff --git a/common/edqs/src/main/java/org/thingsboard/server/edqs/util/RepositoryUtils.java b/common/edqs/src/main/java/org/thingsboard/server/edqs/util/RepositoryUtils.java index 71b58d759a..f355d83e62 100644 --- a/common/edqs/src/main/java/org/thingsboard/server/edqs/util/RepositoryUtils.java +++ b/common/edqs/src/main/java/org/thingsboard/server/edqs/util/RepositoryUtils.java @@ -344,7 +344,7 @@ public class RepositoryUtils { if (dp != null) { return new TsValue(dp.getTs() > 0 ? dp.getTs() : ts, dp.valueToString()); } else { - return new TsValue(ts, ""); + return TsValue.EMPTY; } } diff --git a/common/queue/src/main/java/org/thingsboard/server/queue/discovery/TopicService.java b/common/queue/src/main/java/org/thingsboard/server/queue/discovery/TopicService.java index 2a0fad0643..42263138c1 100644 --- a/common/queue/src/main/java/org/thingsboard/server/queue/discovery/TopicService.java +++ b/common/queue/src/main/java/org/thingsboard/server/queue/discovery/TopicService.java @@ -47,7 +47,7 @@ public class TopicService { @Value("${queue.edge.event-notifications-topic:tb_edge_event.notifications}") private String tbEdgeEventNotificationsTopic; - @Value("${queue.calculated_fields.notifications-topic:calculated_field.notifications}") + @Value("${queue.calculated-fields.notifications-topic:calculated_field.notifications}") private String tbCalculatedFieldNotificationsTopic; private final ConcurrentMap tbCoreNotificationTopics = new ConcurrentHashMap<>(); diff --git a/common/util/pom.xml b/common/util/pom.xml index 82768cdb68..7fdd3036ae 100644 --- a/common/util/pom.xml +++ b/common/util/pom.xml @@ -111,6 +111,11 @@ org.locationtech.jts jts-core + + net.objecthunter + exp4j + ${exp4j.version} + diff --git a/common/util/src/main/java/org/thingsboard/common/util/ExpressionFunctionsUtil.java b/common/util/src/main/java/org/thingsboard/common/util/ExpressionFunctionsUtil.java new file mode 100644 index 0000000000..b1753e7a17 --- /dev/null +++ b/common/util/src/main/java/org/thingsboard/common/util/ExpressionFunctionsUtil.java @@ -0,0 +1,78 @@ +/** + * Copyright © 2016-2025 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.common.util; + +import net.objecthunter.exp4j.function.Function; +import net.objecthunter.exp4j.function.Functions; + +import java.util.ArrayList; +import java.util.List; + +public class ExpressionFunctionsUtil { + + public static final List userDefinedFunctions = new ArrayList<>(); + + static { + userDefinedFunctions.add( + new Function("ln") { + @Override + public double apply(double... args) { + return Math.log(args[0]); + } + } + ); + userDefinedFunctions.add( + new Function("lg") { + @Override + public double apply(double... args) { + return Math.log10(args[0]); + } + } + ); + userDefinedFunctions.add( + new Function("logab", 2) { + @Override + public double apply(double... args) { + return Math.log(args[1]) / Math.log(args[0]); + } + } + ); + userDefinedFunctions.add(Functions.getBuiltinFunction("sin")); + userDefinedFunctions.add(Functions.getBuiltinFunction("cos")); + userDefinedFunctions.add(Functions.getBuiltinFunction("tan")); + userDefinedFunctions.add(Functions.getBuiltinFunction("cot")); + userDefinedFunctions.add(Functions.getBuiltinFunction("log")); + userDefinedFunctions.add(Functions.getBuiltinFunction("log2")); + userDefinedFunctions.add(Functions.getBuiltinFunction("log10")); + userDefinedFunctions.add(Functions.getBuiltinFunction("log1p")); + userDefinedFunctions.add(Functions.getBuiltinFunction("abs")); + userDefinedFunctions.add(Functions.getBuiltinFunction("acos")); + userDefinedFunctions.add(Functions.getBuiltinFunction("asin")); + userDefinedFunctions.add(Functions.getBuiltinFunction("atan")); + userDefinedFunctions.add(Functions.getBuiltinFunction("cbrt")); + userDefinedFunctions.add(Functions.getBuiltinFunction("floor")); + userDefinedFunctions.add(Functions.getBuiltinFunction("sinh")); + userDefinedFunctions.add(Functions.getBuiltinFunction("sqrt")); + userDefinedFunctions.add(Functions.getBuiltinFunction("tanh")); + userDefinedFunctions.add(Functions.getBuiltinFunction("cosh")); + userDefinedFunctions.add(Functions.getBuiltinFunction("ceil")); + userDefinedFunctions.add(Functions.getBuiltinFunction("pow")); + userDefinedFunctions.add(Functions.getBuiltinFunction("exp")); + userDefinedFunctions.add(Functions.getBuiltinFunction("expm1")); + userDefinedFunctions.add(Functions.getBuiltinFunction("signum")); + } + +} diff --git a/common/version-control/src/main/java/org/thingsboard/server/service/sync/vc/GitRepository.java b/common/version-control/src/main/java/org/thingsboard/server/service/sync/vc/GitRepository.java index 2bbca7a578..3500e48a79 100644 --- a/common/version-control/src/main/java/org/thingsboard/server/service/sync/vc/GitRepository.java +++ b/common/version-control/src/main/java/org/thingsboard/server/service/sync/vc/GitRepository.java @@ -41,6 +41,8 @@ import org.eclipse.jgit.diff.HistogramDiff; import org.eclipse.jgit.diff.RawText; import org.eclipse.jgit.diff.RawTextComparator; import org.eclipse.jgit.errors.LargeObjectException; +import org.eclipse.jgit.errors.RepositoryNotFoundException; +import org.eclipse.jgit.errors.TransportException; import org.eclipse.jgit.lib.Constants; import org.eclipse.jgit.lib.ObjectId; import org.eclipse.jgit.lib.ObjectLoader; @@ -147,22 +149,31 @@ public class GitRepository { } public static GitRepository openOrClone(Path directory, RepositorySettings settings, boolean fetch) throws IOException, GitAPIException { - GitRepository repository; if (GitRepository.exists(directory.toString())) { - repository = GitRepository.open(directory.toFile(), settings); - if (fetch) { - repository.fetch(); + try { + GitRepository repository = GitRepository.open(directory.toFile(), settings); + if (fetch) { + repository.fetch(); + } + return repository; + } catch (RepositoryNotFoundException e) { + log.warn("{} not a git repository, reinitializing", directory); + } catch (TransportException e) { + if (StringUtils.containsIgnoreCase(e.getMessage(), "missing commit")) { + log.warn("Couldn't fetch {} due to {}, reinitializing", directory, e.getMessage()); + } else { + throw e; + } } + } + + FileUtils.deleteDirectory(directory.toFile()); + Files.createDirectories(directory); + if (settings.isLocalOnly()) { + return GitRepository.create(settings, directory.toFile()); } else { - FileUtils.deleteDirectory(directory.toFile()); - Files.createDirectories(directory); - if (settings.isLocalOnly()) { - repository = GitRepository.create(settings, directory.toFile()); - } else { - repository = GitRepository.clone(settings, directory.toFile()); - } + return GitRepository.clone(settings, directory.toFile()); } - return repository; } public static void test(RepositorySettings settings, File directory) throws Exception { diff --git a/dao/src/main/java/org/thingsboard/server/dao/device/DeviceConnectivityServiceImpl.java b/dao/src/main/java/org/thingsboard/server/dao/device/DeviceConnectivityServiceImpl.java index 606388e465..eb7ce56b9f 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/device/DeviceConnectivityServiceImpl.java +++ b/dao/src/main/java/org/thingsboard/server/dao/device/DeviceConnectivityServiceImpl.java @@ -41,7 +41,6 @@ import org.thingsboard.server.common.data.security.DeviceCredentialsType; import org.thingsboard.server.dao.settings.AdminSettingsService; import org.thingsboard.server.dao.util.DeviceConnectivityUtil; -import java.io.IOException; import java.io.InputStream; import java.io.InputStreamReader; import java.net.URISyntaxException; @@ -86,6 +85,8 @@ public class DeviceConnectivityServiceImpl implements DeviceConnectivityService private String mqttsPemCertFile; @Value("${device.connectivity.coaps.pem_cert_file:}") private String coapsPemCertFile; + @Value("${device.connectivity.gateway.image_version:latest}") + private String gatewayImageVersion; @Override public JsonNode findDevicePublishTelemetryCommands(String baseUrl, Device device) throws URISyntaxException { @@ -157,7 +158,8 @@ public class DeviceConnectivityServiceImpl implements DeviceConnectivityService String mqttType = isEnabled(MQTTS) ? MQTTS : MQTT; DeviceConnectivityInfo properties = getConnectivity(mqttType); DeviceCredentials creds = deviceCredentialsService.findDeviceCredentialsByDeviceId(device.getTenantId(), device.getId()); - return DeviceConnectivityUtil.getGatewayDockerComposeFile(baseUrl, properties, creds, mqttType); + String host = getHost(baseUrl, properties, mqttType); + return DeviceConnectivityUtil.getGatewayDockerComposeFile(host, gatewayImageVersion, creds); } private DeviceConnectivityInfo getConnectivity(String protocol) { diff --git a/dao/src/main/java/org/thingsboard/server/dao/sql/NamedParameterJdbcTemplateConfiguration.java b/dao/src/main/java/org/thingsboard/server/dao/sql/NamedParameterJdbcTemplateConfiguration.java new file mode 100644 index 0000000000..ef8d0ed064 --- /dev/null +++ b/dao/src/main/java/org/thingsboard/server/dao/sql/NamedParameterJdbcTemplateConfiguration.java @@ -0,0 +1,43 @@ +/** + * Copyright © 2016-2025 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.dao.sql; + +import jakarta.annotation.PostConstruct; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate; +import org.springframework.stereotype.Component; + +import java.util.concurrent.TimeUnit; + +@Component +@RequiredArgsConstructor +@Slf4j +public class NamedParameterJdbcTemplateConfiguration { + + @Value("${spring.jpa.properties.javax.persistence.query.timeout:30000}") + private int queryTimeout; + + private final NamedParameterJdbcTemplate namedParameterJdbcTemplate; + + @PostConstruct + private void init() { + int timeout = Math.max(1, (int) TimeUnit.MILLISECONDS.toSeconds(queryTimeout)); + log.info("Set jdbcTemplate query timeout [{}] second(s)", timeout); + namedParameterJdbcTemplate.getJdbcTemplate().setQueryTimeout(timeout); + } +} diff --git a/dao/src/main/java/org/thingsboard/server/dao/sql/event/JpaBaseEventDao.java b/dao/src/main/java/org/thingsboard/server/dao/sql/event/JpaBaseEventDao.java index 592ed21f9a..189e024608 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/sql/event/JpaBaseEventDao.java +++ b/dao/src/main/java/org/thingsboard/server/dao/sql/event/JpaBaseEventDao.java @@ -200,6 +200,7 @@ public class JpaBaseEventDao implements EventDao { break; case DEBUG_CALCULATED_FIELD: removeEventsByFilter(tenantId, entityId, (CalculatedFieldDebugEventFilter) eventFilter, startTime, endTime); + break; default: throw new RuntimeException("Not supported event type: " + eventFilter.getEventType()); } diff --git a/dao/src/main/java/org/thingsboard/server/dao/sqlts/CachedRedisSqlTimeseriesLatestDao.java b/dao/src/main/java/org/thingsboard/server/dao/sqlts/CachedRedisSqlTimeseriesLatestDao.java index 6e43034a44..d45182442a 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/sqlts/CachedRedisSqlTimeseriesLatestDao.java +++ b/dao/src/main/java/org/thingsboard/server/dao/sqlts/CachedRedisSqlTimeseriesLatestDao.java @@ -33,18 +33,14 @@ import org.thingsboard.server.common.data.kv.BasicTsKvEntry; import org.thingsboard.server.common.data.kv.DeleteTsKvQuery; import org.thingsboard.server.common.data.kv.TsKvEntry; import org.thingsboard.server.common.data.kv.TsKvLatestRemovingResult; -import org.thingsboard.server.common.data.page.PageData; -import org.thingsboard.server.common.data.page.PageLink; import org.thingsboard.server.common.stats.DefaultCounter; import org.thingsboard.server.common.stats.StatsFactory; import org.thingsboard.server.dao.cache.CacheExecutorService; -import org.thingsboard.server.dao.model.sqlts.latest.TsKvLatestEntity; import org.thingsboard.server.dao.timeseries.TimeseriesLatestDao; import org.thingsboard.server.dao.timeseries.TsLatestCacheKey; import org.thingsboard.server.dao.util.SqlTsLatestAnyDaoCachedRedis; import java.util.List; -import java.util.Map; import java.util.Optional; @Slf4j diff --git a/dao/src/main/java/org/thingsboard/server/dao/sqlts/SqlTimeseriesLatestDao.java b/dao/src/main/java/org/thingsboard/server/dao/sqlts/SqlTimeseriesLatestDao.java index e8ef37b3b5..c546fc21ea 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/sqlts/SqlTimeseriesLatestDao.java +++ b/dao/src/main/java/org/thingsboard/server/dao/sqlts/SqlTimeseriesLatestDao.java @@ -189,7 +189,7 @@ public class SqlTimeseriesLatestDao extends BaseAbstractSqlTimeseriesDao impleme } - private ListenableFuture getNewLatestEntryFuture(TenantId tenantId, EntityId entityId, DeleteTsKvQuery query) { + private ListenableFuture getNewLatestEntryFuture(TenantId tenantId, EntityId entityId, DeleteTsKvQuery query, Long version) { ListenableFuture> future = findNewLatestEntryFuture(tenantId, entityId, query); return Futures.transformAsync(future, entryList -> { if (entryList.size() == 1) { @@ -198,7 +198,7 @@ public class SqlTimeseriesLatestDao extends BaseAbstractSqlTimeseriesDao impleme } else { log.trace("Could not find new latest value for [{}], key - {}", entityId, query.getKey()); } - return Futures.immediateFuture(new TsKvLatestRemovingResult(query.getKey(), true)); + return Futures.immediateFuture(new TsKvLatestRemovingResult(query.getKey(), true, version)); }, service); } @@ -241,7 +241,7 @@ public class SqlTimeseriesLatestDao extends BaseAbstractSqlTimeseriesDao impleme rs -> rs.next() ? rs.getLong(1) : null, entityId.getId(), keyDictionaryDao.getOrSaveKeyId(query.getKey()))); isRemoved = true; if (query.getRewriteLatestIfDeleted()) { - return getNewLatestEntryFuture(tenantId, entityId, query); + return getNewLatestEntryFuture(tenantId, entityId, query, version); } } return Futures.immediateFuture(new TsKvLatestRemovingResult(query.getKey(), isRemoved, version)); diff --git a/dao/src/main/java/org/thingsboard/server/dao/util/DeviceConnectivityUtil.java b/dao/src/main/java/org/thingsboard/server/dao/util/DeviceConnectivityUtil.java index 56e0e44778..cae2d56ae5 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/util/DeviceConnectivityUtil.java +++ b/dao/src/main/java/org/thingsboard/server/dao/util/DeviceConnectivityUtil.java @@ -92,15 +92,13 @@ public class DeviceConnectivityUtil { return command.toString(); } - public static Resource getGatewayDockerComposeFile(String baseUrl, DeviceConnectivityInfo properties, DeviceCredentials deviceCredentials, String mqttType) throws URISyntaxException { - String host = getHost(baseUrl, properties, mqttType); - + public static Resource getGatewayDockerComposeFile(String host, String gatewayImageVersion, DeviceCredentials deviceCredentials) { StringBuilder dockerComposeBuilder = new StringBuilder(); dockerComposeBuilder.append("version: '3.4'\n"); dockerComposeBuilder.append("services:\n"); dockerComposeBuilder.append(" # ThingsBoard IoT Gateway Service Configuration\n"); dockerComposeBuilder.append(" tb-gateway:\n"); - dockerComposeBuilder.append(" image: thingsboard/tb-gateway\n"); + dockerComposeBuilder.append(" image: thingsboard/tb-gateway:").append(gatewayImageVersion).append("\n"); dockerComposeBuilder.append(" container_name: tb-gateway\n"); dockerComposeBuilder.append(" restart: always\n"); dockerComposeBuilder.append("\n"); diff --git a/dao/src/test/java/org/thingsboard/server/dao/service/timeseries/BaseTimeseriesServiceTest.java b/dao/src/test/java/org/thingsboard/server/dao/service/timeseries/BaseTimeseriesServiceTest.java index 6a38159804..56b835c528 100644 --- a/dao/src/test/java/org/thingsboard/server/dao/service/timeseries/BaseTimeseriesServiceTest.java +++ b/dao/src/test/java/org/thingsboard/server/dao/service/timeseries/BaseTimeseriesServiceTest.java @@ -81,21 +81,21 @@ public abstract class BaseTimeseriesServiceTest extends AbstractServiceTest { protected static final int MAX_TIMEOUT = 30; - private static final String STRING_KEY = "stringKey"; + protected static final String STRING_KEY = "stringKey"; private static final String LONG_KEY = "longKey"; private static final String DOUBLE_KEY = "doubleKey"; private static final String BOOLEAN_KEY = "booleanKey"; - private static final long TS = 42L; + protected static final long TS = 42L; private static final String DESC_ORDER = "DESC"; - KvEntry stringKvEntry = new StringDataEntry(STRING_KEY, "value"); + protected KvEntry stringKvEntry = new StringDataEntry(STRING_KEY, "value"); KvEntry longKvEntry = new LongDataEntry(LONG_KEY, Long.MAX_VALUE); KvEntry doubleKvEntry = new DoubleDataEntry(DOUBLE_KEY, Double.MAX_VALUE); KvEntry booleanKvEntry = new BooleanDataEntry(BOOLEAN_KEY, Boolean.TRUE); protected TenantId tenantId; - DeviceId deviceId = new DeviceId(Uuids.timeBased()); + protected DeviceId deviceId = new DeviceId(Uuids.timeBased()); @Before public void before() { @@ -811,11 +811,11 @@ public abstract class BaseTimeseriesServiceTest extends AbstractServiceTest { tsService.saveWithoutLatest(tenantId, deviceId, tsKvEntry, 0).get(MAX_TIMEOUT, TimeUnit.SECONDS); } - private static TsKvEntry toTsEntry(long ts, KvEntry entry) { + protected static TsKvEntry toTsEntry(long ts, KvEntry entry) { return new BasicTsKvEntry(ts, entry); } - private static void equalsIgnoreVersion(TsKvEntry expected, TsKvEntry actual) { + protected static void equalsIgnoreVersion(TsKvEntry expected, TsKvEntry actual) { assertEquals(expected.getKey(), actual.getKey()); assertEquals(expected.getValue(), actual.getValue()); assertEquals(expected.getTs(), actual.getTs()); diff --git a/dao/src/test/java/org/thingsboard/server/dao/service/timeseries/sql/TimeseriesServiceSqlTest.java b/dao/src/test/java/org/thingsboard/server/dao/service/timeseries/sql/TimeseriesServiceSqlTest.java index 06a81adac3..7fa5b5f7e1 100644 --- a/dao/src/test/java/org/thingsboard/server/dao/service/timeseries/sql/TimeseriesServiceSqlTest.java +++ b/dao/src/test/java/org/thingsboard/server/dao/service/timeseries/sql/TimeseriesServiceSqlTest.java @@ -15,9 +15,42 @@ */ package org.thingsboard.server.dao.service.timeseries.sql; +import org.junit.Test; +import org.thingsboard.server.common.data.kv.TsKvEntry; import org.thingsboard.server.dao.service.DaoSqlTest; import org.thingsboard.server.dao.service.timeseries.BaseTimeseriesServiceTest; +import java.util.List; +import java.util.Optional; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.awaitility.Awaitility.await; + @DaoSqlTest public class TimeseriesServiceSqlTest extends BaseTimeseriesServiceTest { + + @Test + public void testRemoveLatestAndNoValuePresentInDB() throws ExecutionException, InterruptedException, TimeoutException { + TsKvEntry tsKvEntry = toTsEntry(TS, stringKvEntry); + tsService.save(tenantId, deviceId, tsKvEntry).get(MAX_TIMEOUT, TimeUnit.SECONDS); + + Optional tsKvEntryOpt = tsService.findLatest(tenantId, deviceId, STRING_KEY).get(MAX_TIMEOUT, TimeUnit.SECONDS); + + assertThat(tsKvEntryOpt).isPresent(); + equalsIgnoreVersion(tsKvEntry, tsKvEntryOpt.get()); + assertThat(tsKvEntryOpt.get().getVersion()).isNotNull(); + + tsService.removeLatest(tenantId, deviceId, List.of(STRING_KEY)); + + await().alias("Wait until ts last is removed from the cache").atMost(MAX_TIMEOUT, TimeUnit.SECONDS) + .pollInterval(1, TimeUnit.SECONDS) + .untilAsserted(() -> { + Optional tsKvEntryAfterRemoval = tsService.findLatest(tenantId, deviceId, STRING_KEY).get(MAX_TIMEOUT, TimeUnit.SECONDS); + assertThat(tsKvEntryAfterRemoval).isNotPresent(); + }); + } + } diff --git a/dao/src/test/java/org/thingsboard/server/dao/sql/JdbcTemplateTest.java b/dao/src/test/java/org/thingsboard/server/dao/sql/JdbcTemplateTest.java new file mode 100644 index 0000000000..d0af99021a --- /dev/null +++ b/dao/src/test/java/org/thingsboard/server/dao/sql/JdbcTemplateTest.java @@ -0,0 +1,39 @@ +/** + * Copyright © 2016-2025 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.dao.sql; + +import org.junit.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.dao.DataAccessResourceFailureException; +import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate; +import org.springframework.test.context.TestPropertySource; +import org.thingsboard.server.dao.AbstractJpaDaoTest; + +import static org.junit.jupiter.api.Assertions.assertThrows; + +@TestPropertySource(properties = { + "spring.jpa.properties.javax.persistence.query.timeout=500" +}) +public class JdbcTemplateTest extends AbstractJpaDaoTest { + + @Autowired + private NamedParameterJdbcTemplate jdbcTemplate; + + @Test + public void queryTimeoutTest() { + assertThrows(DataAccessResourceFailureException.class, () -> jdbcTemplate.query("SELECT pg_sleep(10)", rs -> {})); + } +} diff --git a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/math/TbMathNode.java b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/math/TbMathNode.java index 500aef0fe5..76ba71163a 100644 --- a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/math/TbMathNode.java +++ b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/math/TbMathNode.java @@ -53,6 +53,7 @@ import java.util.function.BiFunction; import java.util.function.Function; import java.util.stream.Collectors; +import static org.thingsboard.common.util.ExpressionFunctionsUtil.userDefinedFunctions; import static org.thingsboard.rule.engine.math.TbMathArgumentType.CONSTANT; @SuppressWarnings("UnstableApiUsage") @@ -310,6 +311,7 @@ public class TbMathNode implements TbNode { var expr = customExpression.get(); if (expr == null) { expr = new ExpressionBuilder(config.getCustomFunction()) + .functions(userDefinedFunctions) .implicitMultiplication(true) .variables(config.getArguments().stream().map(TbMathArgument::getName).collect(Collectors.toSet())) .build(); diff --git a/rule-engine/rule-engine-components/src/test/java/org/thingsboard/rule/engine/math/TbMathNodeTest.java b/rule-engine/rule-engine-components/src/test/java/org/thingsboard/rule/engine/math/TbMathNodeTest.java index 71e9ed8350..dcdd2530df 100644 --- a/rule-engine/rule-engine-components/src/test/java/org/thingsboard/rule/engine/math/TbMathNodeTest.java +++ b/rule-engine/rule-engine-components/src/test/java/org/thingsboard/rule/engine/math/TbMathNodeTest.java @@ -56,6 +56,8 @@ import org.thingsboard.server.common.msg.TbMsgMetaData; import org.thingsboard.server.dao.attributes.AttributesService; import org.thingsboard.server.dao.timeseries.TimeseriesService; +import java.math.BigDecimal; +import java.math.RoundingMode; import java.util.Arrays; import java.util.List; import java.util.Optional; @@ -848,6 +850,42 @@ public class TbMathNodeTest { verify(ctx, never()).tellFailure(any(), any()); } + @ParameterizedTest + @MethodSource + public void testCustomFunctions(String customFunction, double result) { + var node = initNodeWithCustomFunction(customFunction, + new TbMathResult(TbMathArgumentType.MESSAGE_BODY, "result", 2, false, false, null), + new TbMathArgument("a", TbMathArgumentType.MESSAGE_BODY, "argumentA"), + new TbMathArgument("b", TbMathArgumentType.MESSAGE_BODY, "argumentB") + ); + + TbMsg msg = TbMsg.newMsg() + .type(TbMsgType.POST_TELEMETRY_REQUEST) + .originator(originator) + .metaData(TbMsgMetaData.EMPTY) + .data("{\"argumentA\":2,\"argumentB\":5}") + .build(); + + node.onMsg(ctx, msg); + + ArgumentCaptor msgCaptor = ArgumentCaptor.forClass(TbMsg.class); + verify(ctx, timeout(TIMEOUT)).tellSuccess(msgCaptor.capture()); + TbMsg outMsg = msgCaptor.getValue(); + assertThat(outMsg).isNotNull(); + assertThat(outMsg.getData()).isNotNull(); + var resultJson = JacksonUtil.toJsonNode(outMsg.getData()); + assertThat(resultJson.has("result")).isTrue(); + assertThat(resultJson.get("result").asDouble()).isEqualTo(new BigDecimal(result).setScale(2, RoundingMode.HALF_UP).doubleValue()); + } + + private static Stream testCustomFunctions() { + return Stream.of( + Arguments.of("ln(a)", Math.log(2)), + Arguments.of("lg(a)", Math.log10(2)), + Arguments.of("logab(a, b)", Math.log(5) / Math.log(2)) + ); + } + static class RuleDispatcherExecutor extends AbstractListeningExecutor { @Override protected int getThreadPollSize() { diff --git a/ui-ngx/package.json b/ui-ngx/package.json index 6280fb94c2..e927abe7b2 100644 --- a/ui-ngx/package.json +++ b/ui-ngx/package.json @@ -26,7 +26,7 @@ "@auth0/angular-jwt": "^5.2.0", "@flowjs/flow.js": "^2.14.1", "@flowjs/ngx-flow": "18.0.1", - "@geoman-io/leaflet-geoman-free": "2.17.0", + "@geoman-io/leaflet-geoman-free": "2.18.3", "@iplab/ngx-color-picker": "^18.0.1", "@mat-datetimepicker/core": "~14.0.0", "@mdi/svg": "^7.4.47", diff --git a/ui-ngx/src/app/core/api/entity-data-subscription.ts b/ui-ngx/src/app/core/api/entity-data-subscription.ts index 6579b952af..3adda4cd27 100644 --- a/ui-ngx/src/app/core/api/entity-data-subscription.ts +++ b/ui-ngx/src/app/core/api/entity-data-subscription.ts @@ -64,7 +64,7 @@ import { PageData } from '@shared/models/page/page-data'; import { DataAggregator, onAggregatedData } from '@core/api/data-aggregator'; import { NULL_UUID } from '@shared/models/id/has-uuid'; import { EntityType } from '@shared/models/entity-type.models'; -import { firstValueFrom, from, Observable, of, ReplaySubject, Subject, tap } from 'rxjs'; +import { firstValueFrom, from, Observable, of, ReplaySubject, Subject, Subscription } from 'rxjs'; import { EntityId } from '@shared/models/id/entity-id'; import { TelemetryWebsocketService } from '@core/ws/telemetry-websocket.service'; import { @@ -75,6 +75,7 @@ import { } from '@shared/models/js-function.models'; import { HttpClient } from '@angular/common/http'; import Timeout = NodeJS.Timeout; +import { finalize, switchMap } from 'rxjs/operators'; declare type DataKeyFunction = (time: number, prevValue: any) => any; declare type DataKeyPostFunction = (time: number, value: any, prevValue: any, timePrev: number, prevOrigValue: any) => any; @@ -141,6 +142,7 @@ export class EntityDataSubscription { private aggTsValues: Array; private aggTsComparisonValues: Array; + private subscribeSubscription: Subscription; private entityDataResolveSubject: Subject; private pageData: PageData; private prematureUpdates: Array>; @@ -216,7 +218,7 @@ export class EntityDataSubscription { if (isNotEmptyTbFunction(dataKey.postFuncBody) && !dataKey.postFunc) { try { dataKey.postFunc = await firstValueFrom(compileTbFunction(this.http, dataKey.postFuncBody, 'time', 'value', 'prevValue', 'timePrev', 'prevOrigValue')); - } catch (e) {} + } catch (e) {/**/} } } let key: string; @@ -245,6 +247,10 @@ export class EntityDataSubscription { } public unsubscribe() { + if (this.subscribeSubscription) { + this.subscribeSubscription.unsubscribe(); + this.subscribeSubscription = null; + } if (this.timeseriesTimer) { clearTimeout(this.timeseriesTimer); this.timeseriesTimer = null; @@ -277,8 +283,14 @@ export class EntityDataSubscription { public subscribe(): Observable { this.entityDataResolveSubject = new ReplaySubject(1); - from(this.initializeSubscription()).pipe( - tap(() => { + const subscribeSubject = new ReplaySubject(1); + this.subscribeSubscription = from(this.initializeSubscription()).pipe( + finalize(() => { + subscribeSubject.next(); + subscribeSubject.complete(); + }) + ).subscribe( + () => { if (this.entityDataSubscriptionOptions.isPaginatedDataSubscription) { this.started = true; this.dataResolved = true; @@ -621,13 +633,17 @@ export class EntityDataSubscription { ); this.subscriber.subscribe(); } + } + ); + return subscribeSubject.pipe( + switchMap(() => { + if (this.entityDataSubscriptionOptions.isPaginatedDataSubscription) { + return of(null); + } else { + return this.entityDataResolveSubject.asObservable(); + } }) - ).subscribe(); - if (this.entityDataSubscriptionOptions.isPaginatedDataSubscription) { - return of(null); - } else { - return this.entityDataResolveSubject.asObservable(); - } + ); } public start() { diff --git a/ui-ngx/src/app/core/services/utils.service.ts b/ui-ngx/src/app/core/services/utils.service.ts index 61f16d1f67..bfaad1148d 100644 --- a/ui-ngx/src/app/core/services/utils.service.ts +++ b/ui-ngx/src/app/core/services/utils.service.ts @@ -208,7 +208,7 @@ export class UtilsService { return parseException(exception, lineOffset); } - public customTranslation(translationValue: string, defaultValue: string): string { + public customTranslation(translationValue: string, defaultValue: string = translationValue): string { if (translationValue && isString(translationValue)) { if (translationValue.includes(`{${i18nPrefix}`)) { const matches = translationValue.match(i18nRegExp); diff --git a/ui-ngx/src/app/modules/home/components/calculated-fields/components/dialog/calculated-field-dialog.component.html b/ui-ngx/src/app/modules/home/components/calculated-fields/components/dialog/calculated-field-dialog.component.html index 813f6b1b4c..1688685523 100644 --- a/ui-ngx/src/app/modules/home/components/calculated-fields/components/dialog/calculated-field-dialog.component.html +++ b/ui-ngx/src/app/modules/home/components/calculated-fields/components/dialog/calculated-field-dialog.component.html @@ -75,7 +75,9 @@ />
-
{{ 'calculated-fields.expression' | translate }}
+
+ {{ (fieldFormGroup.get('type').value === CalculatedFieldType.SIMPLE ? 'calculated-fields.expression' : 'calculated-fields.type.script' ) | translate }} +
@if (configFormGroup.get('expressionSIMPLE').errors && configFormGroup.get('expressionSIMPLE').touched) { diff --git a/ui-ngx/src/app/modules/home/components/rule-node/common/time-unit-input.component.ts b/ui-ngx/src/app/modules/home/components/rule-node/common/time-unit-input.component.ts index 54bb7fa9b8..b0d0a97641 100644 --- a/ui-ngx/src/app/modules/home/components/rule-node/common/time-unit-input.component.ts +++ b/ui-ngx/src/app/modules/home/components/rule-node/common/time-unit-input.component.ts @@ -177,7 +177,7 @@ export class TimeUnitInputComponent implements ControlValueAccessor, Validator, } private updatedModel(value: Partial, forceUpdated = false) { - const time = value.time * this.timeIntervalsInSec.get(value.timeUnit); + const time = isDefinedAndNotNull(value.time) ? value.time * this.timeIntervalsInSec.get(value.timeUnit) : null; if (this.modelValue !== time || forceUpdated) { this.modelValue = time; this.propagateChange(time); diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/maps/data-layer/circles-data-layer.ts b/ui-ngx/src/app/modules/home/components/widget/lib/maps/data-layer/circles-data-layer.ts index 2d3fc6b2fe..078eadc898 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/maps/data-layer/circles-data-layer.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/maps/data-layer/circles-data-layer.ts @@ -23,7 +23,7 @@ import { } from '@shared/models/widget/maps/map.models'; import L from 'leaflet'; import { DataKey, FormattedData } from '@shared/models/widget.models'; -import { TbShapesDataLayer } from '@home/components/widget/lib/maps/data-layer/shapes-data-layer'; +import { ShapeStyleInfo, TbShapesDataLayer } from '@home/components/widget/lib/maps/data-layer/shapes-data-layer'; import { TbMap } from '@home/components/widget/lib/maps/map'; import { Observable } from 'rxjs'; import { isNotEmptyStr } from '@core/utils'; @@ -36,7 +36,7 @@ import { map } from 'rxjs/operators'; class TbCircleDataLayerItem extends TbLatestDataLayerItem { private circle: L.Circle; - private circleStyle: L.PathOptions; + private circleStyleInfo: ShapeStyleInfo; private editing = false; constructor(data: FormattedData, @@ -54,16 +54,29 @@ class TbCircleDataLayerItem extends TbLatestDataLayerItem, dsData: FormattedData[]): L.Layer { const circleData = this.dataLayer.extractCircleCoordinates(data); const center = new L.LatLng(circleData.latitude, circleData.longitude); - this.circleStyle = this.dataLayer.getShapeStyle(data, dsData); this.circle = L.circle(center, { bubblingMouseEvents: !this.dataLayer.isEditMode(), radius: circleData.radius, - ...this.circleStyle, snapIgnore: !this.dataLayer.isSnappable() }); + + this.dataLayer.getShapeStyle(data, dsData, this.circleStyleInfo?.patternId).subscribe((styleInfo) => { + this.circleStyleInfo = styleInfo; + if (this.circle) { + this.circle.setStyle(this.circleStyleInfo.style); + } + }); + this.updateLabel(data, dsData); return this.circle; } @@ -78,11 +91,13 @@ class TbCircleDataLayerItem extends TbLatestDataLayerItem, dsData: FormattedData[]): void { - this.circleStyle = this.dataLayer.getShapeStyle(data, dsData); - this.updateCircleShape(data); - this.updateTooltip(data, dsData); - this.updateLabel(data, dsData); - this.circle.setStyle(this.circleStyle); + this.dataLayer.getShapeStyle(data, dsData, this.circleStyleInfo?.patternId).subscribe((styleInfo) => { + this.circleStyleInfo = styleInfo; + this.updateCircleShape(data); + this.updateTooltip(data, dsData); + this.updateLabel(data, dsData); + this.circle.setStyle(this.circleStyleInfo.style); + }); } protected doInvalidateCoordinates(data: FormattedData, _dsData: FormattedData[]): void { @@ -126,7 +141,7 @@ class TbCircleDataLayerItem extends TbLatestDataLayerItem this.editing = true); this.circle.on('pm:markerdragend', () => this.editing = false); this.circle.on('pm:edit', () => this.saveCircleCoordinates()); - this.circle.pm.enable(); + this.circle.pm.enable({draggable: true, snappable: this.dataLayer.isSnappable()}); } return []; } diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/maps/data-layer/data-layer-utils.ts b/ui-ngx/src/app/modules/home/components/widget/lib/maps/data-layer/data-layer-utils.ts index d5329586be..4b7b933d74 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/maps/data-layer/data-layer-utils.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/maps/data-layer/data-layer-utils.ts @@ -29,35 +29,39 @@ export const createTooltip = (map: TbMap, settings: DataLayerTooltipSettings, data: FormattedData, canOpen: () => boolean): L.Popup => { - const tooltip = L.popup(); - layer.bindPopup(tooltip, {autoClose: settings.autoclose, closeOnClick: false}); - layer.off('click'); + const tooltip = L.popup({autoClose: settings.autoclose, closeOnClick: false}); + (tooltip as any)._source = layer; + layer.on('move', (e) => { + tooltip.setLatLng((e as any).latlng); + }); + layer.on('remove', () => { + tooltip.close(); + }); if (settings.trigger === DataLayerTooltipTrigger.click) { - layer.on('click', () => { + layer.on('click', (e) => { + L.DomEvent.stop(e); if (tooltip.isOpen()) { - layer.closePopup(); + tooltip.close(); } else if (canOpen()) { - layer.openPopup(); + if ((tooltip as any)._prepareOpen((layer as any)._latlng)) { + tooltip.openOn(map.getMap()); + } } }); } else if (settings.trigger === DataLayerTooltipTrigger.hover) { layer.on('mouseover', () => { if (canOpen()) { - layer.openPopup(); + if ((tooltip as any)._prepareOpen((layer as any)._latlng)) { + tooltip.openOn(map.getMap()); + } } }); - layer.on('mousemove', (e) => { - tooltip.setLatLng(e.latlng); - }); layer.on('mouseout', () => { - layer.closePopup(); + tooltip.close(); }); } layer.on('popupopen', () => { bindTooltipActions(map, tooltip, settings, data); - (layer as any)._popup._closeButton.addEventListener('click', (event: Event) => { - event.preventDefault(); - }); }); return tooltip; } diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/maps/data-layer/markers-data-layer.ts b/ui-ngx/src/app/modules/home/components/widget/lib/maps/data-layer/markers-data-layer.ts index 093cc265bb..1ca5e0b1bf 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/maps/data-layer/markers-data-layer.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/maps/data-layer/markers-data-layer.ts @@ -396,6 +396,8 @@ class TbMarkerDataLayerItem extends TbLatestDataLayerItem void; + private dragEnd: () => void; constructor(data: FormattedData, dsData: FormattedData[], @@ -415,6 +417,8 @@ class TbMarkerDataLayerItem extends TbLatestDataLayerItem, dsData: FormattedData[]): L.Marker { this.iconClassList = []; const location = this.dataLayer.dataProcessor.extractLocation(data, dsData); + this.dragStart = this._dragStart.bind(this); + this.dragEnd = this._dragEnd.bind(this); this.marker = L.marker(location, { tbMarkerData: data, snapIgnore: !this.dataLayer.isSnappable(), @@ -465,44 +469,28 @@ class TbMarkerDataLayerItem extends TbLatestDataLayerItem { - this.moving = true; - }); - this.marker.on('dragend', () => { - this.saveMarkerLocation(); - this.moving = false; - }); - } else { + this.marker.options.draggable = true; + this.marker.dragging?.enable(); + if (!this.settings.markerClustering?.enable) { this.marker.pm.setOptions({ snappable: this.dataLayer.isSnappable() }); this.marker.pm.enableLayerDrag(); - this.marker.on('pm:dragstart', () => { - (this.marker.dragging as any)._draggable = { _moved: true, off: (_args: any) => { return { disable: () => {}} } }; - (this.marker.dragging as any)._enabled = true; - this.moving = true; - }); - this.marker.on('pm:dragend', () => { - this.saveMarkerLocation(); - delete (this.marker.dragging as any)._draggable; - delete (this.marker.dragging as any)._enabled; - this.moving = false; - }); } + const evtPrefix = this.settings.markerClustering?.enable ? '' : 'pm:'; + this.marker.on(evtPrefix + 'dragstart', this.dragStart); + this.marker.on(evtPrefix + 'dragend', this.dragEnd); } protected disableDrag(): void { - if (this.settings.markerClustering?.enable) { - this.marker.options.draggable = false; - this.marker.off('dragstart'); - this.marker.off('dragend'); - } else { + this.marker.options.draggable = false; + this.marker.dragging?.disable(); + if (!this.settings.markerClustering?.enable) { this.marker.pm.disableLayerDrag(); - this.marker.off('pm:dragstart'); - this.marker.off('pm:dragend'); } + const evtPrefix = this.settings.markerClustering?.enable ? '' : 'pm:'; + this.marker.off(evtPrefix + 'dragstart', this.dragStart); + this.marker.off(evtPrefix + 'dragend', this.dragEnd); } protected removeDataItemTitle(): string { @@ -513,6 +501,15 @@ class TbMarkerDataLayerItem extends TbLatestDataLayerItem, @@ -54,16 +54,29 @@ class TbPolygonDataLayerItem extends TbLatestDataLayerItem, dsData: FormattedData[]): L.Layer { const polyData = this.dataLayer.extractPolygonCoordinates(data); const polyConstructor = isCutPolygon(polyData) || polyData.length !== 2 ? L.polygon : L.rectangle; - this.polygonStyle = this.dataLayer.getShapeStyle(data, dsData); this.polygon = polyConstructor(polyData as (TbPolygonRawCoordinates & L.LatLngTuple[]), { - ...this.polygonStyle, + noClip: true, snapIgnore: !this.dataLayer.isSnappable(), bubblingMouseEvents: !this.dataLayer.isEditMode() }); + this.dataLayer.getShapeStyle(data, dsData, this.polygonStyleInfo?.patternId).subscribe((styleInfo) => { + this.polygonStyleInfo = styleInfo; + if (this.polygon) { + this.polygon.setStyle(this.polygonStyleInfo.style); + } + }); + this.polygonContainer = L.featureGroup(); this.polygon.addTo(this.polygonContainer); @@ -81,13 +94,15 @@ class TbPolygonDataLayerItem extends TbLatestDataLayerItem, dsData: FormattedData[]): void { - this.polygonStyle = this.dataLayer.getShapeStyle(data, dsData); - this.updatePolygonShape(data); - this.updateTooltip(data, dsData); - this.updateLabel(data, dsData); - if (!this.editing || !this.dataLayer.getMap().getMap().pm.globalCutModeEnabled()) { - this.polygon.setStyle(this.polygonStyle); - } + this.dataLayer.getShapeStyle(data, dsData, this.polygonStyleInfo?.patternId).subscribe((styleInfo) => { + this.polygonStyleInfo = styleInfo; + this.updatePolygonShape(data); + this.updateTooltip(data, dsData); + this.updateLabel(data, dsData); + if (!this.editing || !this.dataLayer.getMap().getMap().pm.globalCutModeEnabled()) { + this.polygon.setStyle(this.polygonStyleInfo.style); + } + }); } protected doInvalidateCoordinates(data: FormattedData, _dsData: FormattedData[]): void { @@ -230,7 +245,7 @@ class TbPolygonDataLayerItem extends TbLatestDataLayerItem { @@ -238,7 +253,7 @@ class TbPolygonDataLayerItem extends TbLatestDataLayerItem { + + static fromSettings(dataLayer: TbMapDataLayer, + settings: ShapeDataLayerSettings): ShapePatternProcessor { + switch (settings.fillType) { + case ShapeFillType.color: + return new ShapeColorPatternProcessor(dataLayer, settings.fillColor); + case ShapeFillType.image: + return new ShapeImagePatternProcessor(dataLayer, settings.fillImage); + case ShapeFillType.stripe: + return new ShapeStripePatternProcessor(dataLayer, settings.fillStripe); + } + } + + protected constructor(protected dataLayer: TbMapDataLayer, + protected settings: S) {} + + public abstract setup(): Observable; + + protected abstract computePattern(data: FormattedData, + dsData: FormattedData[]): Observable; + + public processPattern(data: FormattedData, + dsData: FormattedData[], prevPatternId?: string): Observable { + return this.computePattern(data, dsData).pipe( + map((patternInfo) => this.patternFromPatternInfo(patternInfo, prevPatternId)) + ); + } + + private patternFromPatternInfo(patternInfo: ShapePatternInfo, prevPatternId?: string): PatternWithId { + const patternId = objectHashCode(patternInfo) + ''; + let pattern = this.dataLayer.getMap().useShapePattern(patternId, prevPatternId); + if (!pattern) { + pattern = this.constructPattern(patternInfo); + this.dataLayer.getMap().storeShapePattern(patternId, pattern); + } + return { + pattern, + patternId + }; + } + + private constructPattern(patternInfo: ShapePatternInfo): L.TB.Pattern { + let pattern: L.TB.Pattern; + if (patternInfo.type === ShapeFillType.color) { + pattern = new L.TB.Pattern({width: 1, height: 1}); + const fillRect = new L.TB.PatternRect({x: 0, y: 0, width: 1, height: 1, + fillOpacity: 1, stroke: false, fill: true, fillColor: patternInfo.fillColor}); + pattern.addElement(fillRect); + } else if (patternInfo.type === ShapeFillType.image) { + pattern = new L.TB.Pattern({ + width: 1, + height: 1, + patternUnits: 'objectBoundingBox', + patternContentUnits: 'objectBoundingBox', + preserveAspectRatioAlign: 'xMidYMid', + preserveAspectRatioMeetOrSlice: 'slice', + viewBox: [0,0,patternInfo.fillImage.width,patternInfo.fillImage.height] + }); + const imagePatternShape = new L.TB.PatternImage({ + imageUrl: patternInfo.fillImage.image, + width: patternInfo.fillImage.width, + height: patternInfo.fillImage.height, + opacity: patternInfo.fillImage.opacity, + angle: patternInfo.fillImage.angle, + scale: patternInfo.fillImage.scale + }); + pattern.addElement(imagePatternShape); + } else if (patternInfo.type === ShapeFillType.stripe) { + const stripeInfo = patternInfo.fillStripe; + const height = stripeInfo.weight + stripeInfo.spaceWeight; + pattern = new L.TB.Pattern({width: 8, height, angle: stripeInfo.angle}); + const stripePattern = new L.TB.PatternPath({ + d: 'M0 ' + stripeInfo.weight / 2 + ' H ' + 8, + stroke: true, + weight: stripeInfo.weight, + color: stripeInfo.color, + opacity: 1 + }); + pattern.addElement(stripePattern); + const spacePattern = new L.TB.PatternPath({ + d: 'M0 ' + (stripeInfo.weight + stripeInfo.spaceWeight / 2) + ' H ' + 8, + stroke: true, + weight: stripeInfo.spaceWeight, + color: stripeInfo.spaceColor, + opacity: 1 + }); + pattern.addElement(spacePattern); + } + return pattern; + } + +} + +class ShapeColorPatternProcessor extends ShapePatternProcessor { + + private fillColorProcessor: DataLayerColorProcessor; + + constructor(protected dataLayer: TbMapDataLayer, + protected settings: DataLayerColorSettings) { + super(dataLayer, settings); + } + + public setup(): Observable { + this.fillColorProcessor = new DataLayerColorProcessor(this.dataLayer, this.settings); + return this.fillColorProcessor.setup(); + } + + protected computePattern(data: FormattedData, dsData: FormattedData[]): Observable { + const fillColor = this.fillColorProcessor.processColor(data, dsData); + const shapePatternInfo: ShapePatternInfo = { + type: ShapeFillType.color, + fillColor + }; + return of(shapePatternInfo); + } + +} + +class ShapeImagePatternProcessor extends ShapePatternProcessor { + + private shapeFillImageFunction: CompiledTbFunction; + + constructor(protected dataLayer: TbMapDataLayer, + protected settings: ShapeFillImageSettings) { + super(dataLayer, settings); + } + + public setup(): Observable { + if (this.settings.type === ShapeFillImageType.function) { + return parseTbFunction(this.dataLayer.getCtx().http, this.settings.imageFunction, ['data', 'images', 'dsData']).pipe( + map((parsed) => { + this.shapeFillImageFunction = parsed; + return null; + }) + ); + } else { + return of(null); + } + } + + protected computePattern(data: FormattedData, dsData: FormattedData[]): Observable { + let currentImage: ShapeFillImageInfo; + if (this.settings.type === ShapeFillImageType.function) { + currentImage = safeExecuteTbFunction(this.shapeFillImageFunction, [data, this.settings.images, dsData]); + } + if (!currentImage?.url) { + currentImage = { + url: this.settings.image, + opacity: this.settings.opacity, + angle: this.settings.angle, + scale: this.settings.scale + }; + } + return this.loadPatternInfoFromImage(currentImage); + } + + private loadPatternInfoFromImage(image: ShapeFillImageInfo): Observable { + const imageUrl = image?.url || '/assets/widget-preview-empty.svg'; + const opacity = isDefinedAndNotNull(image?.opacity) ? image.opacity : 1; + const imagePipe = this.dataLayer.getCtx().$injector.get(ImagePipe); + return loadImageWithAspect(imagePipe, imageUrl).pipe( + map((res) => { + const shapePatternInfo: ShapePatternInfo = { + type: ShapeFillType.image, + fillImage: { + image: res.url, + width: res.width, + height: res.height, + opacity, + angle: image?.angle, + scale: image?.scale + } + }; + return shapePatternInfo; + }) + ); + } +} + +class ShapeStripePatternProcessor extends ShapePatternProcessor { + + private colorProcessor: DataLayerColorProcessor; + private spaceColorProcessor: DataLayerColorProcessor; + + constructor(protected dataLayer: TbMapDataLayer, + protected settings: ShapeFillStripeSettings) { + super(dataLayer, settings); + } + + public setup(): Observable { + this.colorProcessor = new DataLayerColorProcessor(this.dataLayer, this.settings.color); + this.spaceColorProcessor = new DataLayerColorProcessor(this.dataLayer, this.settings.spaceColor); + return forkJoin([this.colorProcessor.setup(), this.spaceColorProcessor.setup()]); + } + + protected computePattern(data: FormattedData, dsData: FormattedData[]): Observable { + const color = this.colorProcessor.processColor(data, dsData); + const spaceColor = this.spaceColorProcessor.processColor(data, dsData); + return of({ + type: ShapeFillType.stripe, + fillStripe: { + color, + spaceColor, + angle: this.settings.angle, + weight: this.settings.weight, + spaceWeight: this.settings.spaceWeight + } + }); + } + +} export abstract class TbShapesDataLayer> extends TbLatestMapDataLayer { - public fillColorProcessor: DataLayerColorProcessor; - public strokeColorProcessor: DataLayerColorProcessor; + private shapePatternProcessor: ShapePatternProcessor; + private strokeColorProcessor: DataLayerColorProcessor; protected constructor(protected map: TbMap, inputSettings: S) { super(map, inputSettings); } - public getShapeStyle(data: FormattedData, dsData: FormattedData[]): L.PathOptions { - const fill = this.fillColorProcessor.processColor(data, dsData); - const stroke = this.strokeColorProcessor.processColor(data, dsData); - return { - fill: true, - fillColor: fill, - color: stroke, - weight: this.settings.strokeWeight, - fillOpacity: 1, - opacity: 1 - }; + public getShapeStyle(data: FormattedData, dsData: FormattedData[], fillPatternId: string): Observable { + return this.shapePatternProcessor.processPattern(data, dsData, fillPatternId).pipe( + map((patternWithId) => { + const stroke = this.strokeColorProcessor.processColor(data, dsData); + const style: L.PathOptions = { + fill: true, + fillPattern: patternWithId.pattern, + color: stroke, + weight: this.settings.strokeWeight, + fillOpacity: 1, + opacity: 1 + }; + return { + patternId: patternWithId.patternId, + style + } + }) + ); } protected allColorSettings(): DataLayerColorSettings[] { - return [this.settings.fillColor, this.settings.strokeColor]; + const colorSettings: DataLayerColorSettings[] = [this.settings.strokeColor]; + if (this.settings.fillType === ShapeFillType.color) { + colorSettings.push(this.settings.fillColor) + } else if (this.settings.fillType === ShapeFillType.stripe) { + if (this.settings.fillStripe?.color) { + colorSettings.push(this.settings.fillStripe.color); + } + if (this.settings.fillStripe?.spaceColor) { + colorSettings.push(this.settings.fillStripe.spaceColor); + } + } + return colorSettings; } protected doSetup(): Observable { - this.fillColorProcessor = new DataLayerColorProcessor(this, this.settings.fillColor); + this.shapePatternProcessor = ShapePatternProcessor.fromSettings(this, this.settings); this.strokeColorProcessor = new DataLayerColorProcessor(this, this.settings.strokeColor); - return forkJoin([this.fillColorProcessor.setup(), this.strokeColorProcessor.setup()]); + return forkJoin([this.shapePatternProcessor.setup(), this.strokeColorProcessor.setup()]); } } diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/maps/leaflet/leaflet-tb.ts b/ui-ngx/src/app/modules/home/components/widget/lib/maps/leaflet/leaflet-tb.ts index 79e2f11ce6..6176975975 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/maps/leaflet/leaflet-tb.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/maps/leaflet/leaflet-tb.ts @@ -15,7 +15,7 @@ /// import L, { TB } from 'leaflet'; -import { guid, isNotEmptyStr } from '@core/utils'; +import { guid, isDefinedAndNotNull, isNotEmptyStr } from '@core/utils'; import 'leaflet-providers'; import { Map as MapLibreGLMap, LngLat as MapLibreGLLngLat } from 'maplibre-gl'; import '@geoman-io/leaflet-geoman-free'; @@ -27,6 +27,64 @@ import { of } from 'rxjs'; L.MarkerCluster = L.MarkerCluster.mergeOptions({ pmIgnore: true }); +L.Map.addInitHook(function () { + this._patterns = {}; +}); + +L.Map.include({ + + addPattern: function (pattern: Pattern) { + const id = L.stamp(pattern); + if (this._patterns[id]) { + return pattern; + } + this._patterns[id] = pattern; + this.whenReady(() => { + pattern.onAdd(this); + }); + return this; + }, + + removePattern: function (pattern: Pattern) { + const id = L.stamp(pattern); + if (!this._patterns[id]) { + return this; + } + if (this._loaded) { + pattern.onRemove(this); + } + delete this._patterns[id]; + + if (this._loaded) { + this.fire('patternremove', {pattern: pattern}); + pattern.fire('remove'); + } + + pattern._map = null; + return this; + }, + + _initDefRoot: function () { + if (!this._defRoot) { + const renderer: L.Renderer = this.getRenderer(this); + this._defRoot = Pattern.prototype._createElement('defs'); + ((renderer as any)._container).appendChild(this._defRoot); + } + } +}); + +L.SVG.include({ + _superUpdateStyle: (L.SVG.prototype as any)._updateStyle, + + _updateStyle: function (layer: L.Layer){ + this._superUpdateStyle(layer); + const options: L.PathOptions = layer.options; + if (options.fill && options.fillPattern) { + ((layer as any)._path as SVGElement).setAttribute('fill', 'url(#' + L.stamp(options.fillPattern) + ")"); + } + } +}) + class SidebarControl extends L.Control implements L.TB.SidebarControl { private readonly sidebar: JQuery; @@ -572,6 +630,373 @@ class BottomToolbarControl implements L.TB.BottomToolbarControl { } +class Pattern extends L.Evented implements L.TB.Pattern { + + _map: L.Map; + _dom: SVGPatternElement & HTMLElement; + + private options: L.TB.PatternOptions = { + x: 0, + y: 0, + width: 8, + height: 8, + patternUnits: 'userSpaceOnUse', + patternContentUnits: 'userSpaceOnUse' + }; + private _elements: {[id: string]: PatternElement} = {}; + + constructor(options: L.TB.PatternOptions) { + super(); + this.options = {...this.options, ...options}; + } + + onAdd(map: L.Map): void { + this._map = map; + this._map._initDefRoot(); + + this._initDom(); + + for (const i in this._elements) { + this._elements[i].onAdd(this); + } + + this._addElements(); + this._addDom(); + this.redraw(); + this.fire('add'); + this._map.fire('patternadd', {pattern: this}); + } + + onRemove(_map: L.Map): void { + this._removeDom(); + } + + redraw(): this { + if (this._map) { + this._update(); + for (const i in this._elements) { + this._elements[i].redraw(); + } + } + return this; + } + + setStyle(style: L.TB.PatternOptions): this { + L.setOptions(this, style); + if (this._map) { + this._updateStyle(); + this.redraw(); + } + return this; + } + + addTo(map: L.Map): this { + map.addPattern(this); + return this; + } + + remove(): this { + return this.removeFrom(this._map); + } + + removeFrom(map: L.Map): this { + if (map) { + map.removePattern(this); + } + return this; + } + + addElement(element: PatternElement): PatternElement | undefined { + const id = L.stamp(element); + if (this._elements[id]) { + return element; + } + this._elements[id] = element; + element.onAdd(this); + } + + _createElement (name: string): E { + return document.createElementNS("http://www.w3.org/2000/svg", name) as E; + } + + _initDom(): void { + this._dom = this._createElement('pattern'); + if (this.options.className) { + L.DomUtil.addClass(this._dom, this.options.className); + } + this._updateStyle(); + } + + _addDom(): void { + this._map._defRoot.appendChild(this._dom); + } + + _removeDom(): void { + L.DomUtil.remove(this._dom); + } + + _updateStyle(): void { + const dom = this._dom; + const options = this.options; + if (!dom) { return; } + dom.setAttribute('id', `${L.stamp(this)}`); + dom.setAttribute('x', `${options.x}`); + dom.setAttribute('y', `${options.y}`); + dom.setAttribute('width', `${options.width}`); + dom.setAttribute('height', `${options.height}`); + dom.setAttribute('patternUnits', options.patternUnits); + dom.setAttribute('patternContentUnits', options.patternContentUnits); + + if (options.patternTransform || options.angle) { + let transform = options.patternTransform ? options.patternTransform + " " : ""; + transform += options.angle ? "rotate(" + options.angle + ") " : ""; + dom.setAttribute('patternTransform', transform); + } else { + dom.removeAttribute('patternTransform'); + } + if (options.viewBox) { + dom.setAttribute('viewBox', options.viewBox.join(' ')); + } else { + dom.removeAttribute('viewBox'); + } + if (options.preserveAspectRatioAlign) { + let preserveAspectRatioValue = options.preserveAspectRatioAlign; + if (preserveAspectRatioValue !== 'none' && options.preserveAspectRatioMeetOrSlice) { + preserveAspectRatioValue += (' ' + options.preserveAspectRatioMeetOrSlice); + } + dom.setAttribute('preserveAspectRatio', preserveAspectRatioValue); + } else { + dom.removeAttribute('preserveAspectRatio'); + } + + for (const i in this._elements) { + this._elements[i]._updateStyle(); + } + } + + protected _addElements() {}; + protected _update() {}; + +} + +abstract class PatternElement extends L.Class implements L.TB.PatternElement { + + protected options: O; + + protected _pattern: Pattern; + protected _dom: SVGElement & HTMLElement; + + protected constructor(options: L.TB.PatternElementOptions) { + super(); + this.options = {...this._defaultOptions(), ...options}; + } + + onAdd(pattern: Pattern): void { + this._pattern = pattern; + if (this._pattern._dom) { + this._initDom(); + this._addDom(); + } + } + + addTo(pattern: Pattern): this { + pattern.addElement(this); + return this; + } + + redraw(): this { + if (this._pattern) { + this._updateElement(); + } + return this; + } + + setStyle(style: L.TB.PatternElementOptions): this { + L.setOptions(this, style); + if (this._pattern) { + this._updateStyle(); + } + return this; + } + + _createElement (name: string): E { + return document.createElementNS("http://www.w3.org/2000/svg", name) as E; + } + + _initDomElement(type: string): void { + this._dom = this._createElement(type); + if (this.options.className) { + L.DomUtil.addClass(this._dom, this.options.className); + } + this._updateStyle(); + } + + _addDom(): void { + this._pattern._dom.appendChild(this._dom); + } + + _updateStyle(): void {} + + protected _initDom() {} + protected _updateElement() {} + + protected abstract _defaultOptions(): O; +} + +const defaultPatternShapeOptions: L.TB.PatternShapeOptions = { + stroke: true, + color: '#3388ff', + weight: 3, + opacity: 1, + lineCap: 'round', + lineJoin: 'round', + fillOpacity: 0.2, + fillRule: 'evenodd' +}; + + +abstract class PatternShape extends PatternElement implements L.TB.PatternShape { + + protected constructor(options: O) { + super(options); + } + + _updateStyle(): void { + const dom = this._dom; + const options = this.options; + if (!dom) { return; } + if (options.stroke) { + dom.setAttribute('stroke', options.color); + dom.setAttribute('stroke-opacity', `${options.opacity}`); + dom.setAttribute('stroke-width', `${options.weight}`); + dom.setAttribute('stroke-linecap', options.lineCap); + dom.setAttribute('stroke-linejoin', options.lineJoin); + + if (options.dashArray) { + dom.setAttribute('stroke-dasharray', options.dashArray.join(' ')); + } else { + dom.removeAttribute('stroke-dasharray'); + } + + if (options.dashOffset) { + dom.setAttribute('stroke-dashoffset', `${options.dashOffset}`); + } else { + dom.removeAttribute('stroke-dashoffset'); + } + } else { + dom.setAttribute('stroke', 'none'); + } + + if (options.fill) { + if (options.fillPattern) { + dom.setAttribute('fill', 'url(#' + L.stamp(options.fillPattern) + ")"); + } + else { + dom.setAttribute('fill', options.fillColor || options.color); + } + dom.setAttribute('fill-opacity', `${options.fillOpacity}`); + dom.setAttribute('fill-rule', options.fillRule || 'evenodd'); + } else { + dom.setAttribute('fill', 'none'); + } + dom.setAttribute('pointer-events', options.pointerEvents || (options.interactive ? 'visiblePainted' : 'none')); + } +} + +class PatternRect extends PatternShape implements L.TB.PatternRect { + + constructor(options: L.TB.PatternRectOptions) { + super(options); + } + + protected _initDom() { + this._initDomElement('rect'); + } + + protected _updateElement() { + if (!this._dom) { return; } + this._dom.setAttribute('x', `${this.options.x}`); + this._dom.setAttribute('y', `${this.options.y}`); + this._dom.setAttribute('width', `${this.options.width}`); + this._dom.setAttribute('height', `${this.options.height}`); + if (this.options.rx) { this._dom.setAttribute('rx', `${this.options.rx}`); } + if (this.options.ry) { this._dom.setAttribute('ry', `${this.options.ry}`); } + } + + protected _defaultOptions(): L.TB.PatternRectOptions { + return { + x: 0, + y: 0, + width: 10, + height: 10, + ...defaultPatternShapeOptions + }; + } +} + +class PatternPath extends PatternShape implements L.TB.PatternPath { + + constructor(options: L.TB.PatternPathOptions) { + super(options); + } + + protected _initDom() { + this._initDomElement('path'); + } + + protected _updateElement() { + if (!this._dom) { return; } + this._dom.setAttribute('d', this.options.d); + } + + protected _defaultOptions(): L.TB.PatternPathOptions { + return {...defaultPatternShapeOptions}; + } +} + +class PatternImage extends PatternElement implements L.TB.PatternImage { + + constructor(options: TB.PatternImageOptions) { + super(options); + } + + protected _initDom() { + this._initDomElement('image'); + } + + _updateStyle(): void { + const dom = this._dom; + const options = this.options; + if (!dom) { return; } + this._dom.setAttribute('href', options.imageUrl); + this._dom.setAttribute('opacity', isDefinedAndNotNull(options.opacity) ? `${options.opacity}` : '1'); + this._dom.setAttribute('x', '0'); + this._dom.setAttribute('y', '0'); + this._dom.setAttribute('width', `${options.width}`); + this._dom.setAttribute('height', `${options.height}`); + this._dom.setAttribute('preserveAspectRatio', 'xMidYMid slice'); + const transforms: string[] = []; + if (options.angle) { + transforms.push(`rotate(${options.angle})`); + } + if (options.scale && options.scale !== 1) { + transforms.push(`scale(${options.scale})`); + } + if (transforms.length) { + this._dom.setAttribute('transform', transforms.join(' ')); + this._dom.setAttribute('transform-origin', `${options.width/2} ${options.height/2}`); + } + } + + protected _defaultOptions(): L.TB.PatternImageOptions { + return { + imageUrl: '', + width: 0, + height: 0 + }; + } +} + const sidebar = (options: TB.SidebarControlOptions): L.TB.SidebarControl => { return new SidebarControl(options); } @@ -952,6 +1377,12 @@ L.TB = L.TB || { ToolbarButton, ToolbarControl, BottomToolbarControl, + Pattern, + PatternElement, + PatternShape, + PatternRect, + PatternPath, + PatternImage, sidebar, sidebarPane, layers, diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/maps/map.ts b/ui-ngx/src/app/modules/home/components/widget/lib/maps/map.ts index f1bb414878..aa8dd67494 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/maps/map.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/maps/map.ts @@ -50,7 +50,14 @@ import { UnplacedMapDataItem, } from '@home/components/widget/lib/maps/data-layer/latest-map-data-layer'; import { IWidgetSubscription, PlaceMapItemActionData, WidgetSubscriptionOptions } from '@core/api/widget-api.models'; -import { FormattedData, MapItemType, WidgetAction, WidgetActionType, widgetType } from '@shared/models/widget.models'; +import { + FormattedData, + mapItemTooltipsTranslation, + MapItemType, + WidgetAction, + WidgetActionType, + widgetType +} from '@shared/models/widget.models'; import { EntityDataPageLink } from '@shared/models/query/query.models'; import { CustomTranslatePipe } from '@shared/pipe/custom-translate.pipe'; import { TbMarkersDataLayer } from '@home/components/widget/lib/maps/data-layer/markers-data-layer'; @@ -75,6 +82,7 @@ import { TbMapDataLayer } from '@home/components/widget/lib/maps/data-layer/map- import { EntityType } from '@shared/models/entity-type.models'; import ITooltipsterInstance = JQueryTooltipster.ITooltipsterInstance; import TooltipPositioningSide = JQueryTooltipster.TooltipPositioningSide; +import { ShapePatternStorage } from '@home/components/widget/lib/maps/data-layer/shapes-data-layer'; type TooltipInstancesData = {root: HTMLElement, instances: ITooltipsterInstance[]}; @@ -125,6 +133,8 @@ export abstract class TbMap { protected addPolygonDataLayers: TbLatestMapDataLayer[]; protected addCircleDataLayers: TbLatestMapDataLayer[]; + protected shapePatternStorage: ShapePatternStorage = {}; + private readonly mapResize$: ResizeObserver; private tooltipInstances: TooltipInstancesData[] = []; @@ -326,7 +336,11 @@ export abstract class TbMap { type: widgetType.latest, callbacks: { onDataUpdated: (subscription) => { - this.update(subscription); + try { + this.update(subscription); + } catch (e) { + console.error(e); + } } } }; @@ -360,10 +374,18 @@ export abstract class TbMap { type: widgetType.timeseries, callbacks: { onDataUpdated: (subscription) => { - this.updateTrips(subscription); + try { + this.updateTrips(subscription); + } catch (e) { + console.error(e); + } }, onLatestDataUpdated: (subscription) => { - this.updateTripsWithLatestData(subscription); + try { + this.updateTripsWithLatestData(subscription); + } catch (e) { + console.error(e); + } } } }; @@ -661,29 +683,45 @@ export abstract class TbMap { private createMarker(actionData: PlaceMapItemActionData) { this.createItem(actionData, () => this.prepareDrawMode('Marker', { - placeMarker: this.ctx.translate.instant('widgets.maps.data-layer.marker.place-marker-hint') + placeMarker: actionData.action.mapItemTooltips.placeMarker + ? this.ctx.utilsService.customTranslation(actionData.action.mapItemTooltips.placeMarker) + : this.ctx.translate.instant(mapItemTooltipsTranslation.placeMarker) })); } private createRectangle(actionData: PlaceMapItemActionData): void { this.createItem(actionData, () => this.prepareDrawMode('Rectangle', { - firstVertex: this.ctx.translate.instant('widgets.maps.data-layer.polygon.rectangle-place-first-point-hint'), - finishRect: this.ctx.translate.instant('widgets.maps.data-layer.polygon.finish-rectangle-hint') + firstVertex: actionData.action.mapItemTooltips.startRect + ? this.ctx.utilsService.customTranslation(actionData.action.mapItemTooltips.startRect) + : this.ctx.translate.instant(mapItemTooltipsTranslation.startRect), + finishRect: actionData.action.mapItemTooltips.finishRect + ? this.ctx.utilsService.customTranslation(actionData.action.mapItemTooltips.finishRect) + : this.ctx.translate.instant(mapItemTooltipsTranslation.finishRect), })); } private createPolygon(actionData: PlaceMapItemActionData): void { this.createItem(actionData, () => this.prepareDrawMode('Polygon', { - firstVertex: this.ctx.translate.instant('widgets.maps.data-layer.polygon.polygon-place-first-point-hint'), - continueLine: this.ctx.translate.instant('widgets.maps.data-layer.polygon.continue-polygon-hint'), - finishPoly: this.ctx.translate.instant('widgets.maps.data-layer.polygon.finish-polygon-hint') + firstVertex: actionData.action.mapItemTooltips.firstVertex + ? this.ctx.utilsService.customTranslation(actionData.action.mapItemTooltips.firstVertex) + : this.ctx.translate.instant(mapItemTooltipsTranslation.firstVertex), + continueLine: actionData.action.mapItemTooltips.continueLine + ? this.ctx.utilsService.customTranslation(actionData.action.mapItemTooltips.continueLine) + : this.ctx.translate.instant(mapItemTooltipsTranslation.continueLine), + finishPoly: actionData.action.mapItemTooltips.finishPoly + ? this.ctx.utilsService.customTranslation(actionData.action.mapItemTooltips.finishPoly) + : this.ctx.translate.instant(mapItemTooltipsTranslation.finishPoly), })); } private createCircle(actionData: PlaceMapItemActionData): void { this.createItem(actionData, () => this.prepareDrawMode('Circle', { - startCircle: this.ctx.translate.instant('widgets.maps.data-layer.circle.place-circle-center-hint'), - finishCircle: this.ctx.translate.instant('widgets.maps.data-layer.circle.finish-circle-hint') + startCircle: actionData.action.mapItemTooltips.startCircle + ? this.ctx.utilsService.customTranslation(actionData.action.mapItemTooltips.startCircle) + : this.ctx.translate.instant(mapItemTooltipsTranslation.startCircle), + finishCircle: actionData.action.mapItemTooltips.finishCircle + ? this.ctx.utilsService.customTranslation(actionData.action.mapItemTooltips.finishCircle) + : this.ctx.translate.instant(mapItemTooltipsTranslation.finishCircle), })); } @@ -1066,6 +1104,42 @@ export abstract class TbMap { return this.settings.mapType; } + public useShapePattern(patternId: string, prevPatternId?: string): L.TB.Pattern { + if (prevPatternId && patternId !== prevPatternId) { + this.unUseShapePattern(prevPatternId); + } + if (this.shapePatternStorage[patternId]) { + const patternItem = this.shapePatternStorage[patternId]; + if (patternId !== prevPatternId) { + patternItem.refCount++; + return patternItem.pattern; + } else { + return patternItem.pattern; + } + } + } + + public unUseShapePattern(patternId: string): void { + if (patternId) { + const patternItem = this.shapePatternStorage[patternId]; + if (patternItem) { + patternItem.refCount--; + if (patternItem.refCount === 0) { + patternItem.pattern.remove(); + delete this.shapePatternStorage[patternId]; + } + } + } + } + + public storeShapePattern(patternId: string, pattern: L.TB.Pattern): void { + pattern.addTo(this.map); + this.shapePatternStorage[patternId] = { + pattern, + refCount: 1 + }; + } + public enabledDataLayersUpdated() { this.updateEditButtonsStates(); this.updateTripsAnchors(); diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/maps/panels/select-map-entity-panel.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/maps/panels/select-map-entity-panel.component.ts index 24e89aecd5..132177d0eb 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/maps/panels/select-map-entity-panel.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/maps/panels/select-map-entity-panel.component.ts @@ -15,10 +15,7 @@ /// import { Component, EventEmitter, Input, OnInit, Output, ViewEncapsulation } from '@angular/core'; -import { PageComponent } from '@shared/components/page.component'; import { UntypedFormBuilder, UntypedFormGroup, Validators } from '@angular/forms'; -import { Store } from '@ngrx/store'; -import { AppState } from '@core/core.state'; import { TbPopoverComponent } from '@shared/components/popover.component'; import { UnplacedMapDataItem } from '@home/components/widget/lib/maps/data-layer/latest-map-data-layer'; @@ -29,7 +26,7 @@ import { UnplacedMapDataItem } from '@home/components/widget/lib/maps/data-layer styleUrls: ['./select-map-entity-panel.component.scss'], encapsulation: ViewEncapsulation.None }) -export class SelectMapEntityPanelComponent extends PageComponent implements OnInit { +export class SelectMapEntityPanelComponent implements OnInit { @Input() entities: UnplacedMapDataItem[]; @@ -42,9 +39,7 @@ export class SelectMapEntityPanelComponent extends PageComponent implements OnIn selectedEntity: UnplacedMapDataItem = null; constructor(private fb: UntypedFormBuilder, - protected store: Store, private popover: TbPopoverComponent) { - super(store); } ngOnInit(): void { diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/action/map-item-tooltips.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/action/map-item-tooltips.component.html new file mode 100644 index 0000000000..f0abe17509 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/action/map-item-tooltips.component.html @@ -0,0 +1,82 @@ + +
+ + {{ 'widget-action.map-item-tooltip.customize-map-item-tooltips' | translate }} + + @switch (mapItemType) { + @case (MapItemType.marker) { +
+
widget-action.map-item-tooltip.place-marker
+ + + +
+ } + @case (MapItemType.rectangle) { +
+
widget-action.map-item-tooltip.start-draw-rectangle
+ + + +
+
+
widget-action.map-item-tooltip.finish-draw-rectangle
+ + + +
+ } + @case (MapItemType.polygon) { +
+
widget-action.map-item-tooltip.start-draw-polygon
+ + + +
+
+
widget-action.map-item-tooltip.continue-draw-polygon
+ + + +
+
+
widget-action.map-item-tooltip.finish-draw-polygon
+ + + +
+ } + @case (MapItemType.circle) { +
+
widget-action.map-item-tooltip.start-draw-circle
+ + + +
+
+
widget-action.map-item-tooltip.finish-draw-circle
+ + + +
+ } + } +
+
+
diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/action/map-item-tooltips.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/action/map-item-tooltips.component.ts new file mode 100644 index 0000000000..830fd925b7 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/action/map-item-tooltips.component.ts @@ -0,0 +1,123 @@ +/// +/// Copyright © 2016-2025 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +/// See the License for the specific language governing permissions and +/// limitations under the License. +/// + +import { Component, forwardRef, Input, OnChanges, SimpleChanges } from '@angular/core'; +import { ControlValueAccessor, FormBuilder, FormGroup, NG_VALUE_ACCESSOR } from '@angular/forms'; +import { MapItemTooltips, MapItemType, mapItemTooltipsTranslation } from '@shared/models/widget.models'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; +import { deepTrim, isEqual } from '@core/utils'; + +@Component({ + selector: 'tb-map-item-tooltips', + templateUrl: './map-item-tooltips.component.html', + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => MapItemTooltipsComponent), + multi: true + } + ] +}) +export class MapItemTooltipsComponent implements ControlValueAccessor, OnChanges { + + @Input({required: true}) + mapItemType: MapItemType; + + tooltipsForm: FormGroup; + MapItemType = MapItemType; + readonly mapItemTooltipsDefaultTranslate = mapItemTooltipsTranslation; + + private modelValue: MapItemTooltips; + private propagateChange = (_val: any) => {}; + + constructor(private fd: FormBuilder) { + this.tooltipsForm = this.fd.group({ + placeMarker: [''], + firstVertex: [''], + continueLine: [''], + finishPoly: [''], + startRect: [''], + finishRect: [''], + startCircle: [''], + finishCircle: [''] + }); + + this.tooltipsForm.valueChanges.pipe( + takeUntilDestroyed() + ).subscribe(this.updatedModel.bind(this)); + } + + ngOnChanges(changes: SimpleChanges) { + if (changes.mapItemType) { + const mapItemTypeChanges = changes.mapItemType; + if (!mapItemTypeChanges.firstChange && mapItemTypeChanges.currentValue !== mapItemTypeChanges.previousValue) { + this.updatedValidators(true); + } + } + } + + registerOnChange(fn: any) { + this.propagateChange = fn; + } + + registerOnTouched(_fn: any) { + } + + setDisabledState(isDisabled: boolean) { + if (isDisabled) { + this.tooltipsForm.disable({emitEvent: false}); + } else { + this.tooltipsForm.enable({emitEvent: false}); + } + } + + writeValue(obj: MapItemTooltips) { + this.modelValue = obj; + this.tooltipsForm.patchValue(obj, {emitEvent: false}); + this.updatedValidators(); + } + + private updatedValidators(emitNewValue = false) { + this.tooltipsForm.disable({emitEvent: false}); + switch (this.mapItemType) { + case MapItemType.marker: + this.tooltipsForm.get('placeMarker').enable({emitEvent: false}); + break; + case MapItemType.rectangle: + this.tooltipsForm.get('startRect').enable({emitEvent: false}); + this.tooltipsForm.get('finishRect').enable({emitEvent: false}); + break; + case MapItemType.polygon: + this.tooltipsForm.get('firstVertex').enable({emitEvent: false}); + this.tooltipsForm.get('continueLine').enable({emitEvent: false}); + this.tooltipsForm.get('finishPoly').enable({emitEvent: false}); + break; + case MapItemType.circle: + this.tooltipsForm.get('startCircle').enable({emitEvent: false}); + this.tooltipsForm.get('finishCircle').enable({emitEvent: false}); + break; + } + this.tooltipsForm.updateValueAndValidity({emitEvent: emitNewValue}) + } + + private updatedModel(value: MapItemTooltips) { + const currentValue = Object.fromEntries(Object.entries(deepTrim(value)).filter(([_, v]) => v != '')); + if (!isEqual(currentValue, this.modelValue)) { + this.modelValue = currentValue; + this.propagateChange(currentValue); + } + } +} diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/action/widget-action.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/action/widget-action.component.html index 5c735010f1..a58b58bb45 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/action/widget-action.component.html +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/action/widget-action.component.html @@ -270,6 +270,10 @@
+ + -
-
widgets.maps.data-layer.fill-color
- +
+
+
{{ 'widgets.maps.data-layer.shape.fill' | translate }}
+ + {{ 'widgets.maps.data-layer.shape.fill-type-color' | translate }} + {{ 'widgets.maps.data-layer.shape.fill-type-stripe' | translate }} + {{ 'widgets.maps.data-layer.shape.fill-type-image' | translate }} + +
+
+
widgets.maps.data-layer.shape.color
+ +
+
+
widgets.maps.data-layer.shape.stripe-pattern
+ + +
+
+
widgets.maps.data-layer.shape.image
+ +
-
widgets.maps.data-layer.stroke
+
widgets.maps.data-layer.shape.stroke
diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/map-data-layer-dialog.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/map-data-layer-dialog.component.ts index e7e11284eb..48854adc44 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/map-data-layer-dialog.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/map-data-layer-dialog.component.ts @@ -30,7 +30,7 @@ import { pathDecoratorSymbols, pathDecoratorSymbolTranslationMap, PolygonsDataLayerSettings, - ShapeDataLayerSettings, + ShapeDataLayerSettings, ShapeFillType, TripsDataLayerSettings, updateDataKeyToNewDsType } from '@shared/models/widget/maps/map.models'; @@ -78,6 +78,8 @@ export class MapDataLayerDialogComponent extends DialogComponent = []; datasourceTypesTranslations = datasourceTypeTranslationMap; @@ -266,7 +268,10 @@ export class MapDataLayerDialogComponent extends DialogComponent + this.updateValidators() + ); break; } this.dataLayerFormGroup.get('dsType').valueChanges.pipe( @@ -349,8 +359,9 @@ export class MapDataLayerDialogComponent extends DialogComponent - {{ 'widgets.maps.overlays.trips' | translate }} - {{ 'widgets.maps.overlays.markers' | translate }} - {{ 'widgets.maps.overlays.polygons' | translate }} - {{ 'widgets.maps.overlays.circles' | translate }} + {{ 'widgets.maps.overlays.trips' | translate }} + {{ 'widgets.maps.overlays.markers' | translate }} + {{ 'widgets.maps.overlays.polygons' | translate }} + {{ 'widgets.maps.overlays.circles' | translate }}
+
+
widgets.maps.data-layer.shape.fill-image
+
+ + + {{ 'widgets.maps.data-layer.shape.fill-image-type-image' | translate }} + + + {{ 'widgets.maps.data-layer.shape.fill-image-type-function' | translate }} + + +
+
+
+ +
+
widgets.maps.data-layer.shape.opacity
+ + + +
+
+
widgets.maps.data-layer.shape.angle
+ + +
deg
+
+
+
+
widgets.maps.data-layer.shape.scale
+ + + +
+
+
+
+
+ + + + +
+
+
+ + + +
+
diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/shape-fill-image-settings-panel.component.scss b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/shape-fill-image-settings-panel.component.scss new file mode 100644 index 0000000000..5c127549db --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/shape-fill-image-settings-panel.component.scss @@ -0,0 +1,54 @@ +/** + * Copyright © 2016-2025 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +@import '../../../../../../../../../scss/constants'; + +.tb-shape-fill-image-settings-panel { + width: 700px; + max-width: 90vw; + min-height: 300px; + max-height: 90vh; + display: flex; + flex-direction: column; + gap: 16px; + @media #{$mat-xs} { + width: 90vw; + } + .tb-shape-fill-image-settings-title { + font-size: 16px; + font-weight: 500; + line-height: 24px; + letter-spacing: 0.25px; + color: rgba(0, 0, 0, 0.87); + } + .tb-form-row { + height: auto; + } + .tb-shape-fill-image-settings-panel-body { + flex: 1; + display: flex; + flex-direction: column; + min-height: 0; + overflow: auto; + } + .tb-shape-fill-image-settings-panel-buttons { + height: 40px; + display: flex; + flex-direction: row; + gap: 16px; + justify-content: flex-end; + align-items: flex-end; + } +} diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/shape-fill-image-settings-panel.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/shape-fill-image-settings-panel.component.ts new file mode 100644 index 0000000000..0d14c5f81c --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/shape-fill-image-settings-panel.component.ts @@ -0,0 +1,101 @@ +/// +/// Copyright © 2016-2025 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +/// See the License for the specific language governing permissions and +/// limitations under the License. +/// + +import { Component, DestroyRef, EventEmitter, Input, OnInit, Output, ViewEncapsulation } from '@angular/core'; +import { TbPopoverComponent } from '@shared/components/popover.component'; +import { UntypedFormBuilder, UntypedFormGroup, Validators } from '@angular/forms'; +import { WidgetService } from '@core/http/widget.service'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; +import { ShapeFillImageSettings, ShapeFillImageType } from '@shared/models/widget/maps/map.models'; + +@Component({ + selector: 'tb-shape-fill-image-settings-panel', + templateUrl: './shape-fill-image-settings-panel.component.html', + providers: [], + styleUrls: ['./shape-fill-image-settings-panel.component.scss'], + encapsulation: ViewEncapsulation.None +}) +export class ShapeFillImageSettingsPanelComponent implements OnInit { + + @Input() + shapeFillImageSettings: ShapeFillImageSettings; + + @Output() + shapeFillImageSettingsApplied = new EventEmitter(); + + ShapeFillImageType = ShapeFillImageType; + + shapeFillImageSettingsFormGroup: UntypedFormGroup; + + functionScopeVariables = this.widgetService.getWidgetScopeVariables(); + + constructor(private fb: UntypedFormBuilder, + private popover: TbPopoverComponent, + private widgetService: WidgetService, + private destroyRef: DestroyRef) { + } + + ngOnInit(): void { + this.shapeFillImageSettingsFormGroup = this.fb.group( + { + type: [this.shapeFillImageSettings?.type || ShapeFillImageType.image, []], + image: [this.shapeFillImageSettings?.image, [Validators.required]], + opacity: [this.shapeFillImageSettings?.opacity, [Validators.min(0), Validators.max(1)]], + angle: [this.shapeFillImageSettings?.angle, [Validators.min(0), Validators.max(360)]], + scale: [this.shapeFillImageSettings?.scale, [Validators.min(0)]], + imageFunction: [this.shapeFillImageSettings?.imageFunction, [Validators.required]], + images: [this.shapeFillImageSettings?.images, []] + } + ); + this.shapeFillImageSettingsFormGroup.get('type').valueChanges.pipe( + takeUntilDestroyed(this.destroyRef) + ).subscribe(() => { + this.updateValidators(); + setTimeout(() => {this.popover?.updatePosition();}, 0); + }); + this.updateValidators(); + } + + cancel() { + this.popover?.hide(); + } + + applyShapeFillImageSettings() { + const shapeFillImageSettings: ShapeFillImageSettings = this.shapeFillImageSettingsFormGroup.value; + this.shapeFillImageSettingsApplied.emit(shapeFillImageSettings); + this.popover?.hide(); + } + + private updateValidators() { + const type: ShapeFillImageType = this.shapeFillImageSettingsFormGroup.get('type').value; + if (type === ShapeFillImageType.image) { + this.shapeFillImageSettingsFormGroup.get('image').enable({emitEvent: false}); + this.shapeFillImageSettingsFormGroup.get('opacity').enable({emitEvent: false}); + this.shapeFillImageSettingsFormGroup.get('angle').enable({emitEvent: false}); + this.shapeFillImageSettingsFormGroup.get('scale').enable({emitEvent: false}); + this.shapeFillImageSettingsFormGroup.get('imageFunction').disable({emitEvent: false}); + this.shapeFillImageSettingsFormGroup.get('images').disable({emitEvent: false}); + } else { + this.shapeFillImageSettingsFormGroup.get('image').disable({emitEvent: false}); + this.shapeFillImageSettingsFormGroup.get('opacity').disable({emitEvent: false}); + this.shapeFillImageSettingsFormGroup.get('angle').disable({emitEvent: false}); + this.shapeFillImageSettingsFormGroup.get('scale').disable({emitEvent: false}); + this.shapeFillImageSettingsFormGroup.get('imageFunction').enable({emitEvent: false}); + this.shapeFillImageSettingsFormGroup.get('images').enable({emitEvent: false}); + } + } + +} diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/shape-fill-image-settings.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/shape-fill-image-settings.component.html new file mode 100644 index 0000000000..da4feaccf2 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/shape-fill-image-settings.component.html @@ -0,0 +1,29 @@ + + + + + diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/shape-fill-image-settings.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/shape-fill-image-settings.component.ts new file mode 100644 index 0000000000..5f707abced --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/shape-fill-image-settings.component.ts @@ -0,0 +1,96 @@ +/// +/// Copyright © 2016-2025 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +/// See the License for the specific language governing permissions and +/// limitations under the License. +/// + +import { ChangeDetectorRef, Component, forwardRef, Input, Renderer2, ViewContainerRef } from '@angular/core'; +import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms'; +import { MatButton } from '@angular/material/button'; +import { TbPopoverService } from '@shared/components/popover.service'; +import { ShapeFillImageSettings, ShapeFillImageType } from '@shared/models/widget/maps/map.models'; +import { + ShapeFillImageSettingsPanelComponent +} from '@home/components/widget/lib/settings/common/map/shape-fill-image-settings-panel.component'; + +@Component({ + selector: 'tb-shape-fill-image-settings', + templateUrl: './shape-fill-image-settings.component.html', + styleUrls: [], + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => ShapeFillImageSettingsComponent), + multi: true + } + ] +}) +export class ShapeFillImageSettingsComponent implements ControlValueAccessor { + + @Input() + disabled: boolean; + + ShapeFillImageType = ShapeFillImageType; + + modelValue: ShapeFillImageSettings; + + private propagateChange: (v: any) => void = () => { }; + + constructor(private popoverService: TbPopoverService, + private renderer: Renderer2, + private cd: ChangeDetectorRef, + private viewContainerRef: ViewContainerRef) {} + + registerOnChange(fn: any): void { + this.propagateChange = fn; + } + + registerOnTouched(_fn: any): void { + } + + setDisabledState(isDisabled: boolean): void { + this.disabled = isDisabled; + } + + writeValue(value: ShapeFillImageSettings): void { + if (value) { + this.modelValue = value; + } + } + + openImageSettingsPopup($event: Event, matButton: MatButton) { + if ($event) { + $event.stopPropagation(); + } + const trigger = matButton._elementRef.nativeElement; + if (this.popoverService.hasPopover(trigger)) { + this.popoverService.hidePopover(trigger); + } else { + this.popoverService.displayPopover({ + trigger, + renderer: this.renderer, + componentType: ShapeFillImageSettingsPanelComponent, + hostView: this.viewContainerRef, + preferredPlacement: 'left', + context: { + shapeFillImageSettings: this.modelValue, + }, + isModal: true + }).tbComponentRef.instance.shapeFillImageSettingsApplied.subscribe((shapeFillImageSettings) => { + this.modelValue = shapeFillImageSettings; + this.propagateChange(this.modelValue); + this.cd.detectChanges(); + }); + } + } +} diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/shape-fill-stripe-settings-panel.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/shape-fill-stripe-settings-panel.component.html new file mode 100644 index 0000000000..c4b931dafd --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/shape-fill-stripe-settings-panel.component.html @@ -0,0 +1,84 @@ + +
+
widgets.maps.data-layer.shape.stripe-pattern
+
+
+
+
widgets.maps.data-layer.shape.first-stripe
+
+ + + px + + +
+
+
+
widgets.maps.data-layer.shape.second-stripe
+
+ + + px + + +
+
+
+
widgets.maps.data-layer.shape.angle
+ + +
deg
+
+
+
+
+ widgets.background.preview +
+
+
+
+
+
+
+ + + +
+
diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/shape-fill-stripe-settings-panel.component.scss b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/shape-fill-stripe-settings-panel.component.scss new file mode 100644 index 0000000000..5f3af9894b --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/shape-fill-stripe-settings-panel.component.scss @@ -0,0 +1,80 @@ +/** + * Copyright © 2016-2025 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +@import '../../../../../../../../../scss/constants'; + +.tb-shape-fill-stripe-settings-panel { + width: 700px; + max-width: 90vw; + min-height: 300px; + max-height: 90vh; + display: flex; + flex-direction: column; + gap: 16px; + @media #{$mat-xs} { + width: 90vw; + } + .tb-shape-fill-stripe-settings-title { + font-size: 16px; + font-weight: 500; + line-height: 24px; + letter-spacing: 0.25px; + color: rgba(0, 0, 0, 0.87); + } + .tb-form-row { + height: auto; + } + .tb-shape-fill-stripe-settings-panel-body { + flex: 1; + display: flex; + flex-direction: column; + min-height: 0; + overflow: auto; + } + .tb-shape-fill-stripe-settings-preview { + flex: 1; + background: #fff; + border-radius: 4px; + border: 1px solid rgba(0, 0, 0, 0.12); + display: flex; + flex-direction: column; + padding: 12px 16px 24px 16px; + align-items: center; + gap: 12px; + } + .tb-shape-fill-stripe-settings-preview-title { + align-self: stretch; + font-size: 16px; + font-style: normal; + font-weight: 500; + line-height: 24px; + color: rgba(0, 0, 0, 0.38); + } + .tb-shape-fill-stripe-settings-preview-box { + position: relative; + width: 136px; + height: 118px; + border-radius: 2.666px; + } + + .tb-shape-fill-stripe-settings-panel-buttons { + height: 40px; + display: flex; + flex-direction: row; + gap: 16px; + justify-content: flex-end; + align-items: flex-end; + } +} diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/shape-fill-stripe-settings-panel.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/shape-fill-stripe-settings-panel.component.ts new file mode 100644 index 0000000000..c9df56338b --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/shape-fill-stripe-settings-panel.component.ts @@ -0,0 +1,106 @@ +/// +/// Copyright © 2016-2025 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +/// See the License for the specific language governing permissions and +/// limitations under the License. +/// + +import { Component, DestroyRef, EventEmitter, Input, OnInit, Output, ViewEncapsulation } from '@angular/core'; +import { TbPopoverComponent } from '@shared/components/popover.component'; +import { UntypedFormBuilder, UntypedFormGroup, Validators } from '@angular/forms'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; +import { MapDataLayerType, ShapeFillStripeSettings } from '@shared/models/widget/maps/map.models'; +import { DomSanitizer } from '@angular/platform-browser'; +import { + generateStripePreviewUrl +} from '@home/components/widget/lib/settings/common/map/shape-fill-stripe-settings.component'; +import { ComponentStyle } from '@shared/models/widget-settings.models'; +import { MapSettingsContext } from '@home/components/widget/lib/settings/common/map/map-settings.component.models'; +import { DatasourceType } from '@shared/models/widget.models'; + +@Component({ + selector: 'tb-shape-fill-stripe-settings-panel', + templateUrl: './shape-fill-stripe-settings-panel.component.html', + providers: [], + styleUrls: ['./shape-fill-stripe-settings-panel.component.scss'], + encapsulation: ViewEncapsulation.None +}) +export class ShapeFillStripeSettingsPanelComponent implements OnInit { + + @Input() + shapeFillStripeSettings: ShapeFillStripeSettings; + + @Input() + context: MapSettingsContext; + + @Input() + dsType: DatasourceType; + + @Input() + dsEntityAliasId: string; + + @Input() + dsDeviceId: string; + + @Input() + dataLayerType: MapDataLayerType; + + @Output() + shapeFillStripeSettingsApplied = new EventEmitter(); + + stripePreviewStyle: ComponentStyle; + + shapeFillStripeSettingsFormGroup: UntypedFormGroup; + + constructor(private fb: UntypedFormBuilder, + private sanitizer: DomSanitizer, + private popover: TbPopoverComponent, + private destroyRef: DestroyRef) { + } + + ngOnInit(): void { + this.shapeFillStripeSettingsFormGroup = this.fb.group( + { + weight: [this.shapeFillStripeSettings?.weight, [Validators.min(0)]], + color: [this.shapeFillStripeSettings?.color, []], + spaceWeight: [this.shapeFillStripeSettings?.spaceWeight, [Validators.min(0)]], + spaceColor: [this.shapeFillStripeSettings?.spaceColor, []], + angle: [this.shapeFillStripeSettings?.angle, [Validators.min(0), Validators.max(180)]] + } + ); + this.shapeFillStripeSettingsFormGroup.valueChanges.pipe( + takeUntilDestroyed(this.destroyRef) + ).subscribe(() => { + this.updatePreview(); + }); + this.updatePreview(); + } + + cancel() { + this.popover?.hide(); + } + + applyShapeFillStripeSettings() { + const shapeFillStripeSettings: ShapeFillStripeSettings = this.shapeFillStripeSettingsFormGroup.value; + this.shapeFillStripeSettingsApplied.emit(shapeFillStripeSettings); + this.popover?.hide(); + } + + private updatePreview() { + const shapeFillStripeSettings: ShapeFillStripeSettings = this.shapeFillStripeSettingsFormGroup.value; + const previewUrl = generateStripePreviewUrl(shapeFillStripeSettings); + this.stripePreviewStyle = { + background: this.sanitizer.bypassSecurityTrustStyle(`url(${previewUrl}) no-repeat 50% 50% / cover`) + }; + } + +} diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/shape-fill-stripe-settings.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/shape-fill-stripe-settings.component.html new file mode 100644 index 0000000000..2188e97ae6 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/shape-fill-stripe-settings.component.html @@ -0,0 +1,27 @@ + + diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/shape-fill-stripe-settings.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/shape-fill-stripe-settings.component.ts new file mode 100644 index 0000000000..63b2b4b212 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/shape-fill-stripe-settings.component.ts @@ -0,0 +1,148 @@ +/// +/// Copyright © 2016-2025 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +/// See the License for the specific language governing permissions and +/// limitations under the License. +/// + +import { ChangeDetectorRef, Component, forwardRef, Input, Renderer2, ViewContainerRef } from '@angular/core'; +import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms'; +import { MatButton } from '@angular/material/button'; +import { TbPopoverService } from '@shared/components/popover.service'; +import { MapDataLayerType, ShapeFillStripeSettings } from '@shared/models/widget/maps/map.models'; +import { DomSanitizer, SafeUrl } from '@angular/platform-browser'; +import { isDefinedAndNotNull, stringToBase64 } from '@core/utils'; +import { MapSettingsContext } from '@home/components/widget/lib/settings/common/map/map-settings.component.models'; +import { DatasourceType } from '@shared/models/widget.models'; +import { + ShapeFillStripeSettingsPanelComponent +} from '@home/components/widget/lib/settings/common/map/shape-fill-stripe-settings-panel.component'; + +@Component({ + selector: 'tb-shape-fill-stripe-settings', + templateUrl: './shape-fill-stripe-settings.component.html', + styleUrls: [], + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => ShapeFillStripeSettingsComponent), + multi: true + } + ] +}) +export class ShapeFillStripeSettingsComponent implements ControlValueAccessor { + + @Input() + disabled: boolean; + + @Input() + context: MapSettingsContext; + + @Input() + dsType: DatasourceType; + + @Input() + dsEntityAliasId: string; + + @Input() + dsDeviceId: string; + + @Input() + dataLayerType: MapDataLayerType; + + modelValue: ShapeFillStripeSettings; + + stripePreviewUrl: SafeUrl; + + private propagateChange: (v: any) => void = () => { }; + + constructor(private popoverService: TbPopoverService, + private sanitizer: DomSanitizer, + private renderer: Renderer2, + private cd: ChangeDetectorRef, + private viewContainerRef: ViewContainerRef) {} + + registerOnChange(fn: any): void { + this.propagateChange = fn; + } + + registerOnTouched(_fn: any): void { + } + + setDisabledState(isDisabled: boolean): void { + this.disabled = isDisabled; + } + + writeValue(value: ShapeFillStripeSettings): void { + if (value) { + this.modelValue = value; + } + this.updatePreview(); + } + + openStripeSettingsPopup($event: Event, matButton: MatButton) { + if ($event) { + $event.stopPropagation(); + } + const trigger = matButton._elementRef.nativeElement; + if (this.popoverService.hasPopover(trigger)) { + this.popoverService.hidePopover(trigger); + } else { + this.popoverService.displayPopover({ + trigger, + renderer: this.renderer, + componentType: ShapeFillStripeSettingsPanelComponent, + hostView: this.viewContainerRef, + preferredPlacement: 'left', + context: { + shapeFillStripeSettings: this.modelValue, + context: this.context, + dsType: this.dsType, + dsEntityAliasId: this.dsEntityAliasId, + dsDeviceId: this.dsDeviceId, + dataLayerType: this.dataLayerType + }, + isModal: true + }).tbComponentRef.instance.shapeFillStripeSettingsApplied.subscribe((shapeFillStripeSettings) => { + this.modelValue = shapeFillStripeSettings; + this.updatePreview(); + this.propagateChange(this.modelValue); + this.cd.detectChanges(); + }); + } + } + + private updatePreview() { + this.stripePreviewUrl = this.sanitizer.bypassSecurityTrustUrl(generateStripePreviewUrl(this.modelValue)); + } +} + +export const generateStripePreviewUrl = (settings: ShapeFillStripeSettings): string => { + const weight = isDefinedAndNotNull(settings?.weight) ? settings.weight : 3; + const spaceWeight = isDefinedAndNotNull(settings?.spaceWeight) ? settings.spaceWeight : 9; + const angle = isDefinedAndNotNull(settings?.angle) ? settings.angle : 45; + const height = weight + spaceWeight; + const color = settings?.color?.color || '#8f8f8f'; + const spaceColor = settings?.spaceColor?.color || 'rgba(143,143,143,0)'; + const svgStr = ` + + + + + + + + `; + const encodedSvg = stringToBase64(svgStr); + return `data:image/svg+xml;base64,${encodedSvg}`; +} diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/widget-settings-common.module.ts b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/widget-settings-common.module.ts index 7ec4a4626d..233b202684 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/widget-settings-common.module.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/widget-settings-common.module.ts @@ -69,6 +69,9 @@ import { } from '@home/components/widget/lib/settings/common/action/set-value-action-settings-panel.component'; import { CssSizeInputComponent } from '@home/components/widget/lib/settings/common/css-size-input.component'; import { WidgetActionComponent } from '@home/components/widget/lib/settings/common/action/widget-action.component'; +import { + MapItemTooltipsComponent +} from '@home/components/widget/lib/settings/common/action/map-item-tooltips.component'; import { CustomActionPrettyResourcesTabsComponent } from '@home/components/widget/lib/settings/common/action/custom-action-pretty-resources-tabs.component'; @@ -252,6 +255,18 @@ import { MapDataSourcesComponent } from '@home/components/widget/lib/settings/co import { MapDataSourceRowComponent } from '@home/components/widget/lib/settings/common/map/map-data-source-row.component'; +import { + ShapeFillImageSettingsComponent +} from '@home/components/widget/lib/settings/common/map/shape-fill-image-settings.component'; +import { + ShapeFillImageSettingsPanelComponent +} from '@home/components/widget/lib/settings/common/map/shape-fill-image-settings-panel.component'; +import { + ShapeFillStripeSettingsComponent +} from '@home/components/widget/lib/settings/common/map/shape-fill-stripe-settings.component'; +import { + ShapeFillStripeSettingsPanelComponent +} from '@home/components/widget/lib/settings/common/map/shape-fill-stripe-settings-panel.component'; @NgModule({ declarations: [ @@ -283,6 +298,7 @@ import { SetValueActionSettingsComponent, SetValueActionSettingsPanelComponent, WidgetActionComponent, + MapItemTooltipsComponent, CustomActionPrettyResourcesTabsComponent, CustomActionPrettyEditorComponent, MobileActionEditorComponent, @@ -338,6 +354,10 @@ import { MarkerImageSettingsComponent, MarkerImageSettingsPanelComponent, MarkerClusteringSettingsComponent, + ShapeFillStripeSettingsComponent, + ShapeFillStripeSettingsPanelComponent, + ShapeFillImageSettingsComponent, + ShapeFillImageSettingsPanelComponent, MapDataLayerDialogComponent, MapDataLayerRowComponent, MapDataLayersComponent, diff --git a/ui-ngx/src/app/shared/components/color-input.component.ts b/ui-ngx/src/app/shared/components/color-input.component.ts index d0dc7235f7..77d02b47d3 100644 --- a/ui-ngx/src/app/shared/components/color-input.component.ts +++ b/ui-ngx/src/app/shared/components/color-input.component.ts @@ -201,7 +201,7 @@ export class ColorInputComponent extends PageComponent implements OnInit, Contro renderer: this.renderer, hostView: this.viewContainerRef, componentType: ColorPickerPanelComponent, - preferredPlacement: ['left'], + preferredPlacement: ['leftTopOnly', 'leftOnly', 'leftBottomOnly'], context: { color: this.colorFormGroup.get('color').value, colorClearButton: this.colorClearButton, diff --git a/ui-ngx/src/app/shared/components/string-items-list.component.html b/ui-ngx/src/app/shared/components/string-items-list.component.html index b50e0ecce8..d467fbe5f0 100644 --- a/ui-ngx/src/app/shared/components/string-items-list.component.html +++ b/ui-ngx/src/app/shared/components/string-items-list.component.html @@ -30,7 +30,7 @@ close @@ -49,12 +48,11 @@ - - - - - {{ 'common.not-found' | translate }} - + @for (value of filteredValues | async; track value.value) { + + } @empty { + {{ 'common.not-found' | translate }} + } {{ hint }} diff --git a/ui-ngx/src/app/shared/components/string-items-list.component.ts b/ui-ngx/src/app/shared/components/string-items-list.component.ts index a2fa28d0ff..5fe6f44609 100644 --- a/ui-ngx/src/app/shared/components/string-items-list.component.ts +++ b/ui-ngx/src/app/shared/components/string-items-list.component.ts @@ -30,6 +30,7 @@ import { coerceArray, coerceBoolean } from '@shared/decorators/coercion'; import { Observable, of } from 'rxjs'; import { filter, mergeMap, share, tap } from 'rxjs/operators'; import { MatAutocompleteTrigger } from '@angular/material/autocomplete'; +import { isDefined, isUndefined } from '@core/utils'; export interface StringItemsOption { name: string; @@ -116,6 +117,13 @@ export class StringItemsListComponent implements ControlValueAccessor, OnInit { @coerceArray() predefinedValues: StringItemsOption[]; + @Input() + fetchOptionsFn: (searchText?: string) => Observable>; + + @Input() + @coerceBoolean() + allowUserValue = false; + get itemsControl(): AbstractControl { return this.stringItemsForm.get('items'); } @@ -124,7 +132,7 @@ export class StringItemsListComponent implements ControlValueAccessor, OnInit { return this.stringItemsForm.get('item'); } - onTouched = () => {}; + private onTouched = () => {}; private propagateChange: (value: any) => void = () => {}; private dirty = false; @@ -136,7 +144,7 @@ export class StringItemsListComponent implements ControlValueAccessor, OnInit { } ngOnInit() { - if (this.predefinedValues) { + if (this.predefinedValues || isDefined(this.fetchOptionsFn)) { this.filteredValues = this.itemControl.valueChanges .pipe( tap((value) => { @@ -147,7 +155,8 @@ export class StringItemsListComponent implements ControlValueAccessor, OnInit { } }), filter((value) => typeof value === 'string'), - mergeMap(name => this.fetchValues(name)), + tap(name => this.searchText = name), + mergeMap(name => this.fetchOptionsFn ? this.fetchOptionsFn(name) : this.fetchValues(name)), share() ); } @@ -180,7 +189,7 @@ export class StringItemsListComponent implements ControlValueAccessor, OnInit { if (value != null && value.length > 0) { this.modelValue = [...value]; this.itemList = []; - if (this.predefinedValues) { + if (this.predefinedValues && !this.allowUserValue) { value.forEach(item => { const findItem = this.predefinedValues.find(option => option.value === item); if (findItem) { @@ -199,19 +208,16 @@ export class StringItemsListComponent implements ControlValueAccessor, OnInit { this.dirty = true; } - addItem(event: MatChipInputEvent): void { - const item = event.value?.trim() ?? ''; - if (item) { - if (this.predefinedValues) { - const findItems = this.predefinedValues - .filter(value => value.name.toLowerCase().includes(item.toLowerCase())); - if (findItems.length === 1) { - this.add(findItems[0]); - } - } else { - this.add({value: item, name: item}); - } + addOnBlur(event: FocusEvent) { + const target: HTMLElement = event.relatedTarget as HTMLElement; + if (target && target.tagName !== 'MAT-OPTION') { + this.addItem(this.stringItemInput.nativeElement.value ?? '') } + this.onTouched(); + } + + addOnEnd(event: MatChipInputEvent): void { + this.addItem(event.value ?? '') } removeItems(item: StringItemsOption) { @@ -239,6 +245,27 @@ export class StringItemsListComponent implements ControlValueAccessor, OnInit { return values ? values.name : undefined; } + private addItem(searchText: string) { + searchText = searchText.trim(); + if (searchText) { + if (this.allowUserValue || !this.predefinedValues && isUndefined(this.fetchOptionsFn)) { + this.add({value: searchText, name: searchText}); + } else if (this.predefinedValues) { + const findItems = this.predefinedValues + .filter(value => value.name.toLowerCase().includes(searchText.toLowerCase())); + if (findItems.length === 1) { + this.add(findItems[0]); + } + } else if (isDefined(this.fetchOptionsFn)) { + this.fetchOptionsFn(searchText).subscribe((findItems) => { + if (findItems.length === 1) { + this.add(findItems[0]); + } + }) + } + } + } + private add(item: StringItemsOption) { if (!this.modelValue || this.modelValue.indexOf(item.value) === -1) { if (!this.modelValue) { @@ -256,7 +283,6 @@ export class StringItemsListComponent implements ControlValueAccessor, OnInit { if (!this.predefinedValues?.length) { return of([]); } - this.searchText = searchText; let result = this.predefinedValues; if (searchText && searchText.length) { result = this.predefinedValues.filter(option => option.name.toLowerCase().includes(searchText.toLowerCase())); diff --git a/ui-ngx/src/app/shared/components/toggle-header.component.html b/ui-ngx/src/app/shared/components/toggle-header.component.html index 9b3c387432..fdfabd08b5 100644 --- a/ui-ngx/src/app/shared/components/toggle-header.component.html +++ b/ui-ngx/src/app/shared/components/toggle-header.component.html @@ -41,7 +41,16 @@ [name]="name" [(ngModel)]="value" (ngModelChange)="valueChange.emit(value)"> - {{ option.name }} + + {{ option.name }} + + warning + +