From 7be97f379c69a73ce30d485c6736a2ac62e5e021 Mon Sep 17 00:00:00 2001 From: dashevchenko Date: Tue, 29 Jul 2025 14:30:59 +0300 Subject: [PATCH 01/43] added json timeseries type for bulk import --- .../csv/AbstractBulkImportService.java | 9 ++- .../thingsboard/server/utils/CsvUtils.java | 19 ++++++ .../controller/DeviceControllerTest.java | 60 +++++++++++++++++++ .../server/common/data/util/TypeCastUtil.java | 11 ++++ 4 files changed, 98 insertions(+), 1 deletion(-) diff --git a/application/src/main/java/org/thingsboard/server/service/sync/ie/importing/csv/AbstractBulkImportService.java b/application/src/main/java/org/thingsboard/server/service/sync/ie/importing/csv/AbstractBulkImportService.java index 9850e2d1a1..768adf98ff 100644 --- a/application/src/main/java/org/thingsboard/server/service/sync/ie/importing/csv/AbstractBulkImportService.java +++ b/application/src/main/java/org/thingsboard/server/service/sync/ie/importing/csv/AbstractBulkImportService.java @@ -17,6 +17,7 @@ package org.thingsboard.server.service.sync.ie.importing.csv; import com.fasterxml.jackson.databind.node.ObjectNode; import com.google.common.util.concurrent.FutureCallback; +import com.google.gson.JsonElement; import com.google.gson.JsonObject; import com.google.gson.JsonPrimitive; import jakarta.annotation.Nullable; @@ -183,7 +184,13 @@ public abstract class AbstractBulkImportService dataEntry.getKey().getType() == kvType && StringUtils.isNotEmpty(dataEntry.getKey().getKey())) - .forEach(dataEntry -> kvs.add(dataEntry.getKey().getKey(), dataEntry.getValue().toJsonPrimitive())); + .forEach(dataEntry -> { + ParsedValue value = dataEntry.getValue(); + JsonElement kvValue = (value.getDataType() == DataType.JSON) + ? (JsonElement) value.getValue() + : value.toJsonPrimitive(); + kvs.add(dataEntry.getKey().getKey(), kvValue); + }); return Map.entry(kvType, kvs); }) .filter(kvsEntry -> kvsEntry.getValue().entrySet().size() > 0) diff --git a/application/src/main/java/org/thingsboard/server/utils/CsvUtils.java b/application/src/main/java/org/thingsboard/server/utils/CsvUtils.java index 3f75a4918f..0fde72a3b0 100644 --- a/application/src/main/java/org/thingsboard/server/utils/CsvUtils.java +++ b/application/src/main/java/org/thingsboard/server/utils/CsvUtils.java @@ -17,10 +17,15 @@ package org.thingsboard.server.utils; import lombok.AccessLevel; import lombok.NoArgsConstructor; +import lombok.SneakyThrows; import org.apache.commons.csv.CSVFormat; +import org.apache.commons.csv.CSVPrinter; import org.apache.commons.csv.CSVRecord; import org.apache.commons.io.input.CharSequenceReader; +import java.io.ByteArrayOutputStream; +import java.io.OutputStreamWriter; +import java.nio.charset.StandardCharsets; import java.util.List; import java.util.stream.Collectors; import java.util.stream.Stream; @@ -43,4 +48,18 @@ public class CsvUtils { .collect(Collectors.toList()); } + @SneakyThrows + public static byte[] generateCsv(List> rows) { + ByteArrayOutputStream out = new ByteArrayOutputStream(); + try (OutputStreamWriter writer = new OutputStreamWriter(out, StandardCharsets.UTF_8); + CSVPrinter csvPrinter = new CSVPrinter(writer, CSVFormat.DEFAULT)) { + + for (List row : rows) { + csvPrinter.printRecord(row); + } + csvPrinter.flush(); + } + return out.toByteArray(); + } + } diff --git a/application/src/test/java/org/thingsboard/server/controller/DeviceControllerTest.java b/application/src/test/java/org/thingsboard/server/controller/DeviceControllerTest.java index 36cede9e84..6b31825640 100644 --- a/application/src/test/java/org/thingsboard/server/controller/DeviceControllerTest.java +++ b/application/src/test/java/org/thingsboard/server/controller/DeviceControllerTest.java @@ -77,8 +77,13 @@ import org.thingsboard.server.dao.service.DaoSqlTest; import org.thingsboard.server.gen.transport.TransportProtos; import org.thingsboard.server.service.gateway_device.GatewayNotificationsService; import org.thingsboard.server.service.state.DeviceStateService; +import org.thingsboard.server.utils.CsvUtils; +import java.nio.charset.StandardCharsets; import java.util.ArrayList; +import java.util.Arrays; +import java.util.LinkedHashMap; +import java.util.LinkedList; import java.util.List; import java.util.Map; import java.util.concurrent.TimeUnit; @@ -1586,6 +1591,61 @@ public class DeviceControllerTest extends AbstractControllerTest { Assert.assertEquals(newAttributeValue, actualAttribute.get("value")); } + @Test + public void testBulkImportDeviceWithJsonAttr() throws Exception { + String deviceName = "some_device"; + String deviceType = "some_type"; + JsonNode deviceAttr = JacksonUtil.toJsonNode("{\"threshold\": 45}"); + + List> content = new LinkedList<>(); + content.add(Arrays.asList("NAME", "TYPE", "ATTR")); + content.add(Arrays.asList(deviceName, deviceType, deviceAttr.toString())); + + byte[] bytes = CsvUtils.generateCsv(content); + BulkImportRequest request = new BulkImportRequest(); + request.setFile(new String(bytes, StandardCharsets.UTF_8)); + BulkImportRequest.Mapping mapping = new BulkImportRequest.Mapping(); + BulkImportRequest.ColumnMapping name = new BulkImportRequest.ColumnMapping(); + name.setType(BulkImportColumnType.NAME); + BulkImportRequest.ColumnMapping type = new BulkImportRequest.ColumnMapping(); + type.setType(BulkImportColumnType.TYPE); + BulkImportRequest.ColumnMapping attr = new BulkImportRequest.ColumnMapping(); + attr.setType(BulkImportColumnType.SERVER_ATTRIBUTE); + attr.setKey("attr"); + List columns = new ArrayList<>(); + columns.add(name); + columns.add(type); + columns.add(attr); + + mapping.setColumns(columns); + mapping.setDelimiter(','); + mapping.setUpdate(true); + mapping.setHeader(true); + request.setMapping(mapping); + + BulkImportResult deviceBulkImportResult = doPostWithTypedResponse("/api/device/bulk_import", request, new TypeReference<>() {}); + + Assert.assertEquals(1, deviceBulkImportResult.getCreated().get()); + Assert.assertEquals(0, deviceBulkImportResult.getErrors().get()); + Assert.assertEquals(0, deviceBulkImportResult.getUpdated().get()); + Assert.assertTrue(deviceBulkImportResult.getErrorsList().isEmpty()); + + Device savedDevice = doGet("/api/tenant/devices?deviceName=" + deviceName, Device.class); + + Assert.assertNotNull(savedDevice); + Assert.assertEquals(deviceName, savedDevice.getName()); + Assert.assertEquals(deviceType, savedDevice.getType()); + + //check server attribute value + await().atMost(TIMEOUT, TimeUnit.SECONDS).untilAsserted(() -> { + Map actualAttribute = doGetAsyncTyped("/api/plugins/telemetry/DEVICE/" + savedDevice.getId() + + "/values/attributes/SERVER_SCOPE", new TypeReference>>() {}).stream() + .filter(att -> att.get("key").equals("attr")).findFirst().get(); + LinkedHashMap expected = JacksonUtil.convertValue(deviceAttr, new TypeReference<>() {}); + Assert.assertEquals(expected, actualAttribute.get("value")); + }); + } + @Test public void testSaveDeviceWithOutdatedVersion() throws Exception { Device device = createDevice("Device v1.0"); diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/util/TypeCastUtil.java b/common/data/src/main/java/org/thingsboard/server/common/data/util/TypeCastUtil.java index 85071b3cc9..94a1ee0ef4 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/util/TypeCastUtil.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/util/TypeCastUtil.java @@ -15,6 +15,7 @@ */ package org.thingsboard.server.common.data.util; +import com.google.gson.JsonParser; import org.apache.commons.lang3.math.NumberUtils; import org.apache.commons.lang3.tuple.Pair; import org.thingsboard.server.common.data.kv.DataType; @@ -40,6 +41,11 @@ public class TypeCastUtil { } catch (RuntimeException ignored) {} } else if (value.equalsIgnoreCase("true") || value.equalsIgnoreCase("false")) { return Pair.of(DataType.BOOLEAN, Boolean.parseBoolean(value)); + } else if (looksLikeJson(value)) { + try { + return Pair.of(DataType.JSON, JsonParser.parseString(value)); + } catch (Exception ignored) { + } } return Pair.of(DataType.STRING, value); } @@ -70,4 +76,9 @@ public class TypeCastUtil { return valueAsString.contains(".") && !valueAsString.contains("E") && !valueAsString.contains("e"); } + private static boolean looksLikeJson(String value) { + return (value.startsWith("{") && value.endsWith("}")) || + (value.startsWith("[") && value.endsWith("]")); + } + } From 6ae8cdf62389d7505c79110036558c9a3191c478 Mon Sep 17 00:00:00 2001 From: dashevchenko Date: Wed, 30 Jul 2025 18:38:00 +0300 Subject: [PATCH 02/43] added trim to json parsing, updated test --- .../controller/DeviceControllerTest.java | 19 ++++++++----------- .../server/common/data/util/TypeCastUtil.java | 5 +++-- 2 files changed, 11 insertions(+), 13 deletions(-) diff --git a/application/src/test/java/org/thingsboard/server/controller/DeviceControllerTest.java b/application/src/test/java/org/thingsboard/server/controller/DeviceControllerTest.java index 6b31825640..6ac9e5526a 100644 --- a/application/src/test/java/org/thingsboard/server/controller/DeviceControllerTest.java +++ b/application/src/test/java/org/thingsboard/server/controller/DeviceControllerTest.java @@ -38,6 +38,7 @@ import org.springframework.test.context.ContextConfiguration; import org.testcontainers.shaded.org.awaitility.Awaitility; import org.thingsboard.common.util.JacksonUtil; import org.thingsboard.common.util.ThingsBoardExecutors; +import org.thingsboard.server.common.data.AttributeScope; import org.thingsboard.server.common.data.Customer; import org.thingsboard.server.common.data.Device; import org.thingsboard.server.common.data.DeviceInfo; @@ -59,6 +60,7 @@ import org.thingsboard.server.common.data.id.CustomerId; import org.thingsboard.server.common.data.id.DeviceCredentialsId; import org.thingsboard.server.common.data.id.DeviceId; import org.thingsboard.server.common.data.id.EntityId; +import org.thingsboard.server.common.data.kv.AttributeKvEntry; import org.thingsboard.server.common.data.page.PageData; import org.thingsboard.server.common.data.page.PageLink; import org.thingsboard.server.common.data.relation.EntityRelation; @@ -82,10 +84,10 @@ import org.thingsboard.server.utils.CsvUtils; import java.nio.charset.StandardCharsets; import java.util.ArrayList; import java.util.Arrays; -import java.util.LinkedHashMap; import java.util.LinkedList; import java.util.List; import java.util.Map; +import java.util.Optional; import java.util.concurrent.TimeUnit; import static org.assertj.core.api.Assertions.assertThat; @@ -1595,11 +1597,11 @@ public class DeviceControllerTest extends AbstractControllerTest { public void testBulkImportDeviceWithJsonAttr() throws Exception { String deviceName = "some_device"; String deviceType = "some_type"; - JsonNode deviceAttr = JacksonUtil.toJsonNode("{\"threshold\": 45}"); + String deviceAttr = "{\"threshold\":45}"; List> content = new LinkedList<>(); content.add(Arrays.asList("NAME", "TYPE", "ATTR")); - content.add(Arrays.asList(deviceName, deviceType, deviceAttr.toString())); + content.add(Arrays.asList(deviceName, deviceType, deviceAttr)); byte[] bytes = CsvUtils.generateCsv(content); BulkImportRequest request = new BulkImportRequest(); @@ -1636,14 +1638,9 @@ public class DeviceControllerTest extends AbstractControllerTest { Assert.assertEquals(deviceName, savedDevice.getName()); Assert.assertEquals(deviceType, savedDevice.getType()); - //check server attribute value - await().atMost(TIMEOUT, TimeUnit.SECONDS).untilAsserted(() -> { - Map actualAttribute = doGetAsyncTyped("/api/plugins/telemetry/DEVICE/" + savedDevice.getId() + - "/values/attributes/SERVER_SCOPE", new TypeReference>>() {}).stream() - .filter(att -> att.get("key").equals("attr")).findFirst().get(); - LinkedHashMap expected = JacksonUtil.convertValue(deviceAttr, new TypeReference<>() {}); - Assert.assertEquals(expected, actualAttribute.get("value")); - }); + Optional retrieved = attributesService.find(tenantId, savedDevice.getId(), AttributeScope.SERVER_SCOPE, "attr").get(); + assertThat(retrieved.get().getJsonValue().get()).isEqualTo(deviceAttr); + assertThat(retrieved.get().getStrValue()).isNotPresent(); } @Test diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/util/TypeCastUtil.java b/common/data/src/main/java/org/thingsboard/server/common/data/util/TypeCastUtil.java index 94a1ee0ef4..82de579d3d 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/util/TypeCastUtil.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/util/TypeCastUtil.java @@ -77,8 +77,9 @@ public class TypeCastUtil { } private static boolean looksLikeJson(String value) { - return (value.startsWith("{") && value.endsWith("}")) || - (value.startsWith("[") && value.endsWith("]")); + String trimmed = value.trim(); + return (trimmed.startsWith("{") && trimmed.endsWith("}")) || + (trimmed.startsWith("[") && trimmed.endsWith("]")); } } From 6757f9195cb7b8227c8ff00c73918cb2ebbb5cc3 Mon Sep 17 00:00:00 2001 From: Yevhenii Date: Thu, 11 Sep 2025 15:43:18 +0300 Subject: [PATCH 03/43] Added AiModel sync for Edge --- .../service/edge/EdgeContextComponent.java | 7 + .../service/edge/EdgeMsgConstructorUtils.java | 16 ++ .../service/edge/rpc/EdgeGrpcSession.java | 6 + .../edge/rpc/processor/BaseEdgeProcessor.java | 2 +- .../processor/ai/AiModelEdgeProcessor.java | 168 ++++++++++++++++++ .../rpc/processor/ai/AiModelProcessor.java | 28 +++ .../processor/ai/BaseAiModelProcessor.java | 81 +++++++++ .../server/edge/imitator/EdgeImitator.java | 6 + .../server/dao/ai/AiModelService.java | 4 + .../common/data/edge/EdgeEventType.java | 3 +- .../thingsboard/edge/rpc/EdgeGrpcClient.java | 2 +- common/edge-api/src/main/proto/edge.proto | 9 + .../server/dao/ai/AiModelServiceImpl.java | 14 +- 13 files changed, 342 insertions(+), 4 deletions(-) create mode 100644 application/src/main/java/org/thingsboard/server/service/edge/rpc/processor/ai/AiModelEdgeProcessor.java create mode 100644 application/src/main/java/org/thingsboard/server/service/edge/rpc/processor/ai/AiModelProcessor.java create mode 100644 application/src/main/java/org/thingsboard/server/service/edge/rpc/processor/ai/BaseAiModelProcessor.java diff --git a/application/src/main/java/org/thingsboard/server/service/edge/EdgeContextComponent.java b/application/src/main/java/org/thingsboard/server/service/edge/EdgeContextComponent.java index 5c5eea32fd..c6bfef4971 100644 --- a/application/src/main/java/org/thingsboard/server/service/edge/EdgeContextComponent.java +++ b/application/src/main/java/org/thingsboard/server/service/edge/EdgeContextComponent.java @@ -24,6 +24,7 @@ import org.thingsboard.server.cache.limits.RateLimitService; import org.thingsboard.server.cluster.TbClusterService; import org.thingsboard.server.common.data.edge.EdgeEventType; import org.thingsboard.server.common.msg.notification.NotificationRuleProcessor; +import org.thingsboard.server.dao.ai.AiModelService; import org.thingsboard.server.dao.alarm.AlarmCommentService; import org.thingsboard.server.dao.alarm.AlarmService; import org.thingsboard.server.dao.asset.AssetProfileService; @@ -59,6 +60,7 @@ import org.thingsboard.server.queue.util.TbCoreComponent; import org.thingsboard.server.service.edge.rpc.EdgeEventStorageSettings; import org.thingsboard.server.service.edge.rpc.EdgeRpcService; import org.thingsboard.server.service.edge.rpc.processor.EdgeProcessor; +import org.thingsboard.server.service.edge.rpc.processor.ai.AiModelProcessor; import org.thingsboard.server.service.edge.rpc.processor.alarm.AlarmProcessor; import org.thingsboard.server.service.edge.rpc.processor.alarm.comment.AlarmCommentProcessor; import org.thingsboard.server.service.edge.rpc.processor.asset.AssetEdgeProcessor; @@ -261,6 +263,11 @@ public class EdgeContextComponent { @Autowired private CalculatedFieldProcessor calculatedFieldProcessor; + @Autowired + private AiModelService aiModelService; + @Autowired + private AiModelProcessor aiModelProcessor; + public EdgeProcessor getProcessor(EdgeEventType edgeEventType) { EdgeProcessor processor = processorMap.get(edgeEventType); if (processor == null) { 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 192c56692d..505b03a1cd 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 @@ -44,6 +44,7 @@ import org.thingsboard.server.common.data.TbResource; import org.thingsboard.server.common.data.Tenant; import org.thingsboard.server.common.data.TenantProfile; import org.thingsboard.server.common.data.User; +import org.thingsboard.server.common.data.ai.AiModel; import org.thingsboard.server.common.data.alarm.Alarm; import org.thingsboard.server.common.data.alarm.AlarmComment; import org.thingsboard.server.common.data.asset.Asset; @@ -52,6 +53,7 @@ import org.thingsboard.server.common.data.cf.CalculatedField; import org.thingsboard.server.common.data.domain.DomainInfo; import org.thingsboard.server.common.data.edge.Edge; import org.thingsboard.server.common.data.edge.EdgeEventActionType; +import org.thingsboard.server.common.data.id.AiModelId; import org.thingsboard.server.common.data.id.AssetId; import org.thingsboard.server.common.data.id.AssetProfileId; import org.thingsboard.server.common.data.id.CalculatedFieldId; @@ -86,6 +88,7 @@ import org.thingsboard.server.common.data.security.DeviceCredentials; import org.thingsboard.server.common.data.security.UserCredentials; import org.thingsboard.server.common.data.widget.WidgetTypeDetails; import org.thingsboard.server.common.data.widget.WidgetsBundle; +import org.thingsboard.server.gen.edge.v1.AiModelUpdateMsg; import org.thingsboard.server.gen.edge.v1.AlarmCommentUpdateMsg; import org.thingsboard.server.gen.edge.v1.AlarmUpdateMsg; import org.thingsboard.server.gen.edge.v1.AssetProfileUpdateMsg; @@ -654,4 +657,17 @@ public class EdgeMsgConstructorUtils { .setIdLSB(calculatedFieldId.getId().getLeastSignificantBits()).build(); } + public static AiModelUpdateMsg constructAiModelUpdatedMsg(UpdateMsgType msgType, AiModel aiModel) { + return AiModelUpdateMsg.newBuilder().setMsgType(msgType).setEntity(JacksonUtil.toString(aiModel)) + .setIdMSB(aiModel.getId().getId().getMostSignificantBits()) + .setIdLSB(aiModel.getId().getId().getLeastSignificantBits()).build(); + } + + public static AiModelUpdateMsg constructAiModelDeleteMsg(AiModelId aiModelId) { + return AiModelUpdateMsg.newBuilder() + .setMsgType(UpdateMsgType.ENTITY_DELETED_RPC_MESSAGE) + .setIdMSB(aiModelId.getId().getMostSignificantBits()) + .setIdLSB(aiModelId.getId().getLeastSignificantBits()).build(); + } + } 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 521730741f..f1ee280ee6 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 @@ -46,6 +46,7 @@ import org.thingsboard.server.common.data.page.PageLink; import org.thingsboard.server.common.data.page.TimePageLink; import org.thingsboard.server.common.msg.edge.EdgeEventUpdateMsg; import org.thingsboard.server.dao.edge.stats.EdgeStatsKey; +import org.thingsboard.server.gen.edge.v1.AiModelUpdateMsg; import org.thingsboard.server.gen.edge.v1.AlarmCommentUpdateMsg; import org.thingsboard.server.gen.edge.v1.AlarmUpdateMsg; import org.thingsboard.server.gen.edge.v1.AssetProfileUpdateMsg; @@ -933,6 +934,11 @@ public abstract class EdgeGrpcSession implements Closeable { result.add(ctx.getCalculatedFieldProcessor().processCalculatedFieldMsgFromEdge(edge.getTenantId(), edge, calculatedFieldUpdateMsg)); } } + if (uplinkMsg.getAiModelUpdateMsgCount() > 0) { + for (AiModelUpdateMsg aiModelUpdateMsg : uplinkMsg.getAiModelUpdateMsgList()) { + result.add(ctx.getAiModelProcessor().processAiModelMsgFromEdge(edge.getTenantId(), edge, aiModelUpdateMsg)); + } + } } catch (Exception e) { String failureMsg = String.format("Can't process uplink msg [%s] from edge", uplinkMsg); log.trace("[{}][{}] Can't process uplink msg [{}]", tenantId, edge.getId(), uplinkMsg, e); diff --git a/application/src/main/java/org/thingsboard/server/service/edge/rpc/processor/BaseEdgeProcessor.java b/application/src/main/java/org/thingsboard/server/service/edge/rpc/processor/BaseEdgeProcessor.java index a2243a88d2..d6bfeae0b7 100644 --- a/application/src/main/java/org/thingsboard/server/service/edge/rpc/processor/BaseEdgeProcessor.java +++ b/application/src/main/java/org/thingsboard/server/service/edge/rpc/processor/BaseEdgeProcessor.java @@ -139,7 +139,7 @@ public abstract class BaseEdgeProcessor implements EdgeProcessor { UPDATED_COMMENT, DELETED -> true; default -> switch (type) { case ALARM, ALARM_COMMENT, RULE_CHAIN, RULE_CHAIN_METADATA, USER, CUSTOMER, TENANT, TENANT_PROFILE, - WIDGETS_BUNDLE, WIDGET_TYPE, ADMIN_SETTINGS, OTA_PACKAGE, QUEUE, RELATION, CALCULATED_FIELD, NOTIFICATION_TEMPLATE, + WIDGETS_BUNDLE, WIDGET_TYPE, ADMIN_SETTINGS, OTA_PACKAGE, QUEUE, RELATION, CALCULATED_FIELD, AI_MODEL, NOTIFICATION_TEMPLATE, NOTIFICATION_TARGET, NOTIFICATION_RULE -> true; default -> false; }; diff --git a/application/src/main/java/org/thingsboard/server/service/edge/rpc/processor/ai/AiModelEdgeProcessor.java b/application/src/main/java/org/thingsboard/server/service/edge/rpc/processor/ai/AiModelEdgeProcessor.java new file mode 100644 index 0000000000..aaa1f05aa7 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/edge/rpc/processor/ai/AiModelEdgeProcessor.java @@ -0,0 +1,168 @@ +/** + * 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.rpc.processor.ai; + +import com.google.common.util.concurrent.Futures; +import com.google.common.util.concurrent.ListenableFuture; +import lombok.extern.slf4j.Slf4j; +import org.springframework.data.util.Pair; +import org.springframework.stereotype.Component; +import org.thingsboard.common.util.JacksonUtil; +import org.thingsboard.server.common.data.EdgeUtils; +import org.thingsboard.server.common.data.ai.AiModel; +import org.thingsboard.server.common.data.edge.Edge; +import org.thingsboard.server.common.data.edge.EdgeEvent; +import org.thingsboard.server.common.data.edge.EdgeEventActionType; +import org.thingsboard.server.common.data.edge.EdgeEventType; +import org.thingsboard.server.common.data.id.AiModelId; +import org.thingsboard.server.common.data.id.TenantId; +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.AiModelUpdateMsg; +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; + +import java.util.Optional; +import java.util.UUID; + +@Slf4j +@Component +@TbCoreComponent +public class AiModelEdgeProcessor extends BaseAiModelProcessor implements AiModelProcessor { + + @Override + public ListenableFuture processAiModelMsgFromEdge(TenantId tenantId, Edge edge, AiModelUpdateMsg aiModelUpdateMsg) { + AiModelId aiModelId = new AiModelId(new UUID(aiModelUpdateMsg.getIdMSB(), aiModelUpdateMsg.getIdLSB())); + try { + edgeSynchronizationManager.getEdgeId().set(edge.getId()); + + switch (aiModelUpdateMsg.getMsgType()) { + case ENTITY_CREATED_RPC_MESSAGE: + case ENTITY_UPDATED_RPC_MESSAGE: + processAiModel(tenantId, aiModelId, aiModelUpdateMsg, edge); + return Futures.immediateFuture(null); + case ENTITY_DELETED_RPC_MESSAGE: + Optional aiModel = edgeCtx.getAiModelService().findAiModelById(tenantId, aiModelId); + if (aiModel.isPresent()) { + edgeCtx.getAiModelService().deleteByTenantIdAndId(tenantId, aiModelId); + } + return Futures.immediateFuture(null); + case UNRECOGNIZED: + default: + return handleUnsupportedMsgType(aiModelUpdateMsg.getMsgType()); + } + } catch (DataValidationException e) { + if (e.getMessage().contains("limit reached")) { + log.warn("[{}] Number of allowed aiModel violated {}", tenantId, aiModelUpdateMsg, e); + return Futures.immediateFuture(null); + } else { + return Futures.immediateFailedFuture(e); + } + } finally { + edgeSynchronizationManager.getEdgeId().remove(); + } + } + + @Override + public DownlinkMsg convertEdgeEventToDownlink(EdgeEvent edgeEvent, EdgeVersion edgeVersion) { + AiModelId aiModelId = new AiModelId(edgeEvent.getEntityId()); + switch (edgeEvent.getAction()) { + case ADDED, UPDATED -> { + Optional aiModel = edgeCtx.getAiModelService().findAiModelById(edgeEvent.getTenantId(), aiModelId); + if (aiModel.isPresent()) { + UpdateMsgType msgType = getUpdateMsgType(edgeEvent.getAction()); + AiModelUpdateMsg aiModelUpdateMsg = EdgeMsgConstructorUtils.constructAiModelUpdatedMsg(msgType, aiModel.get()); + return DownlinkMsg.newBuilder() + .setDownlinkMsgId(EdgeUtils.nextPositiveInt()) + .addAiModelUpdateMsg(aiModelUpdateMsg) + .build(); + } + } + case DELETED -> { + AiModelUpdateMsg aiModelUpdateMsg = EdgeMsgConstructorUtils.constructAiModelDeleteMsg(aiModelId); + return DownlinkMsg.newBuilder() + .setDownlinkMsgId(EdgeUtils.nextPositiveInt()) + .addAiModelUpdateMsg(aiModelUpdateMsg) + .build(); + } + } + return null; + } + + @Override + public EdgeEventType getEdgeEventType() { + return EdgeEventType.AI_MODEL; + } + +// @Override +// public ListenableFuture processEntityNotification(TenantId tenantId, TransportProtos.EdgeNotificationMsgProto edgeNotificationMsg) { +// EdgeEventType type = EdgeEventType.valueOf(edgeNotificationMsg.getType()); +// EdgeEventActionType actionType = EdgeEventActionType.valueOf(edgeNotificationMsg.getAction()); +// EntityId entityId = EntityIdFactory.getByEdgeEventTypeAndUuid(type, new UUID(edgeNotificationMsg.getEntityIdMSB(), edgeNotificationMsg.getEntityIdLSB())); +// EdgeId originatorEdgeId = safeGetEdgeId(edgeNotificationMsg.getOriginatorEdgeIdMSB(), edgeNotificationMsg.getOriginatorEdgeIdLSB()); +// +// switch (actionType) { +// case UPDATED: +// case ADDED: +// EntityId calculatedFieldOwnerId = JacksonUtil.fromString(edgeNotificationMsg.getBody(), EntityId.class); +// if (calculatedFieldOwnerId != null && +// (EntityType.DEVICE.equals(calculatedFieldOwnerId.getEntityType()) || EntityType.ASSET.equals(calculatedFieldOwnerId.getEntityType()))) { +// JsonNode body = JacksonUtil.toJsonNode(edgeNotificationMsg.getBody()); +// EdgeId edgeId = safeGetEdgeId(edgeNotificationMsg.getEdgeIdMSB(), edgeNotificationMsg.getEdgeIdLSB()); +// +// return edgeId != null ? +// saveEdgeEvent(tenantId, edgeId, type, actionType, entityId, body) : +// processNotificationToRelatedEdges(tenantId, calculatedFieldOwnerId, entityId, type, actionType, originatorEdgeId); +// } else { +// return processActionForAllEdges(tenantId, type, actionType, entityId, null, originatorEdgeId); +// } +// default: +// return super.processEntityNotification(tenantId, edgeNotificationMsg); +// } +// } + + private void processAiModel(TenantId tenantId, AiModelId aiModelId, AiModelUpdateMsg aiModelUpdateMsg, Edge edge) { + Pair resultPair = super.saveOrUpdateAiModel(tenantId, aiModelId, aiModelUpdateMsg); + Boolean wasCreated = resultPair.getFirst(); + if (wasCreated) { + pushAiModelCreatedEventToRuleEngine(tenantId, edge, aiModelId); + } + Boolean nameWasUpdated = resultPair.getSecond(); + if (nameWasUpdated) { + saveEdgeEvent(tenantId, edge.getId(), EdgeEventType.AI_MODEL, EdgeEventActionType.UPDATED, aiModelId, null); + } + } + + private void pushAiModelCreatedEventToRuleEngine(TenantId tenantId, Edge edge, AiModelId aiModelId) { + try { + Optional aiModel = edgeCtx.getAiModelService().findAiModelById(tenantId, aiModelId); + if (aiModel.isPresent()) { + String aiModelAsString = JacksonUtil.toString(aiModel.get()); + TbMsgMetaData msgMetaData = getEdgeActionTbMsgMetaData(edge, edge.getCustomerId()); + pushEntityEventToRuleEngine(tenantId, aiModelId, edge.getCustomerId(), TbMsgType.ENTITY_CREATED, aiModelAsString, msgMetaData); + } else { + log.warn("[{}][{}] Failed to find AiModel", tenantId, aiModelId); + } + } catch (Exception e) { + log.warn("[{}][{}] Failed to push aiModel action to rule engine: {}", tenantId, aiModelId, TbMsgType.ENTITY_CREATED.name(), e); + } + } + +} diff --git a/application/src/main/java/org/thingsboard/server/service/edge/rpc/processor/ai/AiModelProcessor.java b/application/src/main/java/org/thingsboard/server/service/edge/rpc/processor/ai/AiModelProcessor.java new file mode 100644 index 0000000000..f66421167a --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/edge/rpc/processor/ai/AiModelProcessor.java @@ -0,0 +1,28 @@ +/** + * 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.rpc.processor.ai; + +import com.google.common.util.concurrent.ListenableFuture; +import org.thingsboard.server.common.data.edge.Edge; +import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.gen.edge.v1.AiModelUpdateMsg; +import org.thingsboard.server.service.edge.rpc.processor.EdgeProcessor; + +public interface AiModelProcessor extends EdgeProcessor { + + ListenableFuture processAiModelMsgFromEdge(TenantId tenantId, Edge edge, AiModelUpdateMsg aiModelUpdateMsg); + +} diff --git a/application/src/main/java/org/thingsboard/server/service/edge/rpc/processor/ai/BaseAiModelProcessor.java b/application/src/main/java/org/thingsboard/server/service/edge/rpc/processor/ai/BaseAiModelProcessor.java new file mode 100644 index 0000000000..cb1d27e0a1 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/edge/rpc/processor/ai/BaseAiModelProcessor.java @@ -0,0 +1,81 @@ +/** + * 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.rpc.processor.ai; + +import com.datastax.oss.driver.api.core.uuid.Uuids; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.data.util.Pair; +import org.thingsboard.common.util.JacksonUtil; +import org.thingsboard.server.common.data.StringUtils; +import org.thingsboard.server.common.data.ai.AiModel; +import org.thingsboard.server.common.data.id.AiModelId; +import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.dao.service.DataValidator; +import org.thingsboard.server.gen.edge.v1.AiModelUpdateMsg; +import org.thingsboard.server.service.edge.rpc.processor.BaseEdgeProcessor; + +import java.util.Optional; + +@Slf4j +public abstract class BaseAiModelProcessor extends BaseEdgeProcessor { + + @Autowired + private DataValidator aiModelValidator; + + protected Pair saveOrUpdateAiModel(TenantId tenantId, AiModelId aiModelId, AiModelUpdateMsg aiModelUpdateMsg) { + boolean isCreated = false; + boolean isNameUpdated = false; + try { + AiModel aiModel = JacksonUtil.fromString(aiModelUpdateMsg.getEntity(), AiModel.class, true); + if (aiModel == null) { + throw new RuntimeException("[{" + tenantId + "}] aiModelUpdateMsg {" + aiModelUpdateMsg + " } cannot be converted to aiModel"); + } + + Optional aiModelById = edgeCtx.getAiModelService().findAiModelById(tenantId, aiModelId); + if (aiModelById.isEmpty()) { + aiModel.setCreatedTime(Uuids.unixTimestamp(aiModelId.getId())); + isCreated = true; + aiModel.setId(null); + } else { + aiModel.setId(aiModelId); + } + + String aiModelName = aiModel.getName(); + Optional aiModelByName = edgeCtx.getAiModelService().findAiModelByTenantIdAndName(aiModel.getTenantId(), aiModelName); + if (aiModelByName.isPresent() && !aiModelByName.get().getId().equals(aiModelId)) { + aiModelName = aiModelName + "_" + StringUtils.randomAlphabetic(15); + log.warn("[{}] aiModel with name {} already exists. Renaming aiModel name to {}", + tenantId, aiModel.getName(), aiModelByName.get().getName()); + isNameUpdated = true; + } + aiModel.setName(aiModelName); + + aiModelValidator.validate(aiModel, AiModel::getTenantId); + + if (isCreated) { + aiModel.setId(aiModelId); + } + + edgeCtx.getAiModelService().save(aiModel, false); + } catch (Exception e) { + log.error("[{}] Failed to process aiModel update msg [{}]", tenantId, aiModelUpdateMsg, e); + throw e; + } + return Pair.of(isCreated, isNameUpdated); + } + +} diff --git a/application/src/test/java/org/thingsboard/server/edge/imitator/EdgeImitator.java b/application/src/test/java/org/thingsboard/server/edge/imitator/EdgeImitator.java index 16d10d9b6e..4594435626 100644 --- a/application/src/test/java/org/thingsboard/server/edge/imitator/EdgeImitator.java +++ b/application/src/test/java/org/thingsboard/server/edge/imitator/EdgeImitator.java @@ -29,6 +29,7 @@ import org.thingsboard.edge.rpc.EdgeGrpcClient; import org.thingsboard.edge.rpc.EdgeRpcClient; import org.thingsboard.server.controller.AbstractWebTest; import org.thingsboard.server.gen.edge.v1.AdminSettingsUpdateMsg; +import org.thingsboard.server.gen.edge.v1.AiModelUpdateMsg; import org.thingsboard.server.gen.edge.v1.AlarmCommentUpdateMsg; import org.thingsboard.server.gen.edge.v1.AlarmUpdateMsg; import org.thingsboard.server.gen.edge.v1.AssetProfileUpdateMsg; @@ -358,6 +359,11 @@ public class EdgeImitator { result.add(saveDownlinkMsg(calculatedFieldUpdateMsg)); } } + if (downlinkMsg.getAiModelUpdateMsgCount() > 0) { + for (AiModelUpdateMsg aiModelUpdateMsg : downlinkMsg.getAiModelUpdateMsgList()) { + result.add(saveDownlinkMsg(aiModelUpdateMsg)); + } + } if (downlinkMsg.hasEdgeConfiguration()) { result.add(saveDownlinkMsg(downlinkMsg.getEdgeConfiguration())); } diff --git a/common/dao-api/src/main/java/org/thingsboard/server/dao/ai/AiModelService.java b/common/dao-api/src/main/java/org/thingsboard/server/dao/ai/AiModelService.java index 3ad12048cf..55abc80998 100644 --- a/common/dao-api/src/main/java/org/thingsboard/server/dao/ai/AiModelService.java +++ b/common/dao-api/src/main/java/org/thingsboard/server/dao/ai/AiModelService.java @@ -29,6 +29,8 @@ public interface AiModelService extends EntityDaoService { AiModel save(AiModel model); + AiModel save(AiModel model, boolean doValidate); + Optional findAiModelById(TenantId tenantId, AiModelId modelId); PageData findAiModelsByTenantId(TenantId tenantId, PageLink pageLink); @@ -37,6 +39,8 @@ public interface AiModelService extends EntityDaoService { FluentFuture> findAiModelByTenantIdAndIdAsync(TenantId tenantId, AiModelId modelId); + Optional findAiModelByTenantIdAndName(TenantId tenantId, String name); + boolean deleteByTenantIdAndId(TenantId tenantId, AiModelId modelId); } diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/edge/EdgeEventType.java b/common/data/src/main/java/org/thingsboard/server/common/data/edge/EdgeEventType.java index 0d5c3f34ad..fadb44207a 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/edge/EdgeEventType.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/edge/EdgeEventType.java @@ -47,7 +47,8 @@ public enum EdgeEventType { TB_RESOURCE(true, EntityType.TB_RESOURCE), OAUTH2_CLIENT(true, EntityType.OAUTH2_CLIENT), DOMAIN(true, EntityType.DOMAIN), - CALCULATED_FIELD(false, EntityType.CALCULATED_FIELD); + CALCULATED_FIELD(false, EntityType.CALCULATED_FIELD), + AI_MODEL(true, EntityType.AI_MODEL); private final boolean allEdgesRelated; diff --git a/common/edge-api/src/main/java/org/thingsboard/edge/rpc/EdgeGrpcClient.java b/common/edge-api/src/main/java/org/thingsboard/edge/rpc/EdgeGrpcClient.java index 0eff7ad053..dbaef23431 100644 --- a/common/edge-api/src/main/java/org/thingsboard/edge/rpc/EdgeGrpcClient.java +++ b/common/edge-api/src/main/java/org/thingsboard/edge/rpc/EdgeGrpcClient.java @@ -136,7 +136,7 @@ public class EdgeGrpcClient implements EdgeRpcClient { .setConnectRequestMsg(ConnectRequestMsg.newBuilder() .setEdgeRoutingKey(edgeKey) .setEdgeSecret(edgeSecret) - .setEdgeVersion(EdgeVersion.V_4_2_0) + .setEdgeVersion(EdgeVersion.V_4_3_0) .setMaxInboundMessageSize(maxInboundMessageSize) .build()) .build()); diff --git a/common/edge-api/src/main/proto/edge.proto b/common/edge-api/src/main/proto/edge.proto index dbda462a99..5a0aeac977 100644 --- a/common/edge-api/src/main/proto/edge.proto +++ b/common/edge-api/src/main/proto/edge.proto @@ -44,6 +44,7 @@ enum EdgeVersion { V_4_0_0 = 10; V_4_1_0 = 11; V_4_2_0 = 12; + V_4_3_0 = 13; V_LATEST = 999; } @@ -133,6 +134,12 @@ message CalculatedFieldUpdateMsg{ string entity = 4; } +message AiModelUpdateMsg{ + UpdateMsgType msgType = 1; + int64 idMSB = 2; + int64 idLSB = 3; + string entity = 4; +} message EntityDataProto { int64 entityIdMSB = 1; @@ -441,6 +448,7 @@ message UplinkMsg { repeated RuleChainMetadataUpdateMsg ruleChainMetadataUpdateMsg = 24; repeated CalculatedFieldUpdateMsg calculatedFieldUpdateMsg = 25; repeated CalculatedFieldRequestMsg calculatedFieldRequestMsg = 26; + repeated AiModelUpdateMsg aiModelUpdateMsg = 27; } message UplinkResponseMsg { @@ -491,4 +499,5 @@ message DownlinkMsg { repeated NotificationTemplateUpdateMsg notificationTemplateUpdateMsg = 33; repeated OAuth2DomainUpdateMsg oAuth2DomainUpdateMsg = 34; repeated CalculatedFieldUpdateMsg calculatedFieldUpdateMsg = 35; + repeated AiModelUpdateMsg aiModelUpdateMsg = 36; } diff --git a/dao/src/main/java/org/thingsboard/server/dao/ai/AiModelServiceImpl.java b/dao/src/main/java/org/thingsboard/server/dao/ai/AiModelServiceImpl.java index b091a29247..21720813c7 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/ai/AiModelServiceImpl.java +++ b/dao/src/main/java/org/thingsboard/server/dao/ai/AiModelServiceImpl.java @@ -63,7 +63,14 @@ class AiModelServiceImpl extends CachedVersionedEntityService findAiModelByTenantIdAndId(tenantId, modelId))); } + @Override + public Optional findAiModelByTenantIdAndName(TenantId tenantId, String name) { + return Optional.ofNullable(aiModelDao.findByTenantIdAndName(tenantId.getId(), name)); + } + @Override @Transactional public boolean deleteByTenantIdAndId(TenantId tenantId, AiModelId modelId) { From ec6d28e210845d105c8d7c7ab1248eda034236a3 Mon Sep 17 00:00:00 2001 From: Yevhenii Date: Fri, 12 Sep 2025 14:52:40 +0300 Subject: [PATCH 04/43] Fixed minor issue and added test --- .../edge/EdgeEventSourcingListener.java | 4 +- .../processor/ai/AiModelEdgeProcessor.java | 35 +--- .../server/edge/AiModelEdgeTest.java | 197 ++++++++++++++++++ .../common/data/id/EntityIdFactory.java | 1 + .../server/dao/ai/AiModelServiceImpl.java | 35 +++- 5 files changed, 228 insertions(+), 44 deletions(-) create mode 100644 application/src/test/java/org/thingsboard/server/edge/AiModelEdgeTest.java diff --git a/application/src/main/java/org/thingsboard/server/service/edge/EdgeEventSourcingListener.java b/application/src/main/java/org/thingsboard/server/service/edge/EdgeEventSourcingListener.java index fda9a1d17b..825b911403 100644 --- a/application/src/main/java/org/thingsboard/server/service/edge/EdgeEventSourcingListener.java +++ b/application/src/main/java/org/thingsboard/server/service/edge/EdgeEventSourcingListener.java @@ -113,7 +113,7 @@ public class EdgeEventSourcingListener { return; } try { - if (EntityType.TENANT == entityType || EntityType.EDGE == entityType || EntityType.AI_MODEL == entityType) { + if (EntityType.TENANT == entityType || EntityType.EDGE == entityType) { return; } log.trace("[{}] DeleteEntityEvent called: {}", tenantId, event); @@ -227,7 +227,7 @@ public class EdgeEventSourcingListener { break; case TENANT: return !event.getCreated(); - case API_USAGE_STATE, EDGE, AI_MODEL: + case API_USAGE_STATE, EDGE: return false; case DOMAIN: if (entity instanceof Domain domain) { diff --git a/application/src/main/java/org/thingsboard/server/service/edge/rpc/processor/ai/AiModelEdgeProcessor.java b/application/src/main/java/org/thingsboard/server/service/edge/rpc/processor/ai/AiModelEdgeProcessor.java index aaa1f05aa7..cd932ece5d 100644 --- a/application/src/main/java/org/thingsboard/server/service/edge/rpc/processor/ai/AiModelEdgeProcessor.java +++ b/application/src/main/java/org/thingsboard/server/service/edge/rpc/processor/ai/AiModelEdgeProcessor.java @@ -58,12 +58,6 @@ public class AiModelEdgeProcessor extends BaseAiModelProcessor implements AiMode case ENTITY_UPDATED_RPC_MESSAGE: processAiModel(tenantId, aiModelId, aiModelUpdateMsg, edge); return Futures.immediateFuture(null); - case ENTITY_DELETED_RPC_MESSAGE: - Optional aiModel = edgeCtx.getAiModelService().findAiModelById(tenantId, aiModelId); - if (aiModel.isPresent()) { - edgeCtx.getAiModelService().deleteByTenantIdAndId(tenantId, aiModelId); - } - return Futures.immediateFuture(null); case UNRECOGNIZED: default: return handleUnsupportedMsgType(aiModelUpdateMsg.getMsgType()); @@ -111,33 +105,6 @@ public class AiModelEdgeProcessor extends BaseAiModelProcessor implements AiMode return EdgeEventType.AI_MODEL; } -// @Override -// public ListenableFuture processEntityNotification(TenantId tenantId, TransportProtos.EdgeNotificationMsgProto edgeNotificationMsg) { -// EdgeEventType type = EdgeEventType.valueOf(edgeNotificationMsg.getType()); -// EdgeEventActionType actionType = EdgeEventActionType.valueOf(edgeNotificationMsg.getAction()); -// EntityId entityId = EntityIdFactory.getByEdgeEventTypeAndUuid(type, new UUID(edgeNotificationMsg.getEntityIdMSB(), edgeNotificationMsg.getEntityIdLSB())); -// EdgeId originatorEdgeId = safeGetEdgeId(edgeNotificationMsg.getOriginatorEdgeIdMSB(), edgeNotificationMsg.getOriginatorEdgeIdLSB()); -// -// switch (actionType) { -// case UPDATED: -// case ADDED: -// EntityId calculatedFieldOwnerId = JacksonUtil.fromString(edgeNotificationMsg.getBody(), EntityId.class); -// if (calculatedFieldOwnerId != null && -// (EntityType.DEVICE.equals(calculatedFieldOwnerId.getEntityType()) || EntityType.ASSET.equals(calculatedFieldOwnerId.getEntityType()))) { -// JsonNode body = JacksonUtil.toJsonNode(edgeNotificationMsg.getBody()); -// EdgeId edgeId = safeGetEdgeId(edgeNotificationMsg.getEdgeIdMSB(), edgeNotificationMsg.getEdgeIdLSB()); -// -// return edgeId != null ? -// saveEdgeEvent(tenantId, edgeId, type, actionType, entityId, body) : -// processNotificationToRelatedEdges(tenantId, calculatedFieldOwnerId, entityId, type, actionType, originatorEdgeId); -// } else { -// return processActionForAllEdges(tenantId, type, actionType, entityId, null, originatorEdgeId); -// } -// default: -// return super.processEntityNotification(tenantId, edgeNotificationMsg); -// } -// } - private void processAiModel(TenantId tenantId, AiModelId aiModelId, AiModelUpdateMsg aiModelUpdateMsg, Edge edge) { Pair resultPair = super.saveOrUpdateAiModel(tenantId, aiModelId, aiModelUpdateMsg); Boolean wasCreated = resultPair.getFirst(); @@ -158,7 +125,7 @@ public class AiModelEdgeProcessor extends BaseAiModelProcessor implements AiMode TbMsgMetaData msgMetaData = getEdgeActionTbMsgMetaData(edge, edge.getCustomerId()); pushEntityEventToRuleEngine(tenantId, aiModelId, edge.getCustomerId(), TbMsgType.ENTITY_CREATED, aiModelAsString, msgMetaData); } else { - log.warn("[{}][{}] Failed to find AiModel", tenantId, aiModelId); + log.warn("[{}][{}] Failed to find aiModel", tenantId, aiModelId); } } catch (Exception e) { log.warn("[{}][{}] Failed to push aiModel action to rule engine: {}", tenantId, aiModelId, TbMsgType.ENTITY_CREATED.name(), e); diff --git a/application/src/test/java/org/thingsboard/server/edge/AiModelEdgeTest.java b/application/src/test/java/org/thingsboard/server/edge/AiModelEdgeTest.java new file mode 100644 index 0000000000..66a681e7b7 --- /dev/null +++ b/application/src/test/java/org/thingsboard/server/edge/AiModelEdgeTest.java @@ -0,0 +1,197 @@ +/** + * 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.edge; + +import com.datastax.oss.driver.api.core.uuid.Uuids; +import com.google.protobuf.AbstractMessage; +import com.google.protobuf.InvalidProtocolBufferException; +import org.junit.Assert; +import org.junit.Test; +import org.thingsboard.common.util.JacksonUtil; +import org.thingsboard.server.common.data.ai.AiModel; +import org.thingsboard.server.common.data.ai.model.chat.OpenAiChatModelConfig; +import org.thingsboard.server.common.data.ai.provider.OpenAiProviderConfig; +import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.dao.service.DaoSqlTest; +import org.thingsboard.server.gen.edge.v1.AiModelUpdateMsg; +import org.thingsboard.server.gen.edge.v1.UpdateMsgType; +import org.thingsboard.server.gen.edge.v1.UplinkMsg; +import org.thingsboard.server.gen.edge.v1.UplinkResponseMsg; + +import java.util.Optional; +import java.util.UUID; + +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@DaoSqlTest +public class AiModelEdgeTest extends AbstractEdgeTest { + + private static final String DEFAULT_AI_MODEL_NAME = "Edge Test AiModel"; + private static final String UPDATED_AI_MODEL_NAME = "Updated Edge Test AiModel"; + + @Test + public void testAiModel_create_update_delete() throws Exception { + // create AiModel + AiModel aiModel = createSimpleAiModel(DEFAULT_AI_MODEL_NAME); + + edgeImitator.expectMessageAmount(1); + AiModel savedAiModel = doPost("/api/ai/model", aiModel, AiModel.class); + Assert.assertTrue(edgeImitator.waitForMessages()); + + AbstractMessage latestMessage = edgeImitator.getLatestMessage(); + Assert.assertTrue(latestMessage instanceof AiModelUpdateMsg); + AiModelUpdateMsg aiModelUpdateMsg = (AiModelUpdateMsg) latestMessage; + Assert.assertEquals(UpdateMsgType.ENTITY_CREATED_RPC_MESSAGE, aiModelUpdateMsg.getMsgType()); + Assert.assertEquals(savedAiModel.getUuidId().getMostSignificantBits(), aiModelUpdateMsg.getIdMSB()); + Assert.assertEquals(savedAiModel.getUuidId().getLeastSignificantBits(), aiModelUpdateMsg.getIdLSB()); + AiModel aiModelFromMsg = JacksonUtil.fromString(aiModelUpdateMsg.getEntity(), AiModel.class, true); + Assert.assertNotNull(aiModelFromMsg); + + Assert.assertEquals(DEFAULT_AI_MODEL_NAME, aiModelFromMsg.getName()); + Assert.assertEquals(savedAiModel.getTenantId(), aiModelFromMsg.getTenantId()); + + // update AiModel + edgeImitator.expectMessageAmount(1); + savedAiModel.setName(UPDATED_AI_MODEL_NAME); + savedAiModel = doPost("/api/ai/model", savedAiModel, AiModel.class); + Assert.assertTrue(edgeImitator.waitForMessages()); + + latestMessage = edgeImitator.getLatestMessage(); + Assert.assertTrue(latestMessage instanceof AiModelUpdateMsg); + aiModelUpdateMsg = (AiModelUpdateMsg) latestMessage; + aiModelFromMsg = JacksonUtil.fromString(aiModelUpdateMsg.getEntity(), AiModel.class, true); + Assert.assertNotNull(aiModelFromMsg); + Assert.assertEquals(UpdateMsgType.ENTITY_UPDATED_RPC_MESSAGE, aiModelUpdateMsg.getMsgType()); + Assert.assertEquals(UPDATED_AI_MODEL_NAME, aiModelFromMsg.getName()); + + // delete AiModel + edgeImitator.expectMessageAmount(1); + doDelete("/api/ai/model/" + savedAiModel.getUuidId()) + .andExpect(status().isOk()); + Assert.assertTrue(edgeImitator.waitForMessages()); + + latestMessage = edgeImitator.getLatestMessage(); + Assert.assertTrue(latestMessage instanceof AiModelUpdateMsg); + aiModelUpdateMsg = (AiModelUpdateMsg) latestMessage; + Assert.assertEquals(UpdateMsgType.ENTITY_DELETED_RPC_MESSAGE, aiModelUpdateMsg.getMsgType()); + Assert.assertEquals(savedAiModel.getUuidId().getMostSignificantBits(), aiModelUpdateMsg.getIdMSB()); + Assert.assertEquals(savedAiModel.getUuidId().getLeastSignificantBits(), aiModelUpdateMsg.getIdLSB()); + } + + @Test + public void testSendAiModelToCloud() throws Exception { + AiModel aiModel = createSimpleAiModel(DEFAULT_AI_MODEL_NAME); + UUID uuid = Uuids.timeBased(); + UplinkMsg uplinkMsg = getUplinkMsg(uuid, aiModel, UpdateMsgType.ENTITY_CREATED_RPC_MESSAGE); + + checkAiModelOnCloud(uplinkMsg, uuid, aiModel.getName()); + } + + @Test + public void testUpdateAiModelNameOnCloud() throws Exception { + AiModel aiModel = createSimpleAiModel(DEFAULT_AI_MODEL_NAME); + UUID uuid = Uuids.timeBased(); + UplinkMsg uplinkMsg = getUplinkMsg(uuid, aiModel, UpdateMsgType.ENTITY_CREATED_RPC_MESSAGE); + + checkAiModelOnCloud(uplinkMsg, uuid, aiModel.getName()); + + aiModel.setName(UPDATED_AI_MODEL_NAME); + UplinkMsg updatedUplinkMsg = getUplinkMsg(uuid, aiModel, UpdateMsgType.ENTITY_UPDATED_RPC_MESSAGE); + + checkAiModelOnCloud(updatedUplinkMsg, uuid, aiModel.getName()); + } + + @Test + public void testAiModelToCloudWithNameThatAlreadyExistsOnCloud() throws Exception { + AiModel aiModel = createSimpleAiModel(DEFAULT_AI_MODEL_NAME); + + edgeImitator.expectMessageAmount(1); + AiModel savedAiModel = doPost("/api/ai/model", aiModel, AiModel.class); + Assert.assertTrue(edgeImitator.waitForMessages()); + + UUID uuid = Uuids.timeBased(); + + UplinkMsg uplinkMsg = getUplinkMsg(uuid, aiModel, UpdateMsgType.ENTITY_CREATED_RPC_MESSAGE); + + edgeImitator.expectResponsesAmount(1); + edgeImitator.expectMessageAmount(1); + edgeImitator.sendUplinkMsg(uplinkMsg); + + Assert.assertTrue(edgeImitator.waitForResponses()); + Assert.assertTrue(edgeImitator.waitForMessages()); + + Optional aiModelUpdateMsgOpt = edgeImitator.findMessageByType(AiModelUpdateMsg.class); + Assert.assertTrue(aiModelUpdateMsgOpt.isPresent()); + AiModelUpdateMsg latestAiModelUpdateMsg = aiModelUpdateMsgOpt.get(); + AiModel aiModelFromMsg = JacksonUtil.fromString(latestAiModelUpdateMsg.getEntity(), AiModel.class, true); + Assert.assertNotNull(aiModelFromMsg); + Assert.assertNotEquals(DEFAULT_AI_MODEL_NAME, aiModelFromMsg.getName()); + + Assert.assertNotEquals(savedAiModel.getUuidId(), uuid); + + AiModel aiModelFromCloud = doGet("/api/ai/model/" + uuid, AiModel.class); + Assert.assertNotNull(aiModelFromCloud); + Assert.assertNotEquals(DEFAULT_AI_MODEL_NAME, aiModelFromCloud.getName()); + } + + private AiModel createSimpleAiModel(String name) { + AiModel aiModel = new AiModel(); + aiModel.setTenantId(tenantId); + aiModel.setName(name); + aiModel.setConfiguration(OpenAiChatModelConfig.builder() + .providerConfig(new OpenAiProviderConfig("test-api-key")) + .modelId("gpt-4o") + .temperature(0.5) + .topP(0.3) + .frequencyPenalty(0.1) + .presencePenalty(0.2) + .maxOutputTokens(1000) + .timeoutSeconds(60) + .maxRetries(2) + .build()); + return aiModel; + } + + private UplinkMsg getUplinkMsg(UUID uuid, AiModel aiModel, UpdateMsgType updateMsgType) throws InvalidProtocolBufferException { + UplinkMsg.Builder uplinkMsgBuilder = UplinkMsg.newBuilder(); + AiModelUpdateMsg.Builder aiModelUpdateMsgBuilder = AiModelUpdateMsg.newBuilder(); + aiModelUpdateMsgBuilder.setIdMSB(uuid.getMostSignificantBits()); + aiModelUpdateMsgBuilder.setIdLSB(uuid.getLeastSignificantBits()); + aiModelUpdateMsgBuilder.setEntity(JacksonUtil.toString(aiModel)); + aiModelUpdateMsgBuilder.setMsgType(updateMsgType); + testAutoGeneratedCodeByProtobuf(aiModelUpdateMsgBuilder); + uplinkMsgBuilder.addAiModelUpdateMsg(aiModelUpdateMsgBuilder.build()); + + testAutoGeneratedCodeByProtobuf(uplinkMsgBuilder); + + return uplinkMsgBuilder.build(); + } + + private void checkAiModelOnCloud(UplinkMsg uplinkMsg, UUID uuid, String resourceTitle) throws Exception { + edgeImitator.expectResponsesAmount(1); + edgeImitator.sendUplinkMsg(uplinkMsg); + + Assert.assertTrue(edgeImitator.waitForResponses()); + + UplinkResponseMsg latestResponseMsg = edgeImitator.getLatestResponseMsg(); + Assert.assertTrue(latestResponseMsg.getSuccess()); + + AiModel aiModel = doGet("/api/ai/model/" + uuid, AiModel.class); + Assert.assertNotNull(aiModel); + Assert.assertEquals(resourceTitle, aiModel.getName()); + } + +} diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/id/EntityIdFactory.java b/common/data/src/main/java/org/thingsboard/server/common/data/id/EntityIdFactory.java index fc1c1cd1a9..09d34ff10b 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/id/EntityIdFactory.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/id/EntityIdFactory.java @@ -113,6 +113,7 @@ public class EntityIdFactory { case OAUTH2_CLIENT -> new OAuth2ClientId(uuid); case DOMAIN -> new DomainId(uuid); case CALCULATED_FIELD -> new CalculatedFieldId(uuid); + case AI_MODEL -> new AiModelId(uuid); default -> throw new IllegalArgumentException("EdgeEventType " + edgeEventType + " is not supported!"); }; } diff --git a/dao/src/main/java/org/thingsboard/server/dao/ai/AiModelServiceImpl.java b/dao/src/main/java/org/thingsboard/server/dao/ai/AiModelServiceImpl.java index 21720813c7..f898c7aa4f 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/ai/AiModelServiceImpl.java +++ b/dao/src/main/java/org/thingsboard/server/dao/ai/AiModelServiceImpl.java @@ -29,6 +29,8 @@ 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.dao.entity.CachedVersionedEntityService; +import org.thingsboard.server.dao.eventsourcing.DeleteEntityEvent; +import org.thingsboard.server.dao.eventsourcing.SaveEntityEvent; import org.thingsboard.server.dao.model.sql.AiModelEntity; import org.thingsboard.server.dao.service.DataValidator; import org.thingsboard.server.dao.sql.JpaExecutorService; @@ -67,14 +69,19 @@ class AiModelServiceImpl extends CachedVersionedEntityService Date: Fri, 12 Sep 2025 15:01:48 +0300 Subject: [PATCH 05/43] AiNode enabled for edge --- .../src/main/java/org/thingsboard/rule/engine/ai/TbAiNode.java | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/ai/TbAiNode.java b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/ai/TbAiNode.java index 3497795771..62a2ca241c 100644 --- a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/ai/TbAiNode.java +++ b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/ai/TbAiNode.java @@ -70,8 +70,7 @@ import static org.thingsboard.server.dao.service.ConstraintValidator.validateFie configClazz = TbAiNodeConfiguration.class, configDirective = "tbExternalNodeAiConfig", iconUrl = "data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iNDkiIGhlaWdodD0iNDgiIHZpZXdCb3g9IjAgMCA0OSA0OCIgZmlsbD0ibm9uZSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj4KPHBhdGggZmlsbC1ydWxlPSJldmVub2RkIiBjbGlwLXJ1bGU9ImV2ZW5vZGQiIGQ9Ik0zOC42MzExIDE3LjA3OTVDNDAuMTcwNSAxNy4wNzk2IDQxLjY1MTggMTcuNjg3MiA0Mi43NDc4IDE4Ljc3NjNDNDMuODQ0OCAxOS44NjYzIDQ0LjQ2NTkgMjEuMzUwMSA0NC40NjU5IDIyLjkwMjlWMzUuNDY1MkM0NC40NjU5IDM2LjM1MDkgNDQuMzU2NyAzNy4wNzY5IDQ0LjA5NzMgMzcuNzUxN0M0My44NDE0IDM4LjQxNjcgNDMuNDY1MSAzOC45NjE0IDQzLjA0NDggMzkuNTAyOEM0Mi40NjY3IDQwLjI0NzIgNDEuNjU2MyA0MC42ODU5IDQwLjg5MTkgNDAuOTM4OEM0MC4xMjExIDQxLjE5MzcgMzkuMzE0MyA0MS4yODg1IDM4LjYzMTEgNDEuMjg4NUgzMS4wMjU5TDIzLjM4MTIgNDUuODQ2NEMyMy4wNDMxIDQ2LjA0NzggMjIuNjI0MSA0Ni4wNTA3IDIyLjI4MzkgNDUuODUyOUMyMS45NDM3IDQ1LjY1NDcgMjEuNzMzOCA0NS4yODU5IDIxLjczMzcgNDQuODg3MlY0MS4yODg1SDE5LjY2NjNDMTguMTI2OSA0MS4yODg0IDE2LjY0NTUgNDAuNjgwOSAxNS41NDk2IDM5LjU5MThDMTQuNDUyNyAzOC41MDE5IDEzLjgzMTUgMzcuMDE3OSAxMy44MzE1IDM1LjQ2NTJWMjIuOTAyOUMxMy44MzE1IDIyLjMyMDIgMTMuOTE4NSAyMS43NDY4IDE0LjA4NTggMjEuMjAwN0wxNi4yODg5IDIxLjgxMDFMMTcuMjA5OSAyNS4yNTAyQzE3Ljk0MTYgMjcuOTg0NSAyMS43NTYyIDI3Ljk4NDQgMjIuNDg4IDI1LjI1MDJMMjMuNDA3OSAyMS44MTAxTDI2Ljc5MTcgMjAuODc0OUMyOC41NzkxIDIwLjM4MDUgMjkuMTc3IDE4LjUwMjYgMjguNTg4OCAxNy4wNzk1SDM4LjYzMTFaTTIyLjU4NDIgMzEuNTM5NUMyMS45OCAzMS41Mzk3IDIxLjQ5MDEgMzIuMDM3NiAyMS40OTAxIDMyLjY1MTlDMjEuNDkwMiAzMy4yNjYgMjEuOTgwMSAzMy43NjQgMjIuNTg0MiAzMy43NjQySDM0LjYxOTFDMzUuMjIzMyAzMy43NjQyIDM1LjcxMzEgMzMuMjY2MSAzNS43MTMyIDMyLjY1MTlDMzUuNzEzMiAzMi4wMzc1IDM1LjIyMzQgMzEuNTM5NSAzNC42MTkxIDMxLjUzOTVIMjIuNTg0MlpNMjQuNzcyMyAyNC44NjU3QzI0LjE2ODIgMjQuODY1OCAyMy42NzgzIDI1LjM2MzggMjMuNjc4MyAyNS45NzhDMjMuNjc4NCAyNi41OTIyIDI0LjE2ODMgMjcuMDkwMiAyNC43NzIzIDI3LjA5MDNIMzcuOTAxNEMzOC41MDU1IDI3LjA5MDMgMzguOTk1MyAyNi41OTIyIDM4Ljk5NTQgMjUuOTc4QzM4Ljk5NTQgMjUuMzYzNyAzOC41MDU2IDI0Ljg2NTcgMzcuOTAxNCAyNC44NjU3SDI0Ljc3MjNaIiBmaWxsPSJibGFjayIgZmlsbC1vcGFjaXR5PSIwLjc2Ii8+CjxwYXRoIGQ9Ik0xOC43ODkxIDExLjI5NzVDMTkuMDY5MSAxMC4xODA4IDIwLjYyOTkgMTAuMTgwOCAyMC45MDk5IDExLjI5NzVMMjEuOTE0MyAxNS4zMDM2QzIyLjAxMTYgMTUuNjkxOCAyMi4zMDY1IDE1Ljk5NzggMjIuNjg2NyAxNi4xMDNMMjYuMzYxMSAxNy4xMTg3QzI3LjQzNyAxNy40MTYyIDI3LjQzNyAxOC45Njc2IDI2LjM2MTEgMTkuMjY1MUwyMi42NzYxIDIwLjI4NEMyMi4zMDE4IDIwLjM4NzQgMjIuMDA4NyAyMC42ODQ1IDIxLjkwNjggMjEuMDY1TDIwLjkwNDYgMjQuODEyNUMyMC42MTE3IDI1LjkwNTggMTkuMDg2MSAyNS45MDU5IDE4Ljc5MzMgMjQuODEyNUwxNy43OTExIDIxLjA2NUMxNy42ODkzIDIwLjY4NDcgMTcuMzk3IDIwLjM4NzUgMTcuMDIyOSAyMC4yODRMMTMuMzM2OCAxOS4yNjUxQzEyLjI2MTQgMTguOTY3MyAxMi4yNjE1IDE3LjQxNjUgMTMuMzM2OCAxNy4xMTg3TDE3LjAxMTIgMTYuMTAzQzE3LjM5MTYgMTUuOTk3OCAxNy42ODc0IDE1LjY5MTkgMTcuNzg0NyAxNS4zMDM2TDE4Ljc4OTEgMTEuMjk3NVoiIGZpbGw9ImJsYWNrIiBmaWxsLW9wYWNpdHk9IjAuNzYiLz4KPHBhdGggZD0iTTEwLjAzNDMgNy4wMjQyNUMxMC4zMDY4IDUuODk0NDQgMTEuODg2OCA1Ljg5NDQ0IDEyLjE1OTQgNy4wMjQyNUwxMi42OTg5IDkuMjYyOThDMTIuNzkyNyA5LjY1MTc0IDEzLjA4NTEgOS45NTg4NyAxMy40NjQgMTAuMDY3OUwxNS41NzczIDEwLjY3NTFDMTYuNjM5MyAxMC45ODAzIDE2LjYzOTMgMTIuNTEwOSAxNS41NzczIDEyLjgxNjFMMTMuNDUzMyAxMy40MjY1QzEzLjA4MDIgMTMuNTMzOCAxMi43OTA4IDEzLjgzMzkgMTIuNjkyNSAxNC4yMTUxTDEyLjE1NTEgMTYuMzA0QzExLjg3IDE3LjQxMTYgMTAuMzIzNiAxNy40MTE2IDEwLjAzODUgMTYuMzA0TDkuNTAwMDMgMTQuMjE1MUM5LjQwMTczIDEzLjgzMzkgOS4xMTIzNSAxMy41MzM3IDguNzM5MyAxMy40MjY1TDYuNjE1MjQgMTIuODE2MUM1LjU1Mzc4IDEyLjUxMDYgNS41NTM2NCAxMC45ODA0IDYuNjE1MjQgMTAuNjc1MUw4LjcyODYyIDEwLjA2NzlDOS4xMDc2IDkuOTU4OTggOS4zOTk3OCA5LjY1MTg0IDkuNDkzNjIgOS4yNjI5OEwxMC4wMzQzIDcuMDI0MjVaIiBmaWxsPSJibGFjayIgZmlsbC1vcGFjaXR5PSIwLjc2Ii8+CjxwYXRoIGQ9Ik0yNS45MDI4IDYuNzMzMTNDMjYuMTg3OCA1LjYyNTQxIDI3LjczNDMgNS42MjU0MSAyOC4wMTkzIDYuNzMzMTNMMjguMjAzMSA3LjQ0Njc5QzI4LjMwMyA3LjgzNDMxIDI4LjYwMDEgOC4xMzcwNSAyOC45ODA5IDguMjM5NzVMMjkuNTM0NCA4LjM4OTY1QzMwLjYxOTIgOC42ODIxMiAzMC42MTkzIDEwLjI0NjkgMjkuNTM0NCAxMC41MzkzTDI4Ljk2OTIgMTAuNjkxNEMyOC41OTQ0IDEwLjc5MjUgMjguMjk5OSAxMS4wODgzIDI4LjE5NTYgMTEuNDY4TDI4LjAxNTEgMTIuMTI4NUMyNy43MTc0IDEzLjIxMjggMjYuMjA0NyAxMy4yMTI4IDI1LjkwNyAxMi4xMjg1TDI1LjcyNTQgMTEuNDY4QzI1LjYyMTEgMTEuMDg4MiAyNS4zMjY4IDEwLjc5MjQgMjQuOTUxOCAxMC42OTE0TDI0LjM4NzcgMTAuNTM5M0MyMy4zMDI2IDEwLjI0NyAyMy4zMDI2IDguNjgxOTggMjQuMzg3NyA4LjM4OTY1TDI0Ljk0MDEgOC4yMzk3NUMyNS4zMjExIDguMTM3MDkgMjUuNjE5MSA3LjgzNDQ2IDI1LjcxOSA3LjQ0Njc5TDI1LjkwMjggNi43MzMxM1oiIGZpbGw9ImJsYWNrIiBmaWxsLW9wYWNpdHk9IjAuNzYiLz4KPC9zdmc+Cg==", - docUrl = "https://thingsboard.io/docs/user-guide/rule-engine-2-0/external-nodes/#ai-request-node", - ruleChainTypes = RuleChainType.CORE + docUrl = "https://thingsboard.io/docs/user-guide/rule-engine-2-0/external-nodes/#ai-request-node" ) public final class TbAiNode extends TbAbstractExternalNode implements TbNode { From d6d9413082e859560e2e7d55e5dee1dd47ebadc5 Mon Sep 17 00:00:00 2001 From: Yevhenii Date: Mon, 15 Sep 2025 11:35:03 +0300 Subject: [PATCH 06/43] Added AiModel methods to RestClient --- .../thingsboard/rest/client/RestClient.java | 29 +++++++++++++++++-- 1 file changed, 27 insertions(+), 2 deletions(-) diff --git a/rest-client/src/main/java/org/thingsboard/rest/client/RestClient.java b/rest-client/src/main/java/org/thingsboard/rest/client/RestClient.java index 0e559efd46..1e03a8b11c 100644 --- a/rest-client/src/main/java/org/thingsboard/rest/client/RestClient.java +++ b/rest-client/src/main/java/org/thingsboard/rest/client/RestClient.java @@ -74,6 +74,7 @@ import org.thingsboard.server.common.data.UpdateMessage; import org.thingsboard.server.common.data.UsageInfo; import org.thingsboard.server.common.data.User; import org.thingsboard.server.common.data.UserEmailInfo; +import org.thingsboard.server.common.data.ai.AiModel; import org.thingsboard.server.common.data.alarm.Alarm; import org.thingsboard.server.common.data.alarm.AlarmComment; import org.thingsboard.server.common.data.alarm.AlarmCommentInfo; @@ -98,6 +99,7 @@ import org.thingsboard.server.common.data.edge.EdgeInfo; import org.thingsboard.server.common.data.edge.EdgeInstructions; import org.thingsboard.server.common.data.edge.EdgeSearchQuery; import org.thingsboard.server.common.data.entityview.EntityViewSearchQuery; +import org.thingsboard.server.common.data.id.AiModelId; import org.thingsboard.server.common.data.id.AlarmCommentId; import org.thingsboard.server.common.data.id.AlarmId; import org.thingsboard.server.common.data.id.AssetId; @@ -3006,7 +3008,7 @@ public class RestClient implements Closeable { addWidgetInfoFiltersToParams(tenantOnly, fullSearch, deprecatedFilter, widgetTypeList, params); return restTemplate.exchange( baseURL + "/api/widgetTypes?" + getUrlParams(pageLink) + - getWidgetTypeInfoPageRequestUrlParams(tenantOnly, fullSearch, deprecatedFilter, widgetTypeList), + getWidgetTypeInfoPageRequestUrlParams(tenantOnly, fullSearch, deprecatedFilter, widgetTypeList), HttpMethod.GET, HttpEntity.EMPTY, new ParameterizedTypeReference>() { @@ -3094,7 +3096,7 @@ public class RestClient implements Closeable { addWidgetInfoFiltersToParams(tenantOnly, fullSearch, deprecatedFilter, widgetTypeList, params); return restTemplate.exchange( baseURL + "/api/widgetTypesInfos?widgetsBundleId={widgetsBundleId}&" + getUrlParams(pageLink) + - getWidgetTypeInfoPageRequestUrlParams(tenantOnly, fullSearch, deprecatedFilter, widgetTypeList), + getWidgetTypeInfoPageRequestUrlParams(tenantOnly, fullSearch, deprecatedFilter, widgetTypeList), HttpMethod.GET, HttpEntity.EMPTY, new ParameterizedTypeReference>() { @@ -4144,6 +4146,29 @@ public class RestClient implements Closeable { } } + public AiModel saveAiModel(AiModel aiModel) { + return restTemplate.postForEntity(baseURL + "/api/ai/model", aiModel, AiModel.class).getBody(); + } + + public Optional getAiModel(AiModelId aiModelId) { + try { + ResponseEntity response = restTemplate.getForEntity( + baseURL + "/api/aiModel/{aiModelId}", AiModel.class, aiModelId.getId()); + return Optional.ofNullable(response.getBody()); + } catch (HttpClientErrorException exception) { + if (exception.getStatusCode() == HttpStatus.NOT_FOUND) { + return Optional.empty(); + } else { + throw exception; + } + } + } + + public void deleteAiModel(AiModelId aiModelId) { + restTemplate.delete(baseURL + "/api/aiModel/{aiModelId}", aiModelId.getId()); + } + + private String getTimeUrlParams(TimePageLink pageLink) { String urlParams = getUrlParams(pageLink); if (pageLink.getStartTime() != null) { From caec9699d47f0dc1e89187f86d118c4c63be4443 Mon Sep 17 00:00:00 2001 From: Yevhenii Date: Tue, 16 Sep 2025 18:19:45 +0300 Subject: [PATCH 07/43] Refactoring --- .../processor/ai/AiModelEdgeProcessor.java | 7 +------ .../server/dao/ai/AiModelServiceImpl.java | 20 ++++++++----------- 2 files changed, 9 insertions(+), 18 deletions(-) diff --git a/application/src/main/java/org/thingsboard/server/service/edge/rpc/processor/ai/AiModelEdgeProcessor.java b/application/src/main/java/org/thingsboard/server/service/edge/rpc/processor/ai/AiModelEdgeProcessor.java index cd932ece5d..74ca7ac27a 100644 --- a/application/src/main/java/org/thingsboard/server/service/edge/rpc/processor/ai/AiModelEdgeProcessor.java +++ b/application/src/main/java/org/thingsboard/server/service/edge/rpc/processor/ai/AiModelEdgeProcessor.java @@ -63,12 +63,7 @@ public class AiModelEdgeProcessor extends BaseAiModelProcessor implements AiMode return handleUnsupportedMsgType(aiModelUpdateMsg.getMsgType()); } } catch (DataValidationException e) { - if (e.getMessage().contains("limit reached")) { - log.warn("[{}] Number of allowed aiModel violated {}", tenantId, aiModelUpdateMsg, e); - return Futures.immediateFuture(null); - } else { - return Futures.immediateFailedFuture(e); - } + return Futures.immediateFailedFuture(e); } finally { edgeSynchronizationManager.getEdgeId().remove(); } diff --git a/dao/src/main/java/org/thingsboard/server/dao/ai/AiModelServiceImpl.java b/dao/src/main/java/org/thingsboard/server/dao/ai/AiModelServiceImpl.java index f898c7aa4f..971eba8921 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/ai/AiModelServiceImpl.java +++ b/dao/src/main/java/org/thingsboard/server/dao/ai/AiModelServiceImpl.java @@ -37,6 +37,7 @@ import org.thingsboard.server.dao.sql.JpaExecutorService; import java.util.Optional; import java.util.Set; +import java.util.UUID; import static org.thingsboard.server.dao.service.Validator.validatePageLink; @@ -125,11 +126,7 @@ class AiModelServiceImpl extends CachedVersionedEntityService Date: Tue, 16 Sep 2025 18:52:21 +0300 Subject: [PATCH 08/43] Refactoring --- .../org/thingsboard/server/dao/ai/AiModelServiceImpl.java | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/dao/src/main/java/org/thingsboard/server/dao/ai/AiModelServiceImpl.java b/dao/src/main/java/org/thingsboard/server/dao/ai/AiModelServiceImpl.java index 971eba8921..7cdf5b027d 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/ai/AiModelServiceImpl.java +++ b/dao/src/main/java/org/thingsboard/server/dao/ai/AiModelServiceImpl.java @@ -146,8 +146,8 @@ class AiModelServiceImpl extends CachedVersionedEntityService Date: Fri, 3 Oct 2025 15:40:15 +0300 Subject: [PATCH 09/43] propagation cf init commit --- ...CalculatedFieldEntityMessageProcessor.java | 14 +++- ...tractCalculatedFieldProcessingService.java | 34 ++++++--- ...faultCalculatedFieldProcessingService.java | 41 ++++++++--- .../cf/PropagationCalculatedFieldResult.java | 49 +++++++++++++ .../service/cf/ctx/state/ArgumentEntry.java | 8 ++- .../cf/ctx/state/ArgumentEntryType.java | 2 +- .../cf/ctx/state/CalculatedFieldCtx.java | 39 ++++++---- .../geofencing/GeofencingArgumentEntry.java | 4 +- .../GeofencingCalculatedFieldState.java | 4 ++ .../propagation/PropagationArgumentEntry.java | 72 +++++++++++++++++++ .../PropagationCalculatedFieldState.java | 59 +++++++++++++++ .../utils/CalculatedFieldArgumentUtils.java | 2 + .../server/utils/CalculatedFieldUtils.java | 25 +++++-- .../common/data/cf/CalculatedFieldType.java | 3 +- .../BaseCalculatedFieldConfiguration.java | 10 ++- .../CalculatedFieldConfiguration.java | 3 +- ...opagationCalculatedFieldConfiguration.java | 66 +++++++++++++++++ common/proto/src/main/proto/queue.proto | 1 + .../script/api/tbel/TbelCfArg.java | 3 +- ...ncingArg.java => TbelCfGeofencingArg.java} | 4 +- .../script/api/tbel/TbelCfPropagationArg.java | 42 +++++++++++ 21 files changed, 434 insertions(+), 51 deletions(-) create mode 100644 application/src/main/java/org/thingsboard/server/service/cf/PropagationCalculatedFieldResult.java create mode 100644 application/src/main/java/org/thingsboard/server/service/cf/ctx/state/propagation/PropagationArgumentEntry.java create mode 100644 application/src/main/java/org/thingsboard/server/service/cf/ctx/state/propagation/PropagationCalculatedFieldState.java create mode 100644 common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/PropagationCalculatedFieldConfiguration.java rename common/script/script-api/src/main/java/org/thingsboard/script/api/tbel/{TbelCfTsGeofencingArg.java => TbelCfGeofencingArg.java} (89%) create mode 100644 common/script/script-api/src/main/java/org/thingsboard/script/api/tbel/TbelCfPropagationArg.java diff --git a/application/src/main/java/org/thingsboard/server/actors/calculatedField/CalculatedFieldEntityMessageProcessor.java b/application/src/main/java/org/thingsboard/server/actors/calculatedField/CalculatedFieldEntityMessageProcessor.java index 1625becc20..78ad6678a3 100644 --- a/application/src/main/java/org/thingsboard/server/actors/calculatedField/CalculatedFieldEntityMessageProcessor.java +++ b/application/src/main/java/org/thingsboard/server/actors/calculatedField/CalculatedFieldEntityMessageProcessor.java @@ -27,6 +27,7 @@ import org.thingsboard.server.common.data.AttributeScope; import org.thingsboard.server.common.data.DataConstants; import org.thingsboard.server.common.data.StringUtils; import org.thingsboard.server.common.data.alarm.Alarm; +import org.thingsboard.server.common.data.cf.CalculatedFieldType; import org.thingsboard.server.common.data.cf.configuration.Argument; import org.thingsboard.server.common.data.cf.configuration.ArgumentType; import org.thingsboard.server.common.data.cf.configuration.ReferencedEntityKey; @@ -319,13 +320,15 @@ public class CalculatedFieldEntityMessageProcessor extends AbstractContextAwareM if (state == null) { state = createState(ctx); justRestored = true; - } else if (ctx.shouldFetchDynamicArgumentsFromDb(state)) { + } else if (ctx.shouldFetchRelationQueryDynamicArgumentsFromDb(state)) { log.debug("[{}][{}] Going to update dynamic arguments for CF.", entityId, ctx.getCfId()); try { Map dynamicArgsFromDb = cfService.fetchDynamicArgsFromDb(ctx, entityId); dynamicArgsFromDb.forEach(newArgValues::putIfAbsent); - var geofencingState = (GeofencingCalculatedFieldState) state; - geofencingState.setLastDynamicArgumentsRefreshTs(System.currentTimeMillis()); + if (ctx.getCfType() == CalculatedFieldType.GEOFENCING) { + var geofencingState = (GeofencingCalculatedFieldState) state; + geofencingState.updateLastDynamicArgumentsRefreshTs(); + } } catch (Exception e) { throw CalculatedFieldException.builder().ctx(ctx).eventEntity(entityId).cause(e).build(); } @@ -353,6 +356,11 @@ public class CalculatedFieldEntityMessageProcessor extends AbstractContextAwareM private void initState(CalculatedFieldState state, CalculatedFieldCtx ctx) { state.init(ctx); + if (ctx.getCfType() == CalculatedFieldType.GEOFENCING && ctx.isRelationQueryDynamicArguments()) { + GeofencingCalculatedFieldState geofencingState = (GeofencingCalculatedFieldState) state; + geofencingState.updateLastDynamicArgumentsRefreshTs(); + } + Map arguments = fetchArguments(ctx); state.update(arguments, ctx); diff --git a/application/src/main/java/org/thingsboard/server/service/cf/AbstractCalculatedFieldProcessingService.java b/application/src/main/java/org/thingsboard/server/service/cf/AbstractCalculatedFieldProcessingService.java index 4018916582..232476c15a 100644 --- a/application/src/main/java/org/thingsboard/server/service/cf/AbstractCalculatedFieldProcessingService.java +++ b/application/src/main/java/org/thingsboard/server/service/cf/AbstractCalculatedFieldProcessingService.java @@ -52,6 +52,8 @@ import java.util.Set; import java.util.concurrent.ExecutionException; import java.util.stream.Collectors; +import static org.thingsboard.server.common.data.cf.CalculatedFieldType.PROPAGATION; +import static org.thingsboard.server.common.data.cf.configuration.PropagationCalculatedFieldConfiguration.PROPAGATION_CONFIG_ARGUMENT; import static org.thingsboard.server.common.data.cf.configuration.geofencing.EntityCoordinates.ENTITY_ID_LATITUDE_ARGUMENT_KEY; import static org.thingsboard.server.common.data.cf.configuration.geofencing.EntityCoordinates.ENTITY_ID_LONGITUDE_ARGUMENT_KEY; import static org.thingsboard.server.utils.CalculatedFieldArgumentUtils.createDefaultAttributeEntry; @@ -88,21 +90,26 @@ public abstract class AbstractCalculatedFieldProcessingService { protected ListenableFuture> fetchArguments(CalculatedFieldCtx ctx, EntityId entityId, long ts) { Map> argFutures = switch (ctx.getCalculatedField().getType()) { case GEOFENCING -> fetchGeofencingCalculatedFieldArguments(ctx, entityId, false, ts); - case SIMPLE, SCRIPT, ALARM -> { - Map> futures = new HashMap<>(); - for (var entry : ctx.getArguments().entrySet()) { - var argEntityId = resolveEntityId(ctx.getTenantId(), entityId, entry.getValue()); - var argValueFuture = fetchArgumentValue(ctx.getTenantId(), argEntityId, entry.getValue(), ts); - futures.put(entry.getKey(), argValueFuture); - } - yield futures; - } + case SIMPLE, SCRIPT, ALARM, PROPAGATION -> getBaseCalculatedFieldArguments(ctx, entityId, ts); }; + if (ctx.getCfType() == PROPAGATION) { + argFutures.put(PROPAGATION_CONFIG_ARGUMENT, fetchPropagationCalculatedFieldArgument(ctx, entityId)); + } return Futures.whenAllComplete(argFutures.values()) .call(() -> resolveArgumentFutures(argFutures), MoreExecutors.directExecutor()); } + private Map> getBaseCalculatedFieldArguments(CalculatedFieldCtx ctx, EntityId entityId, long ts) { + Map> futures = new HashMap<>(); + for (var entry : ctx.getArguments().entrySet()) { + var argEntityId = resolveEntityId(ctx.getTenantId(), entityId, entry.getValue()); + var argValueFuture = fetchArgumentValue(ctx.getTenantId(), argEntityId, entry.getValue(), ts); + futures.put(entry.getKey(), argValueFuture); + } + return futures; + } + protected EntityId resolveEntityId(TenantId tenantId, EntityId entityId, Argument argument) { if (argument.getRefEntityId() != null) { return argument.getRefEntityId(); @@ -130,6 +137,11 @@ public abstract class AbstractCalculatedFieldProcessingService { )); } + protected ListenableFuture fetchPropagationCalculatedFieldArgument(CalculatedFieldCtx ctx, EntityId entityId) { + ListenableFuture> propagationEntityIds = fromDynamicSource(ctx.getTenantId(), entityId, ctx.getPropagationArgument()); + return Futures.transform(propagationEntityIds, ArgumentEntry::createPropagationArgument, MoreExecutors.directExecutor()); + } + protected Map> fetchGeofencingCalculatedFieldArguments(CalculatedFieldCtx ctx, EntityId entityId, boolean dynamicArgumentsOnly, long startTs) { Map> argFutures = new HashMap<>(); Set> entries = ctx.getArguments().entrySet(); @@ -160,6 +172,10 @@ public abstract class AbstractCalculatedFieldProcessingService { if (!value.hasDynamicSource()) { return Futures.immediateFuture(List.of(entityId)); } + return fromDynamicSource(tenantId, entityId, value); + } + + private ListenableFuture> fromDynamicSource(TenantId tenantId, EntityId entityId, Argument value) { var refDynamicSourceConfiguration = value.getRefDynamicSourceConfiguration(); return switch (refDynamicSourceConfiguration.getType()) { case CURRENT_OWNER -> Futures.immediateFuture(List.of(resolveOwnerArgument(tenantId, entityId))); diff --git a/application/src/main/java/org/thingsboard/server/service/cf/DefaultCalculatedFieldProcessingService.java b/application/src/main/java/org/thingsboard/server/service/cf/DefaultCalculatedFieldProcessingService.java index 9b2964a736..52393d0ffe 100644 --- a/application/src/main/java/org/thingsboard/server/service/cf/DefaultCalculatedFieldProcessingService.java +++ b/application/src/main/java/org/thingsboard/server/service/cf/DefaultCalculatedFieldProcessingService.java @@ -23,7 +23,6 @@ import org.thingsboard.server.actors.calculatedField.MultipleTbCallback; import org.thingsboard.server.cluster.TbClusterService; import org.thingsboard.server.common.data.DataConstants; import org.thingsboard.server.common.data.EntityType; -import org.thingsboard.server.common.data.cf.CalculatedFieldType; import org.thingsboard.server.common.data.cf.configuration.Argument; import org.thingsboard.server.common.data.id.CalculatedFieldId; import org.thingsboard.server.common.data.id.EntityId; @@ -50,11 +49,13 @@ import org.thingsboard.server.service.cf.ctx.state.ArgumentEntry; import org.thingsboard.server.service.cf.ctx.state.CalculatedFieldCtx; import java.util.ArrayList; +import java.util.Collections; import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.UUID; +import static org.thingsboard.server.common.data.cf.configuration.PropagationCalculatedFieldConfiguration.PROPAGATION_CONFIG_ARGUMENT; import static org.thingsboard.server.utils.CalculatedFieldUtils.toProto; @TbRuleEngineComponent @@ -89,11 +90,11 @@ public class DefaultCalculatedFieldProcessingService extends AbstractCalculatedF @Override public Map fetchDynamicArgsFromDb(CalculatedFieldCtx ctx, EntityId entityId) { - // only scheduledSupported CF instances supports dynamic arguments scheduled updates - if (!ctx.getCalculatedField().getType().equals(CalculatedFieldType.GEOFENCING)) { - return Map.of(); - } - return resolveArgumentFutures(fetchGeofencingCalculatedFieldArguments(ctx, entityId, true, System.currentTimeMillis())); + return switch (ctx.getCfType()) { + case GEOFENCING -> resolveArgumentFutures(fetchGeofencingCalculatedFieldArguments(ctx, entityId, true, System.currentTimeMillis())); + case PROPAGATION -> resolveArgumentFutures(Map.of(PROPAGATION_CONFIG_ARGUMENT, fetchPropagationCalculatedFieldArgument(ctx, entityId))); + default -> Collections.emptyMap(); + }; } @Override @@ -112,13 +113,35 @@ public class DefaultCalculatedFieldProcessingService extends AbstractCalculatedF @Override public void pushMsgToRuleEngine(TenantId tenantId, EntityId entityId, CalculatedFieldResult result, List cfIds, TbCallback callback) { - try { + if (!(result instanceof PropagationCalculatedFieldResult propagationCalculatedFieldResult)) { TbMsg msg = result.toTbMsg(entityId, cfIds); + sendMsgToRuleEngine(tenantId, entityId, callback, msg); + return; + } + List propagationEntityIds = propagationCalculatedFieldResult.getPropagationEntityIds(); + if (propagationEntityIds.isEmpty()) { + callback.onSuccess(); + } + if (propagationEntityIds.size() == 1) { + EntityId propagationEntityId = propagationEntityIds.get(0); + TbMsg msg = result.toTbMsg(propagationEntityId, cfIds); + sendMsgToRuleEngine(tenantId, propagationEntityId, callback, msg); + return; + } + MultipleTbCallback multipleTbCallback = new MultipleTbCallback(propagationEntityIds.size(), callback); + for (var propagationEntityId : propagationEntityIds) { + TbMsg msg = result.toTbMsg(propagationEntityId, cfIds); + sendMsgToRuleEngine(tenantId, propagationEntityId, multipleTbCallback, msg); + } + } + + private void sendMsgToRuleEngine(TenantId tenantId, EntityId entityId, TbCallback callback, TbMsg msg) { + try { clusterService.pushMsgToRuleEngine(tenantId, entityId, msg, new TbQueueCallback() { @Override public void onSuccess(TbQueueMsgMetadata metadata) { - callback.onSuccess(); log.trace("[{}][{}] Pushed message to rule engine: {} ", tenantId, entityId, msg); + callback.onSuccess(); } @Override @@ -127,7 +150,7 @@ public class DefaultCalculatedFieldProcessingService extends AbstractCalculatedF } }); } catch (Exception e) { - log.warn("[{}][{}] Failed to push message to rule engine. CalculatedFieldResult: {}", tenantId, entityId, result, e); + log.warn("[{}][{}] Failed to push message to rule engine: {}", tenantId, entityId, msg, e); callback.onFailure(e); } } diff --git a/application/src/main/java/org/thingsboard/server/service/cf/PropagationCalculatedFieldResult.java b/application/src/main/java/org/thingsboard/server/service/cf/PropagationCalculatedFieldResult.java new file mode 100644 index 0000000000..780fd220a7 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/cf/PropagationCalculatedFieldResult.java @@ -0,0 +1,49 @@ +/** + * 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.cf; + +import lombok.Builder; +import lombok.Data; +import org.thingsboard.server.common.data.id.CalculatedFieldId; +import org.thingsboard.server.common.data.id.EntityId; +import org.thingsboard.server.common.data.util.CollectionsUtil; +import org.thingsboard.server.common.msg.TbMsg; + +import java.util.List; + +@Data +@Builder +public final class PropagationCalculatedFieldResult implements CalculatedFieldResult { + + private final List propagationEntityIds; + private final TelemetryCalculatedFieldResult result; + + @Override + public TbMsg toTbMsg(EntityId entityId, List cfIds) { + return result.toTbMsg(entityId, cfIds); + } + + @Override + public String stringValue() { + return result.stringValue(); + } + + @Override + public boolean isEmpty() { + return CollectionsUtil.isEmpty(propagationEntityIds) || result.isEmpty(); + } + +} diff --git a/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/ArgumentEntry.java b/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/ArgumentEntry.java index 2d43883131..5f8276bc99 100644 --- a/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/ArgumentEntry.java +++ b/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/ArgumentEntry.java @@ -23,6 +23,7 @@ import org.thingsboard.server.common.data.id.EntityId; import org.thingsboard.server.common.data.kv.KvEntry; import org.thingsboard.server.common.data.kv.TsKvEntry; import org.thingsboard.server.service.cf.ctx.state.geofencing.GeofencingArgumentEntry; +import org.thingsboard.server.service.cf.ctx.state.propagation.PropagationArgumentEntry; import java.util.List; import java.util.Map; @@ -35,7 +36,8 @@ import java.util.Map; @JsonSubTypes({ @JsonSubTypes.Type(value = SingleValueArgumentEntry.class, name = "SINGLE_VALUE"), @JsonSubTypes.Type(value = TsRollingArgumentEntry.class, name = "TS_ROLLING"), - @JsonSubTypes.Type(value = GeofencingArgumentEntry.class, name = "GEOFENCING") + @JsonSubTypes.Type(value = GeofencingArgumentEntry.class, name = "GEOFENCING"), + @JsonSubTypes.Type(value = PropagationArgumentEntry.class, name = "PROPAGATION") }) public interface ArgumentEntry { @@ -66,4 +68,8 @@ public interface ArgumentEntry { return new GeofencingArgumentEntry(entityIdkvEntryMap); } + static ArgumentEntry createPropagationArgument(List entityIds) { + return new PropagationArgumentEntry(entityIds); + } + } diff --git a/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/ArgumentEntryType.java b/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/ArgumentEntryType.java index 876bfa2a3f..2b118c9c07 100644 --- a/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/ArgumentEntryType.java +++ b/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/ArgumentEntryType.java @@ -16,5 +16,5 @@ package org.thingsboard.server.service.cf.ctx.state; public enum ArgumentEntryType { - SINGLE_VALUE, TS_ROLLING, GEOFENCING + SINGLE_VALUE, TS_ROLLING, GEOFENCING, PROPAGATION } 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 12e4dc0a3a..ece459350f 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 @@ -36,6 +36,7 @@ import org.thingsboard.server.common.data.cf.configuration.ArgumentType; import org.thingsboard.server.common.data.cf.configuration.ArgumentsBasedCalculatedFieldConfiguration; import org.thingsboard.server.common.data.cf.configuration.ExpressionBasedCalculatedFieldConfiguration; import org.thingsboard.server.common.data.cf.configuration.Output; +import org.thingsboard.server.common.data.cf.configuration.PropagationCalculatedFieldConfiguration; import org.thingsboard.server.common.data.cf.configuration.ReferencedEntityKey; import org.thingsboard.server.common.data.cf.configuration.ScheduledUpdateSupportedCalculatedFieldConfiguration; import org.thingsboard.server.common.data.cf.configuration.SimpleCalculatedFieldConfiguration; @@ -101,6 +102,8 @@ public class CalculatedFieldCtx { private long scheduledUpdateIntervalMillis; + private Argument propagationArgument; + public CalculatedFieldCtx(CalculatedField calculatedField, ActorSystemContext systemContext) { this.calculatedField = calculatedField; @@ -154,6 +157,10 @@ public class CalculatedFieldCtx { } }); } + if (calculatedField.getConfiguration() instanceof PropagationCalculatedFieldConfiguration propagationConfig) { + propagationArgument = propagationConfig.toPropagationArgument(); + relationQueryDynamicArguments = true; + } } if (calculatedField.getConfiguration() instanceof ScheduledUpdateSupportedCalculatedFieldConfiguration scheduledConfig) { this.scheduledUpdateIntervalMillis = scheduledConfig.isScheduledUpdateEnabled() ? TimeUnit.SECONDS.toMillis(scheduledConfig.getScheduledUpdateInterval()) : -1L; @@ -170,7 +177,7 @@ public class CalculatedFieldCtx { public void init() { switch (cfType) { - case SCRIPT -> { + case SCRIPT, PROPAGATION -> { try { initTbelExpression(expression); initialized = true; @@ -512,21 +519,29 @@ public class CalculatedFieldCtx { return false; } - public boolean hasRelationQueryDynamicArguments() { - return relationQueryDynamicArguments && scheduledUpdateIntervalMillis != -1; + private boolean isScheduledUpdateEnabled() { + return scheduledUpdateIntervalMillis != -1; } - public boolean shouldFetchDynamicArgumentsFromDb(CalculatedFieldState state) { - if (!hasRelationQueryDynamicArguments()) { + public boolean shouldFetchRelationQueryDynamicArgumentsFromDb(CalculatedFieldState state) { + if (!relationQueryDynamicArguments) { return false; } - if (!(state instanceof GeofencingCalculatedFieldState geofencingState)) { - return false; - } - if (geofencingState.getLastDynamicArgumentsRefreshTs() == -1L) { - return true; - } - return geofencingState.getLastDynamicArgumentsRefreshTs() < System.currentTimeMillis() - scheduledUpdateIntervalMillis; + return switch (cfType) { + case PROPAGATION -> true; + case GEOFENCING -> { + if (!isScheduledUpdateEnabled()) { + yield false; + } + var geofencingState = (GeofencingCalculatedFieldState) state; + if (geofencingState.getLastDynamicArgumentsRefreshTs() == -1L) { + yield true; + } + yield geofencingState.getLastDynamicArgumentsRefreshTs() < + System.currentTimeMillis() - scheduledUpdateIntervalMillis; + } + default -> false; + }; } public void stop() { diff --git a/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/geofencing/GeofencingArgumentEntry.java b/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/geofencing/GeofencingArgumentEntry.java index 53e5c19e72..bcc4d3ffcd 100644 --- a/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/geofencing/GeofencingArgumentEntry.java +++ b/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/geofencing/GeofencingArgumentEntry.java @@ -18,7 +18,7 @@ package org.thingsboard.server.service.cf.ctx.state.geofencing; import lombok.Data; import lombok.extern.slf4j.Slf4j; import org.thingsboard.script.api.tbel.TbelCfArg; -import org.thingsboard.script.api.tbel.TbelCfTsGeofencingArg; +import org.thingsboard.script.api.tbel.TbelCfGeofencingArg; import org.thingsboard.server.common.data.id.EntityId; import org.thingsboard.server.common.data.kv.KvEntry; import org.thingsboard.server.common.util.ProtoUtils; @@ -83,7 +83,7 @@ public class GeofencingArgumentEntry implements ArgumentEntry { @Override public TbelCfArg toTbelCfArg() { - return new TbelCfTsGeofencingArg(zoneStates); + return new TbelCfGeofencingArg(zoneStates); } private Map toZones(Map entityIdKvEntryMap) { diff --git a/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/geofencing/GeofencingCalculatedFieldState.java b/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/geofencing/GeofencingCalculatedFieldState.java index 47dce596da..f3bf8750cf 100644 --- a/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/geofencing/GeofencingCalculatedFieldState.java +++ b/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/geofencing/GeofencingCalculatedFieldState.java @@ -146,6 +146,10 @@ public class GeofencingCalculatedFieldState extends BaseCalculatedFieldState { lastDynamicArgumentsRefreshTs = -1; } + public void updateLastDynamicArgumentsRefreshTs() { + lastDynamicArgumentsRefreshTs = System.currentTimeMillis(); + } + private Map getGeofencingArguments() { return arguments.entrySet() .stream() diff --git a/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/propagation/PropagationArgumentEntry.java b/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/propagation/PropagationArgumentEntry.java new file mode 100644 index 0000000000..c7d49a4d40 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/propagation/PropagationArgumentEntry.java @@ -0,0 +1,72 @@ +/** + * 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.cf.ctx.state.propagation; + +import lombok.Data; +import org.thingsboard.script.api.tbel.TbelCfArg; +import org.thingsboard.script.api.tbel.TbelCfPropagationArg; +import org.thingsboard.server.common.data.id.EntityId; +import org.thingsboard.server.common.data.util.CollectionsUtil; +import org.thingsboard.server.service.cf.ctx.state.ArgumentEntry; +import org.thingsboard.server.service.cf.ctx.state.ArgumentEntryType; + +import java.util.List; + +@Data +public class PropagationArgumentEntry implements ArgumentEntry { + + private List propagationEntityIds; + + private boolean forceResetPrevious; + + public PropagationArgumentEntry(List propagationEntityIds) { + this.propagationEntityIds = propagationEntityIds; + } + + @Override + public ArgumentEntryType getType() { + return ArgumentEntryType.PROPAGATION; + } + + @Override + public Object getValue() { + return propagationEntityIds; + } + + @Override + public boolean updateEntry(ArgumentEntry entry) { + if (!(entry instanceof PropagationArgumentEntry propagationArgumentEntry)) { + throw new IllegalArgumentException("Unsupported argument entry type for propagation argument entry: " + entry.getType()); + } + if (propagationArgumentEntry.isEmpty()) { + propagationEntityIds.clear(); + } else { + propagationEntityIds = propagationArgumentEntry.getPropagationEntityIds(); + } + return true; + } + + @Override + public boolean isEmpty() { + return CollectionsUtil.isEmpty(propagationEntityIds); + } + + @Override + public TbelCfArg toTbelCfArg() { + return new TbelCfPropagationArg(propagationEntityIds); + } + +} diff --git a/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/propagation/PropagationCalculatedFieldState.java b/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/propagation/PropagationCalculatedFieldState.java new file mode 100644 index 0000000000..21a4493c91 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/propagation/PropagationCalculatedFieldState.java @@ -0,0 +1,59 @@ +/** + * 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.cf.ctx.state.propagation; + +import com.google.common.util.concurrent.Futures; +import com.google.common.util.concurrent.ListenableFuture; +import com.google.common.util.concurrent.MoreExecutors; +import org.thingsboard.server.common.data.cf.CalculatedFieldType; +import org.thingsboard.server.common.data.id.EntityId; +import org.thingsboard.server.service.cf.CalculatedFieldResult; +import org.thingsboard.server.service.cf.PropagationCalculatedFieldResult; +import org.thingsboard.server.service.cf.TelemetryCalculatedFieldResult; +import org.thingsboard.server.service.cf.ctx.state.ArgumentEntry; +import org.thingsboard.server.service.cf.ctx.state.CalculatedFieldCtx; +import org.thingsboard.server.service.cf.ctx.state.ScriptCalculatedFieldState; + +import java.util.Map; + +import static org.thingsboard.server.common.data.cf.configuration.PropagationCalculatedFieldConfiguration.PROPAGATION_CONFIG_ARGUMENT; + +public class PropagationCalculatedFieldState extends ScriptCalculatedFieldState { + + public PropagationCalculatedFieldState(EntityId entityId) { + super(entityId); + } + + @Override + public CalculatedFieldType getType() { + return CalculatedFieldType.PROPAGATION; + } + + @Override + public ListenableFuture performCalculation(Map updatedArgs, CalculatedFieldCtx ctx) { + ArgumentEntry argumentEntry = arguments.get(PROPAGATION_CONFIG_ARGUMENT); + if (!(argumentEntry instanceof PropagationArgumentEntry propagationArgumentEntry) || propagationArgumentEntry.isEmpty()) { + return Futures.immediateFuture(PropagationCalculatedFieldResult.builder().build()); + } + return Futures.transform(super.performCalculation(updatedArgs, ctx), telemetryCfResult -> + PropagationCalculatedFieldResult.builder() + .propagationEntityIds(propagationArgumentEntry.getPropagationEntityIds()) + .result((TelemetryCalculatedFieldResult) telemetryCfResult) + .build(), + MoreExecutors.directExecutor()); + } + +} diff --git a/application/src/main/java/org/thingsboard/server/utils/CalculatedFieldArgumentUtils.java b/application/src/main/java/org/thingsboard/server/utils/CalculatedFieldArgumentUtils.java index c81d14f07b..df82488268 100644 --- a/application/src/main/java/org/thingsboard/server/utils/CalculatedFieldArgumentUtils.java +++ b/application/src/main/java/org/thingsboard/server/utils/CalculatedFieldArgumentUtils.java @@ -36,6 +36,7 @@ import org.thingsboard.server.service.cf.ctx.state.SimpleCalculatedFieldState; import org.thingsboard.server.service.cf.ctx.state.SingleValueArgumentEntry; import org.thingsboard.server.service.cf.ctx.state.alarm.AlarmCalculatedFieldState; import org.thingsboard.server.service.cf.ctx.state.geofencing.GeofencingCalculatedFieldState; +import org.thingsboard.server.service.cf.ctx.state.propagation.PropagationCalculatedFieldState; import java.util.Optional; @@ -79,6 +80,7 @@ public class CalculatedFieldArgumentUtils { case SCRIPT -> new ScriptCalculatedFieldState(entityId); case GEOFENCING -> new GeofencingCalculatedFieldState(entityId); case ALARM -> new AlarmCalculatedFieldState(entityId); + case PROPAGATION -> new PropagationCalculatedFieldState(entityId); }; } diff --git a/application/src/main/java/org/thingsboard/server/utils/CalculatedFieldUtils.java b/application/src/main/java/org/thingsboard/server/utils/CalculatedFieldUtils.java index 38aeb45a20..69337fe2e6 100644 --- a/application/src/main/java/org/thingsboard/server/utils/CalculatedFieldUtils.java +++ b/application/src/main/java/org/thingsboard/server/utils/CalculatedFieldUtils.java @@ -32,6 +32,7 @@ import org.thingsboard.server.gen.transport.TransportProtos.AlarmStateProto; import org.thingsboard.server.gen.transport.TransportProtos.CalculatedFieldEntityCtxIdProto; import org.thingsboard.server.gen.transport.TransportProtos.CalculatedFieldIdProto; import org.thingsboard.server.gen.transport.TransportProtos.CalculatedFieldStateProto; +import org.thingsboard.server.gen.transport.TransportProtos.EntityIdProto; import org.thingsboard.server.gen.transport.TransportProtos.GeofencingArgumentProto; import org.thingsboard.server.gen.transport.TransportProtos.GeofencingZoneProto; import org.thingsboard.server.gen.transport.TransportProtos.SingleValueArgumentProto; @@ -50,7 +51,10 @@ import org.thingsboard.server.service.cf.ctx.state.alarm.AlarmRuleState; import org.thingsboard.server.service.cf.ctx.state.geofencing.GeofencingArgumentEntry; import org.thingsboard.server.service.cf.ctx.state.geofencing.GeofencingCalculatedFieldState; import org.thingsboard.server.service.cf.ctx.state.geofencing.GeofencingZoneState; +import org.thingsboard.server.service.cf.ctx.state.propagation.PropagationArgumentEntry; +import org.thingsboard.server.service.cf.ctx.state.propagation.PropagationCalculatedFieldState; +import java.util.List; import java.util.Map; import java.util.Optional; import java.util.TreeMap; @@ -58,6 +62,8 @@ import java.util.UUID; import java.util.function.Function; import java.util.stream.Collectors; +import static org.thingsboard.server.common.data.cf.configuration.PropagationCalculatedFieldConfiguration.PROPAGATION_CONFIG_ARGUMENT; + public class CalculatedFieldUtils { public static CalculatedFieldIdProto toProto(CalculatedFieldId cfId) { @@ -92,12 +98,11 @@ public class CalculatedFieldUtils { .setType(state.getType().name()); state.getArguments().forEach((argName, argEntry) -> { - if (argEntry instanceof SingleValueArgumentEntry singleValueArgumentEntry) { - builder.addSingleValueArguments(toSingleValueArgumentProto(argName, singleValueArgumentEntry)); - } else if (argEntry instanceof TsRollingArgumentEntry rollingArgumentEntry) { - builder.addRollingValueArguments(toRollingArgumentProto(argName, rollingArgumentEntry)); - } else if (argEntry instanceof GeofencingArgumentEntry geofencingArgumentEntry) { - builder.addGeofencingArguments(toGeofencingArgumentProto(argName, geofencingArgumentEntry)); + switch (argEntry.getType()) { + case SINGLE_VALUE -> builder.addSingleValueArguments(toSingleValueArgumentProto(argName, (SingleValueArgumentEntry) argEntry)); + case TS_ROLLING -> builder.addRollingValueArguments(toRollingArgumentProto(argName, (TsRollingArgumentEntry) argEntry)); + case GEOFENCING -> builder.addGeofencingArguments(toGeofencingArgumentProto(argName, (GeofencingArgumentEntry) argEntry)); + case PROPAGATION -> builder.addAllPropagationEntityIds(toPropagationEntityIdsProto((PropagationArgumentEntry) argEntry)); } }); if (state instanceof AlarmCalculatedFieldState alarmState) { @@ -112,6 +117,10 @@ public class CalculatedFieldUtils { return builder.build(); } + private static List toPropagationEntityIdsProto(PropagationArgumentEntry argEntry) { + return argEntry.getPropagationEntityIds().stream().map(ProtoUtils::toProto).collect(Collectors.toList()); + } + private static AlarmRuleStateProto toAlarmRuleStateProto(AlarmRuleState ruleState) { return AlarmRuleStateProto.newBuilder() .setSeverity(Optional.ofNullable(ruleState.getSeverity()).map(Enum::name).orElse("")) @@ -178,11 +187,15 @@ public class CalculatedFieldUtils { case SCRIPT -> new ScriptCalculatedFieldState(id.entityId()); case GEOFENCING -> new GeofencingCalculatedFieldState(id.entityId()); case ALARM -> new AlarmCalculatedFieldState(id.entityId()); + case PROPAGATION -> new PropagationCalculatedFieldState(id.entityId()); }; proto.getSingleValueArgumentsList().forEach(argProto -> state.getArguments().put(argProto.getArgName(), fromSingleValueArgumentProto(argProto))); + List propagationEntityIds = proto.getPropagationEntityIdsList().stream().map(ProtoUtils::fromProto).toList(); + state.getArguments().put(PROPAGATION_CONFIG_ARGUMENT, new PropagationArgumentEntry(propagationEntityIds)); + switch (type) { case SCRIPT -> { proto.getRollingValueArgumentsList().forEach(argProto -> diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/cf/CalculatedFieldType.java b/common/data/src/main/java/org/thingsboard/server/common/data/cf/CalculatedFieldType.java index 3399808a35..7f38773c1e 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/cf/CalculatedFieldType.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/cf/CalculatedFieldType.java @@ -19,5 +19,6 @@ public enum CalculatedFieldType { SIMPLE, SCRIPT, GEOFENCING, - ALARM + ALARM, + PROPAGATION } diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/BaseCalculatedFieldConfiguration.java b/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/BaseCalculatedFieldConfiguration.java index b72cdad60a..6913b1ed63 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/BaseCalculatedFieldConfiguration.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/BaseCalculatedFieldConfiguration.java @@ -28,12 +28,16 @@ public abstract class BaseCalculatedFieldConfiguration implements ExpressionBase @Override public void validate() { + baseCalculatedFieldRestriction(); + if (arguments.values().stream().anyMatch(Argument::hasRelationQuerySource)) { + throw new IllegalArgumentException("Calculated field with type: '" + getType() + "' doesn't support relation query configuration!"); + } + } + + protected void baseCalculatedFieldRestriction() { if (arguments.containsKey("ctx")) { throw new IllegalArgumentException("Argument name 'ctx' is reserved and cannot be used."); } - if (arguments.values().stream().anyMatch(Argument::hasRelationQuerySource)) { - throw new IllegalArgumentException("Calculated field with type: '" + getType() + "' doesn't support relation query source configuration!"); - } } } diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/CalculatedFieldConfiguration.java b/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/CalculatedFieldConfiguration.java index d3622a2dcf..8676c6060f 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/CalculatedFieldConfiguration.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/CalculatedFieldConfiguration.java @@ -40,7 +40,8 @@ import java.util.stream.Collectors; @Type(value = SimpleCalculatedFieldConfiguration.class, name = "SIMPLE"), @Type(value = ScriptCalculatedFieldConfiguration.class, name = "SCRIPT"), @Type(value = GeofencingCalculatedFieldConfiguration.class, name = "GEOFENCING"), - @Type(value = AlarmCalculatedFieldConfiguration.class, name = "ALARM") + @Type(value = AlarmCalculatedFieldConfiguration.class, name = "ALARM"), + @Type(value = PropagationCalculatedFieldConfiguration.class, name = "PROPAGATION") }) @JsonIgnoreProperties(ignoreUnknown = true) public interface CalculatedFieldConfiguration { diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/PropagationCalculatedFieldConfiguration.java b/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/PropagationCalculatedFieldConfiguration.java new file mode 100644 index 0000000000..7585c30438 --- /dev/null +++ b/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/PropagationCalculatedFieldConfiguration.java @@ -0,0 +1,66 @@ +/** + * 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.common.data.cf.configuration; + +import lombok.Data; +import lombok.EqualsAndHashCode; +import org.thingsboard.server.common.data.StringUtils; +import org.thingsboard.server.common.data.cf.CalculatedFieldType; +import org.thingsboard.server.common.data.relation.EntitySearchDirection; +import org.thingsboard.server.common.data.relation.RelationPathLevel; + +import java.util.List; + +@Data +@EqualsAndHashCode(callSuper = true) +public class PropagationCalculatedFieldConfiguration extends BaseCalculatedFieldConfiguration { + + public static final String PROPAGATION_CONFIG_ARGUMENT = "propagationCtx"; + + private EntitySearchDirection direction; + private String relationType; + + @Override + public CalculatedFieldType getType() { + return CalculatedFieldType.PROPAGATION; + } + + @Override + public void validate() { + baseCalculatedFieldRestriction(); + propagationRestriction(); + if (direction == null) { + throw new IllegalArgumentException("Propagation calculated field direction must be specified!"); + } + if (StringUtils.isBlank(relationType)) { + throw new IllegalArgumentException("Propagation calculated field relation type must be specified!"); + } + } + + public Argument toPropagationArgument() { + var refDynamicSourceConfiguration = new RelationPathQueryDynamicSourceConfiguration(); + refDynamicSourceConfiguration.setLevels(List.of(new RelationPathLevel(direction, relationType))); + var propagationArgument = new Argument(); + propagationArgument.setRefDynamicSourceConfiguration(refDynamicSourceConfiguration); + return propagationArgument; + } + + private void propagationRestriction() { + if (arguments.entrySet().stream().anyMatch(entry -> entry.getKey().equals(PROPAGATION_CONFIG_ARGUMENT))) { + throw new IllegalArgumentException("Argument name '" + PROPAGATION_CONFIG_ARGUMENT + "' is reserved and cannot be used."); + } + } +} diff --git a/common/proto/src/main/proto/queue.proto b/common/proto/src/main/proto/queue.proto index fac1116a30..1e3e121202 100644 --- a/common/proto/src/main/proto/queue.proto +++ b/common/proto/src/main/proto/queue.proto @@ -922,6 +922,7 @@ message CalculatedFieldStateProto { repeated TsRollingArgumentProto rollingValueArguments = 4; repeated GeofencingArgumentProto geofencingArguments = 5; AlarmStateProto alarmState = 6; + repeated EntityIdProto propagationEntityIds = 7; } //Used to report session state to tb-Service and persist this state in the cache on the tb-Service level. diff --git a/common/script/script-api/src/main/java/org/thingsboard/script/api/tbel/TbelCfArg.java b/common/script/script-api/src/main/java/org/thingsboard/script/api/tbel/TbelCfArg.java index 73a2183564..6f83aac1b9 100644 --- a/common/script/script-api/src/main/java/org/thingsboard/script/api/tbel/TbelCfArg.java +++ b/common/script/script-api/src/main/java/org/thingsboard/script/api/tbel/TbelCfArg.java @@ -27,7 +27,8 @@ import com.fasterxml.jackson.annotation.JsonTypeInfo; @JsonSubTypes({ @JsonSubTypes.Type(value = TbelCfSingleValueArg.class, name = "SINGLE_VALUE"), @JsonSubTypes.Type(value = TbelCfTsRollingArg.class, name = "TS_ROLLING"), - @JsonSubTypes.Type(value = TbelCfTsGeofencingArg.class, name = "GEOFENCING_CF_ARGUMENT_VALUE"), + @JsonSubTypes.Type(value = TbelCfGeofencingArg.class, name = "GEOFENCING_CF_ARGUMENT_VALUE"), + @JsonSubTypes.Type(value = TbelCfPropagationArg.class, name = "PROPAGATION_CF_ARGUMENT_VALUE"), }) public interface TbelCfArg extends TbelCfObject { diff --git a/common/script/script-api/src/main/java/org/thingsboard/script/api/tbel/TbelCfTsGeofencingArg.java b/common/script/script-api/src/main/java/org/thingsboard/script/api/tbel/TbelCfGeofencingArg.java similarity index 89% rename from common/script/script-api/src/main/java/org/thingsboard/script/api/tbel/TbelCfTsGeofencingArg.java rename to common/script/script-api/src/main/java/org/thingsboard/script/api/tbel/TbelCfGeofencingArg.java index f1e8ec16db..0fa0f4a5bf 100644 --- a/common/script/script-api/src/main/java/org/thingsboard/script/api/tbel/TbelCfTsGeofencingArg.java +++ b/common/script/script-api/src/main/java/org/thingsboard/script/api/tbel/TbelCfGeofencingArg.java @@ -20,12 +20,12 @@ import com.fasterxml.jackson.annotation.JsonProperty; import lombok.Data; @Data -public class TbelCfTsGeofencingArg implements TbelCfArg { +public class TbelCfGeofencingArg implements TbelCfArg { private final Object value; @JsonCreator - public TbelCfTsGeofencingArg(@JsonProperty("value") Object value) { + public TbelCfGeofencingArg(@JsonProperty("value") Object value) { this.value = value; } diff --git a/common/script/script-api/src/main/java/org/thingsboard/script/api/tbel/TbelCfPropagationArg.java b/common/script/script-api/src/main/java/org/thingsboard/script/api/tbel/TbelCfPropagationArg.java new file mode 100644 index 0000000000..83d7e81a86 --- /dev/null +++ b/common/script/script-api/src/main/java/org/thingsboard/script/api/tbel/TbelCfPropagationArg.java @@ -0,0 +1,42 @@ +/** + * 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.script.api.tbel; + +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.Data; + +@Data +public class TbelCfPropagationArg implements TbelCfArg { + + private final Object value; + + @JsonCreator + public TbelCfPropagationArg(@JsonProperty("value") Object value) { + this.value = value; + } + + @Override + public String getType() { + return "PROPAGATION_CF_ARGUMENT_VALUE"; + } + + @Override + public long memorySize() { + return OBJ_SIZE; + } + +} From 3a8bcc4f954fa56218cc1e2a002819e03acea763 Mon Sep 17 00:00:00 2001 From: dshvaika Date: Mon, 6 Oct 2025 10:32:15 +0300 Subject: [PATCH 10/43] Fixed fromProto parsing in the CalculatedFieldUtils --- .../org/thingsboard/server/utils/CalculatedFieldUtils.java | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/application/src/main/java/org/thingsboard/server/utils/CalculatedFieldUtils.java b/application/src/main/java/org/thingsboard/server/utils/CalculatedFieldUtils.java index 69337fe2e6..51ca44fd54 100644 --- a/application/src/main/java/org/thingsboard/server/utils/CalculatedFieldUtils.java +++ b/application/src/main/java/org/thingsboard/server/utils/CalculatedFieldUtils.java @@ -193,9 +193,6 @@ public class CalculatedFieldUtils { proto.getSingleValueArgumentsList().forEach(argProto -> state.getArguments().put(argProto.getArgName(), fromSingleValueArgumentProto(argProto))); - List propagationEntityIds = proto.getPropagationEntityIdsList().stream().map(ProtoUtils::fromProto).toList(); - state.getArguments().put(PROPAGATION_CONFIG_ARGUMENT, new PropagationArgumentEntry(propagationEntityIds)); - switch (type) { case SCRIPT -> { proto.getRollingValueArgumentsList().forEach(argProto -> @@ -217,6 +214,10 @@ public class CalculatedFieldUtils { alarmState.getCreateRuleStates().put(severity, ruleState); } } + case PROPAGATION -> { + List propagationEntityIds = proto.getPropagationEntityIdsList().stream().map(ProtoUtils::fromProto).toList(); + state.getArguments().put(PROPAGATION_CONFIG_ARGUMENT, new PropagationArgumentEntry(propagationEntityIds)); + } } return state; From bbbcc583c572750a185b84ed5a739ba543722ca4 Mon Sep 17 00:00:00 2001 From: dshvaika Date: Mon, 6 Oct 2025 13:29:07 +0300 Subject: [PATCH 11/43] Added support for arguments only propagation mode --- ...tractCalculatedFieldProcessingService.java | 2 +- .../ctx/state/BaseCalculatedFieldState.java | 16 ++++++ .../cf/ctx/state/CalculatedFieldCtx.java | 23 +++++--- .../cf/ctx/state/CalculatedFieldState.java | 4 +- .../ctx/state/SimpleCalculatedFieldState.java | 11 +--- .../GeofencingCalculatedFieldState.java | 8 +-- .../propagation/PropagationArgumentEntry.java | 1 + .../PropagationCalculatedFieldState.java | 53 ++++++++++++++++--- ...opagationCalculatedFieldConfiguration.java | 15 ++++++ 9 files changed, 102 insertions(+), 31 deletions(-) diff --git a/application/src/main/java/org/thingsboard/server/service/cf/AbstractCalculatedFieldProcessingService.java b/application/src/main/java/org/thingsboard/server/service/cf/AbstractCalculatedFieldProcessingService.java index 232476c15a..f5ec782992 100644 --- a/application/src/main/java/org/thingsboard/server/service/cf/AbstractCalculatedFieldProcessingService.java +++ b/application/src/main/java/org/thingsboard/server/service/cf/AbstractCalculatedFieldProcessingService.java @@ -88,7 +88,7 @@ public abstract class AbstractCalculatedFieldProcessingService { protected abstract String getExecutorNamePrefix(); protected ListenableFuture> fetchArguments(CalculatedFieldCtx ctx, EntityId entityId, long ts) { - Map> argFutures = switch (ctx.getCalculatedField().getType()) { + Map> argFutures = switch (ctx.getCfType()) { case GEOFENCING -> fetchGeofencingCalculatedFieldArguments(ctx, entityId, false, ts); case SIMPLE, SCRIPT, ALARM, PROPAGATION -> getBaseCalculatedFieldArguments(ctx, entityId, ts); }; diff --git a/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/BaseCalculatedFieldState.java b/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/BaseCalculatedFieldState.java index d027a2f9fe..c6af49bf4d 100644 --- a/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/BaseCalculatedFieldState.java +++ b/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/BaseCalculatedFieldState.java @@ -15,7 +15,9 @@ */ package org.thingsboard.server.service.cf.ctx.state; +import com.fasterxml.jackson.databind.node.ObjectNode; import lombok.Getter; +import org.thingsboard.common.util.JacksonUtil; import org.thingsboard.server.common.data.id.EntityId; import org.thingsboard.server.service.cf.ctx.CalculatedFieldEntityCtxId; import org.thingsboard.server.utils.CalculatedFieldUtils; @@ -105,6 +107,20 @@ public abstract class BaseCalculatedFieldState implements CalculatedFieldState { protected void validateNewEntry(String key, ArgumentEntry newEntry) {} + protected ObjectNode toSimpleResult(boolean useLatestTs, ObjectNode valuesNode) { + if (!useLatestTs) { + return valuesNode; + } + long latestTs = getLatestTimestamp(); + if (latestTs == -1) { + return valuesNode; + } + ObjectNode resultNode = JacksonUtil.newObjectNode(); + resultNode.put("ts", latestTs); + resultNode.set("values", valuesNode); + return resultNode; + } + private void updateLastUpdateTimestamp(ArgumentEntry entry) { long newTs = this.latestTimestamp; if (entry instanceof SingleValueArgumentEntry singleValueArgumentEntry) { 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 ece459350f..339ba40838 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 @@ -103,6 +103,7 @@ public class CalculatedFieldCtx { private long scheduledUpdateIntervalMillis; private Argument propagationArgument; + private boolean applyExpressionForResolvedArguments; public CalculatedFieldCtx(CalculatedField calculatedField, ActorSystemContext systemContext) { @@ -159,6 +160,7 @@ public class CalculatedFieldCtx { } if (calculatedField.getConfiguration() instanceof PropagationCalculatedFieldConfiguration propagationConfig) { propagationArgument = propagationConfig.toPropagationArgument(); + applyExpressionForResolvedArguments = propagationConfig.isApplyExpressionToResolvedArguments(); relationQueryDynamicArguments = true; } } @@ -177,13 +179,13 @@ public class CalculatedFieldCtx { public void init() { switch (cfType) { - case SCRIPT, PROPAGATION -> { - try { - initTbelExpression(expression); - initialized = true; - } catch (Exception e) { - throw new RuntimeException("Failed to init calculated field ctx. Invalid expression syntax.", e); + case SCRIPT -> initTbelExpression(); + case PROPAGATION -> { + if (applyExpressionForResolvedArguments) { + initTbelExpression(); + return; } + initialized = true; } case GEOFENCING -> initialized = true; case SIMPLE -> { @@ -206,6 +208,15 @@ public class CalculatedFieldCtx { } } + private void initTbelExpression() { + try { + initTbelExpression(expression); + initialized = true; + } catch (Exception e) { + throw new RuntimeException("Failed to init calculated field ctx. Invalid expression syntax.", e); + } + } + public double evaluateSimpleExpression(String expressionStr, CalculatedFieldState state) { Expression expression = simpleExpressions.get(expressionStr).get(); for (Map.Entry entry : state.getArguments().entrySet()) { diff --git a/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/CalculatedFieldState.java b/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/CalculatedFieldState.java index ad8005af83..ecd8b0d0f7 100644 --- a/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/CalculatedFieldState.java +++ b/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/CalculatedFieldState.java @@ -26,6 +26,7 @@ import org.thingsboard.server.service.cf.ctx.CalculatedFieldEntityCtxId; import org.thingsboard.server.service.cf.ctx.state.alarm.AlarmCalculatedFieldState; import org.thingsboard.server.service.cf.ctx.state.geofencing.GeofencingArgumentEntry; import org.thingsboard.server.service.cf.ctx.state.geofencing.GeofencingCalculatedFieldState; +import org.thingsboard.server.service.cf.ctx.state.propagation.PropagationCalculatedFieldState; import java.util.Map; @@ -36,7 +37,8 @@ import static org.thingsboard.server.utils.CalculatedFieldUtils.toSingleValueArg @Type(value = SimpleCalculatedFieldState.class, name = "SIMPLE"), @Type(value = ScriptCalculatedFieldState.class, name = "SCRIPT"), @Type(value = GeofencingCalculatedFieldState.class, name = "GEOFENCING"), - @Type(value = AlarmCalculatedFieldState.class, name = "ALARM") + @Type(value = AlarmCalculatedFieldState.class, name = "ALARM"), + @Type(value = PropagationCalculatedFieldState.class, name = "PROPAGATION") }) public interface CalculatedFieldState { diff --git a/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/SimpleCalculatedFieldState.java b/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/SimpleCalculatedFieldState.java index 3a98fee361..bd5ec720df 100644 --- a/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/SimpleCalculatedFieldState.java +++ b/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/SimpleCalculatedFieldState.java @@ -84,16 +84,7 @@ public class SimpleCalculatedFieldState extends BaseCalculatedFieldState { } else { valuesNode.set(outputName, JacksonUtil.valueToTree(result)); } - - long latestTs = getLatestTimestamp(); - if (useLatestTs && latestTs != -1) { - ObjectNode resultNode = JacksonUtil.newObjectNode(); - resultNode.put("ts", latestTs); - resultNode.set("values", valuesNode); - return resultNode; - } else { - return valuesNode; - } + return toSimpleResult(useLatestTs, valuesNode); } } diff --git a/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/geofencing/GeofencingCalculatedFieldState.java b/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/geofencing/GeofencingCalculatedFieldState.java index f3bf8750cf..da1acc1905 100644 --- a/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/geofencing/GeofencingCalculatedFieldState.java +++ b/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/geofencing/GeofencingCalculatedFieldState.java @@ -172,13 +172,7 @@ public class GeofencingCalculatedFieldState extends BaseCalculatedFieldState { } private JsonNode toResultNode(OutputType outputType, ObjectNode valuesNode) { - if (OutputType.ATTRIBUTES.equals(outputType) || latestTimestamp == -1) { - return valuesNode; - } - ObjectNode resultNode = JacksonUtil.newObjectNode(); - resultNode.put("ts", latestTimestamp); - resultNode.set("values", valuesNode); - return resultNode; + return toSimpleResult(outputType == OutputType.TIME_SERIES, valuesNode); } private GeofencingEvalResult aggregateZoneGroup(List zoneResults) { diff --git a/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/propagation/PropagationArgumentEntry.java b/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/propagation/PropagationArgumentEntry.java index c7d49a4d40..f09ae93999 100644 --- a/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/propagation/PropagationArgumentEntry.java +++ b/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/propagation/PropagationArgumentEntry.java @@ -32,6 +32,7 @@ public class PropagationArgumentEntry implements ArgumentEntry { private boolean forceResetPrevious; + // TODO: do we need to persist this? public PropagationArgumentEntry(List propagationEntityIds) { this.propagationEntityIds = propagationEntityIds; } diff --git a/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/propagation/PropagationCalculatedFieldState.java b/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/propagation/PropagationCalculatedFieldState.java index 21a4493c91..9554b28024 100644 --- a/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/propagation/PropagationCalculatedFieldState.java +++ b/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/propagation/PropagationCalculatedFieldState.java @@ -15,10 +15,14 @@ */ package org.thingsboard.server.service.cf.ctx.state.propagation; +import com.fasterxml.jackson.databind.node.ObjectNode; import com.google.common.util.concurrent.Futures; import com.google.common.util.concurrent.ListenableFuture; import com.google.common.util.concurrent.MoreExecutors; +import org.thingsboard.common.util.JacksonUtil; import org.thingsboard.server.common.data.cf.CalculatedFieldType; +import org.thingsboard.server.common.data.cf.configuration.Output; +import org.thingsboard.server.common.data.cf.configuration.OutputType; import org.thingsboard.server.common.data.id.EntityId; import org.thingsboard.server.service.cf.CalculatedFieldResult; import org.thingsboard.server.service.cf.PropagationCalculatedFieldResult; @@ -26,6 +30,7 @@ import org.thingsboard.server.service.cf.TelemetryCalculatedFieldResult; import org.thingsboard.server.service.cf.ctx.state.ArgumentEntry; import org.thingsboard.server.service.cf.ctx.state.CalculatedFieldCtx; import org.thingsboard.server.service.cf.ctx.state.ScriptCalculatedFieldState; +import org.thingsboard.server.service.cf.ctx.state.SingleValueArgumentEntry; import java.util.Map; @@ -37,6 +42,12 @@ public class PropagationCalculatedFieldState extends ScriptCalculatedFieldState super(entityId); } + @Override + public void init(CalculatedFieldCtx ctx) { + super.init(ctx); + requiredArguments.add(PROPAGATION_CONFIG_ARGUMENT); + } + @Override public CalculatedFieldType getType() { return CalculatedFieldType.PROPAGATION; @@ -48,12 +59,42 @@ public class PropagationCalculatedFieldState extends ScriptCalculatedFieldState if (!(argumentEntry instanceof PropagationArgumentEntry propagationArgumentEntry) || propagationArgumentEntry.isEmpty()) { return Futures.immediateFuture(PropagationCalculatedFieldResult.builder().build()); } - return Futures.transform(super.performCalculation(updatedArgs, ctx), telemetryCfResult -> - PropagationCalculatedFieldResult.builder() - .propagationEntityIds(propagationArgumentEntry.getPropagationEntityIds()) - .result((TelemetryCalculatedFieldResult) telemetryCfResult) - .build(), - MoreExecutors.directExecutor()); + if (ctx.isApplyExpressionForResolvedArguments()) { + return Futures.transform(super.performCalculation(updatedArgs, ctx), telemetryCfResult -> + PropagationCalculatedFieldResult.builder() + .propagationEntityIds(propagationArgumentEntry.getPropagationEntityIds()) + .result((TelemetryCalculatedFieldResult) telemetryCfResult) + .build(), + MoreExecutors.directExecutor()); + } + return Futures.immediateFuture(PropagationCalculatedFieldResult.builder() + .propagationEntityIds(propagationArgumentEntry.getPropagationEntityIds()) + .result(toTelemetryResult(ctx)) + .build()); + } + + private TelemetryCalculatedFieldResult toTelemetryResult(CalculatedFieldCtx ctx) { + Output output = ctx.getOutput(); + TelemetryCalculatedFieldResult.TelemetryCalculatedFieldResultBuilder telemetryCfBuilder = + TelemetryCalculatedFieldResult.builder() + .type(output.getType()) + .scope(output.getScope()); + ObjectNode valuesNode = JacksonUtil.newObjectNode(); + arguments.forEach((argumentName, argumentEntry) -> { + if (argumentEntry instanceof PropagationArgumentEntry) { + return; + } + if (argumentEntry instanceof SingleValueArgumentEntry singleArgumentEntry) { + // TODO: use argumentName as a key or no? + JacksonUtil.addKvEntry(valuesNode, singleArgumentEntry.getKvEntryValue(), argumentName); + return; + } + throw new IllegalArgumentException("Unsupported argument type: " + argumentEntry.getType() + " detected for argument: " + argumentName + ". " + + "Only Latest telemetry or Attribute arguments supported for 'Arguments Only' propagation mode!"); + }); + ObjectNode result = toSimpleResult(output.getType() == OutputType.TIME_SERIES, valuesNode); + telemetryCfBuilder.result(result); + return telemetryCfBuilder.build(); } } diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/PropagationCalculatedFieldConfiguration.java b/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/PropagationCalculatedFieldConfiguration.java index 7585c30438..b592264d6c 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/PropagationCalculatedFieldConfiguration.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/PropagationCalculatedFieldConfiguration.java @@ -33,6 +33,8 @@ public class PropagationCalculatedFieldConfiguration extends BaseCalculatedField private EntitySearchDirection direction; private String relationType; + private boolean applyExpressionToResolvedArguments; + @Override public CalculatedFieldType getType() { return CalculatedFieldType.PROPAGATION; @@ -48,6 +50,19 @@ public class PropagationCalculatedFieldConfiguration extends BaseCalculatedField if (StringUtils.isBlank(relationType)) { throw new IllegalArgumentException("Propagation calculated field relation type must be specified!"); } + if (!applyExpressionToResolvedArguments) { + arguments.forEach((name, argument) -> { + if (argument.getRefEntityKey() == null) { + throw new IllegalArgumentException("Argument: '" + name + "' doesn't have reference entity key configured!"); + } + if (argument.getRefEntityKey().getType() == ArgumentType.TS_ROLLING) { + throw new IllegalArgumentException("Argument type: 'Time series rolling' detected for argument: '" + name + "'! " + + "Only 'Attribute' or 'Latest telemetry' arguments are allowed for in 'Arguments only' propagation mode!"); + } + }); + } else if (StringUtils.isBlank(expression)) { + throw new IllegalArgumentException("Expression must be specified for 'Expression result' propagation mode!"); + } } public Argument toPropagationArgument() { From e8caf189682641dbe1e60e39bd29c74003b02b21 Mon Sep 17 00:00:00 2001 From: dshvaika Date: Mon, 6 Oct 2025 15:33:21 +0300 Subject: [PATCH 12/43] Resolved TODOs & removed propagation argument persistence logic --- .../propagation/PropagationArgumentEntry.java | 1 - .../PropagationCalculatedFieldState.java | 3 +- .../server/utils/CalculatedFieldUtils.java | 14 ------ .../utils/CalculatedFieldUtilsTest.java | 44 +++++++++++++++++++ common/proto/src/main/proto/queue.proto | 1 - 5 files changed, 45 insertions(+), 18 deletions(-) diff --git a/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/propagation/PropagationArgumentEntry.java b/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/propagation/PropagationArgumentEntry.java index f09ae93999..c7d49a4d40 100644 --- a/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/propagation/PropagationArgumentEntry.java +++ b/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/propagation/PropagationArgumentEntry.java @@ -32,7 +32,6 @@ public class PropagationArgumentEntry implements ArgumentEntry { private boolean forceResetPrevious; - // TODO: do we need to persist this? public PropagationArgumentEntry(List propagationEntityIds) { this.propagationEntityIds = propagationEntityIds; } diff --git a/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/propagation/PropagationCalculatedFieldState.java b/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/propagation/PropagationCalculatedFieldState.java index 9554b28024..d039d7aa7b 100644 --- a/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/propagation/PropagationCalculatedFieldState.java +++ b/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/propagation/PropagationCalculatedFieldState.java @@ -85,8 +85,7 @@ public class PropagationCalculatedFieldState extends ScriptCalculatedFieldState return; } if (argumentEntry instanceof SingleValueArgumentEntry singleArgumentEntry) { - // TODO: use argumentName as a key or no? - JacksonUtil.addKvEntry(valuesNode, singleArgumentEntry.getKvEntryValue(), argumentName); + JacksonUtil.addKvEntry(valuesNode, singleArgumentEntry.getKvEntryValue()); return; } throw new IllegalArgumentException("Unsupported argument type: " + argumentEntry.getType() + " detected for argument: " + argumentName + ". " + diff --git a/application/src/main/java/org/thingsboard/server/utils/CalculatedFieldUtils.java b/application/src/main/java/org/thingsboard/server/utils/CalculatedFieldUtils.java index 51ca44fd54..330dc89352 100644 --- a/application/src/main/java/org/thingsboard/server/utils/CalculatedFieldUtils.java +++ b/application/src/main/java/org/thingsboard/server/utils/CalculatedFieldUtils.java @@ -32,7 +32,6 @@ import org.thingsboard.server.gen.transport.TransportProtos.AlarmStateProto; import org.thingsboard.server.gen.transport.TransportProtos.CalculatedFieldEntityCtxIdProto; import org.thingsboard.server.gen.transport.TransportProtos.CalculatedFieldIdProto; import org.thingsboard.server.gen.transport.TransportProtos.CalculatedFieldStateProto; -import org.thingsboard.server.gen.transport.TransportProtos.EntityIdProto; import org.thingsboard.server.gen.transport.TransportProtos.GeofencingArgumentProto; import org.thingsboard.server.gen.transport.TransportProtos.GeofencingZoneProto; import org.thingsboard.server.gen.transport.TransportProtos.SingleValueArgumentProto; @@ -51,10 +50,8 @@ import org.thingsboard.server.service.cf.ctx.state.alarm.AlarmRuleState; import org.thingsboard.server.service.cf.ctx.state.geofencing.GeofencingArgumentEntry; import org.thingsboard.server.service.cf.ctx.state.geofencing.GeofencingCalculatedFieldState; import org.thingsboard.server.service.cf.ctx.state.geofencing.GeofencingZoneState; -import org.thingsboard.server.service.cf.ctx.state.propagation.PropagationArgumentEntry; import org.thingsboard.server.service.cf.ctx.state.propagation.PropagationCalculatedFieldState; -import java.util.List; import java.util.Map; import java.util.Optional; import java.util.TreeMap; @@ -62,8 +59,6 @@ import java.util.UUID; import java.util.function.Function; import java.util.stream.Collectors; -import static org.thingsboard.server.common.data.cf.configuration.PropagationCalculatedFieldConfiguration.PROPAGATION_CONFIG_ARGUMENT; - public class CalculatedFieldUtils { public static CalculatedFieldIdProto toProto(CalculatedFieldId cfId) { @@ -102,7 +97,6 @@ public class CalculatedFieldUtils { case SINGLE_VALUE -> builder.addSingleValueArguments(toSingleValueArgumentProto(argName, (SingleValueArgumentEntry) argEntry)); case TS_ROLLING -> builder.addRollingValueArguments(toRollingArgumentProto(argName, (TsRollingArgumentEntry) argEntry)); case GEOFENCING -> builder.addGeofencingArguments(toGeofencingArgumentProto(argName, (GeofencingArgumentEntry) argEntry)); - case PROPAGATION -> builder.addAllPropagationEntityIds(toPropagationEntityIdsProto((PropagationArgumentEntry) argEntry)); } }); if (state instanceof AlarmCalculatedFieldState alarmState) { @@ -117,10 +111,6 @@ public class CalculatedFieldUtils { return builder.build(); } - private static List toPropagationEntityIdsProto(PropagationArgumentEntry argEntry) { - return argEntry.getPropagationEntityIds().stream().map(ProtoUtils::toProto).collect(Collectors.toList()); - } - private static AlarmRuleStateProto toAlarmRuleStateProto(AlarmRuleState ruleState) { return AlarmRuleStateProto.newBuilder() .setSeverity(Optional.ofNullable(ruleState.getSeverity()).map(Enum::name).orElse("")) @@ -214,10 +204,6 @@ public class CalculatedFieldUtils { alarmState.getCreateRuleStates().put(severity, ruleState); } } - case PROPAGATION -> { - List propagationEntityIds = proto.getPropagationEntityIdsList().stream().map(ProtoUtils::fromProto).toList(); - state.getArguments().put(PROPAGATION_CONFIG_ARGUMENT, new PropagationArgumentEntry(propagationEntityIds)); - } } return state; diff --git a/application/src/test/java/org/thingsboard/server/utils/CalculatedFieldUtilsTest.java b/application/src/test/java/org/thingsboard/server/utils/CalculatedFieldUtilsTest.java index 40a7a14e1c..0d57f90206 100644 --- a/application/src/test/java/org/thingsboard/server/utils/CalculatedFieldUtilsTest.java +++ b/application/src/test/java/org/thingsboard/server/utils/CalculatedFieldUtilsTest.java @@ -26,24 +26,31 @@ import org.thingsboard.server.common.data.id.EntityId; import org.thingsboard.server.common.data.id.TenantId; import org.thingsboard.server.common.data.kv.BaseAttributeKvEntry; import org.thingsboard.server.common.data.kv.JsonDataEntry; +import org.thingsboard.server.common.data.kv.StringDataEntry; import org.thingsboard.server.gen.transport.TransportProtos.CalculatedFieldStateProto; import org.thingsboard.server.service.cf.ctx.CalculatedFieldEntityCtxId; import org.thingsboard.server.service.cf.ctx.state.ArgumentEntry; import org.thingsboard.server.service.cf.ctx.state.CalculatedFieldCtx; import org.thingsboard.server.service.cf.ctx.state.CalculatedFieldState; +import org.thingsboard.server.service.cf.ctx.state.SingleValueArgumentEntry; import org.thingsboard.server.service.cf.ctx.state.geofencing.GeofencingArgumentEntry; import org.thingsboard.server.service.cf.ctx.state.geofencing.GeofencingCalculatedFieldState; import org.thingsboard.server.service.cf.ctx.state.geofencing.GeofencingZoneState; +import org.thingsboard.server.service.cf.ctx.state.propagation.PropagationArgumentEntry; +import org.thingsboard.server.service.cf.ctx.state.propagation.PropagationCalculatedFieldState; import java.util.LinkedHashMap; +import java.util.List; import java.util.Map; import java.util.UUID; import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.BDDMockito.given; import static org.mockito.Mockito.mock; +import static org.thingsboard.server.common.data.cf.configuration.PropagationCalculatedFieldConfiguration.PROPAGATION_CONFIG_ARGUMENT; import static org.thingsboard.server.utils.CalculatedFieldUtils.toProto; + @ExtendWith(MockitoExtension.class) class CalculatedFieldUtilsTest { @@ -105,4 +112,41 @@ class CalculatedFieldUtilsTest { assertThat(fromProtoGeoArgument.getZoneStates().get(z2).getLastPresence()).isNull(); } + @Test + void toProtoAndFromProto_shouldCreatePropagationStateWithoutPropagationArgument() { + // given + CalculatedFieldEntityCtxId stateId = mock(CalculatedFieldEntityCtxId.class); + given(stateId.tenantId()).willReturn(TENANT_ID); + given(stateId.cfId()).willReturn(CF_ID); + given(stateId.entityId()).willReturn(DEVICE_ID); + + AssetId propagationAssetId = new AssetId(UUID.fromString("17bbf99c-3b87-4d21-b07d-da7409bb2bb7")); + PropagationArgumentEntry propagationArgumentEntry = new PropagationArgumentEntry(List.of(propagationAssetId)); + + long lastUpdateTs = System.currentTimeMillis(); + SingleValueArgumentEntry singleValueArgumentEntry = new SingleValueArgumentEntry(new BaseAttributeKvEntry(new StringDataEntry("state", "active"), lastUpdateTs, 1L)); + + CalculatedFieldCtx cfCtxMock = mock(CalculatedFieldCtx.class); + + CalculatedFieldState state = new PropagationCalculatedFieldState(DEVICE_ID); + state.update(Map.of(PROPAGATION_CONFIG_ARGUMENT, propagationArgumentEntry, "state", singleValueArgumentEntry), cfCtxMock); + + // when + CalculatedFieldStateProto proto = toProto(stateId, state); + + // then + CalculatedFieldState restored = CalculatedFieldUtils.fromProto(stateId, proto); + + // Propagation argument is not persisted -> should be absent after restore + assertThat(restored).isNotNull(); + assertThat(restored).isInstanceOf(PropagationCalculatedFieldState.class); + + PropagationCalculatedFieldState propagationState = (PropagationCalculatedFieldState) restored; + + assertThat(propagationState.getEntityId()).isEqualTo(DEVICE_ID); + assertThat(propagationState.getArguments()).isNotNull(); + assertThat(propagationState.getArguments().get(PROPAGATION_CONFIG_ARGUMENT)).isNull(); + assertThat(propagationState.getArguments().get("state")).isNotNull().isEqualTo(singleValueArgumentEntry); + } + } diff --git a/common/proto/src/main/proto/queue.proto b/common/proto/src/main/proto/queue.proto index 1e3e121202..fac1116a30 100644 --- a/common/proto/src/main/proto/queue.proto +++ b/common/proto/src/main/proto/queue.proto @@ -922,7 +922,6 @@ message CalculatedFieldStateProto { repeated TsRollingArgumentProto rollingValueArguments = 4; repeated GeofencingArgumentProto geofencingArguments = 5; AlarmStateProto alarmState = 6; - repeated EntityIdProto propagationEntityIds = 7; } //Used to report session state to tb-Service and persist this state in the cache on the tb-Service level. From c339aef443bc3e4bec6b40f592b40796a4d2327c Mon Sep 17 00:00:00 2001 From: dshvaika Date: Mon, 6 Oct 2025 18:08:23 +0300 Subject: [PATCH 13/43] Added PropagationArgumentEntryTest & PropagationCalculatedFieldStateTest & integration tests for propagation CF --- .../PropagationCalculatedFieldState.java | 9 +- .../cf/CalculatedFieldIntegrationTest.java | 166 ++++++++++++ .../state/PropagationArgumentEntryTest.java | 143 ++++++++++ .../PropagationCalculatedFieldStateTest.java | 246 ++++++++++++++++++ .../utils/CalculatedFieldUtilsTest.java | 5 +- 5 files changed, 562 insertions(+), 7 deletions(-) create mode 100644 application/src/test/java/org/thingsboard/server/service/cf/ctx/state/PropagationArgumentEntryTest.java create mode 100644 application/src/test/java/org/thingsboard/server/service/cf/ctx/state/PropagationCalculatedFieldStateTest.java diff --git a/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/propagation/PropagationCalculatedFieldState.java b/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/propagation/PropagationCalculatedFieldState.java index d039d7aa7b..23c4af5bce 100644 --- a/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/propagation/PropagationCalculatedFieldState.java +++ b/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/propagation/PropagationCalculatedFieldState.java @@ -43,9 +43,12 @@ public class PropagationCalculatedFieldState extends ScriptCalculatedFieldState } @Override - public void init(CalculatedFieldCtx ctx) { - super.init(ctx); - requiredArguments.add(PROPAGATION_CONFIG_ARGUMENT); + public boolean isReady() { + if (!super.isReady()) { + return false; + } + ArgumentEntry propagationArg = arguments.get(PROPAGATION_CONFIG_ARGUMENT); + return propagationArg != null && !propagationArg.isEmpty(); } @Override diff --git a/application/src/test/java/org/thingsboard/server/cf/CalculatedFieldIntegrationTest.java b/application/src/test/java/org/thingsboard/server/cf/CalculatedFieldIntegrationTest.java index 5d95a572b8..92e4438ff3 100644 --- a/application/src/test/java/org/thingsboard/server/cf/CalculatedFieldIntegrationTest.java +++ b/application/src/test/java/org/thingsboard/server/cf/CalculatedFieldIntegrationTest.java @@ -17,6 +17,7 @@ package org.thingsboard.server.cf; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.node.ArrayNode; +import com.fasterxml.jackson.databind.node.NullNode; import com.fasterxml.jackson.databind.node.ObjectNode; import org.junit.Test; import org.thingsboard.common.util.JacksonUtil; @@ -24,6 +25,7 @@ import org.thingsboard.server.common.data.AttributeScope; import org.thingsboard.server.common.data.DataConstants; import org.thingsboard.server.common.data.Device; import org.thingsboard.server.common.data.EntityInfo; +import org.thingsboard.server.common.data.EntityType; import org.thingsboard.server.common.data.TenantProfile; import org.thingsboard.server.common.data.asset.Asset; import org.thingsboard.server.common.data.asset.AssetProfile; @@ -34,6 +36,7 @@ import org.thingsboard.server.common.data.cf.configuration.ArgumentType; import org.thingsboard.server.common.data.cf.configuration.CalculatedFieldConfiguration; import org.thingsboard.server.common.data.cf.configuration.Output; import org.thingsboard.server.common.data.cf.configuration.OutputType; +import org.thingsboard.server.common.data.cf.configuration.PropagationCalculatedFieldConfiguration; import org.thingsboard.server.common.data.cf.configuration.ReferencedEntityKey; import org.thingsboard.server.common.data.cf.configuration.RelationPathQueryDynamicSourceConfiguration; import org.thingsboard.server.common.data.cf.configuration.ScriptCalculatedFieldConfiguration; @@ -944,6 +947,169 @@ public class CalculatedFieldIntegrationTest extends CalculatedFieldControllerTes }); } + @Test + public void testPropagationCalculatedField_withExpression() throws Exception { + // --- Arrange entities --- + Device device = createDevice("Propagation Device With Expression", "sn-prop-1"); + Asset asset1 = createAsset("Propagated Asset 1", null); + Asset asset2 = createAsset("Propagated Asset 2", null); + + // Create relations FROM assets TO device + EntityRelation rel1 = new EntityRelation(asset1.getId(), device.getId(), EntityRelation.CONTAINS_TYPE); + EntityRelation rel2 = new EntityRelation(asset2.getId(), device.getId(), EntityRelation.CONTAINS_TYPE); + doPost("/api/relation", rel1).andExpect(status().isOk()); + doPost("/api/relation", rel2).andExpect(status().isOk()); + + // Telemetry on device + doPost("/api/plugins/telemetry/DEVICE/" + device.getUuidId() + "/timeseries/unusedScope", + JacksonUtil.toJsonNode("{\"temperature\":12.5}")).andExpect(status().isOk()); + + // --- Build CF: PROPAGATION with expression --- + CalculatedField cf = new CalculatedField(); + cf.setEntityId(device.getId()); + cf.setType(CalculatedFieldType.PROPAGATION); + cf.setName("Propagation CF (expr)"); + cf.setConfigurationVersion(1); + + PropagationCalculatedFieldConfiguration cfg = new PropagationCalculatedFieldConfiguration(); + cfg.setDirection(EntitySearchDirection.TO); + cfg.setRelationType(EntityRelation.CONTAINS_TYPE); + cfg.setApplyExpressionToResolvedArguments(true); + + Argument arg = new Argument(); + arg.setRefEntityKey(new ReferencedEntityKey("temperature", ArgumentType.TS_LATEST, null)); + cfg.setArguments(Map.of("t", arg)); + + cfg.setExpression("{\"testResult\": t * 2}"); + + Output output = new Output(); + output.setType(OutputType.ATTRIBUTES); + output.setScope(AttributeScope.SERVER_SCOPE); + cfg.setOutput(output); + + cf.setConfiguration(cfg); + + doPost("/api/calculatedField", cf, CalculatedField.class); + + // --- Assert propagated calculation (expression applied) --- + await().alias("propagation expr mode evaluation") + .atMost(TIMEOUT, TimeUnit.SECONDS) + .pollInterval(POLL_INTERVAL, TimeUnit.SECONDS) + .untilAsserted(() -> { + ArrayNode attrs1 = getServerAttributes(asset1.getId(), "testResult"); + ArrayNode attrs2 = getServerAttributes(asset2.getId(), "testResult"); + assertThat(attrs1).isNotNull(); + assertThat(attrs2).isNotNull(); + assertThat(attrs1.get(0).get("value").asDouble()).isEqualTo(25.0); + assertThat(attrs2.get(0).get("value").asDouble()).isEqualTo(25.0); + }); + + String deleteUrl = String.format("/api/v2/relation?fromId=%s&fromType=%s&relationType=%s&toId=%s&toType=%s", + asset1.getId().getId(), EntityType.ASSET, + EntityRelation.CONTAINS_TYPE, device.getId().getId(), EntityType.DEVICE + ); + doDelete(deleteUrl).andExpect(status().isOk()); + doDelete("/api/plugins/telemetry/ASSET/" + asset1.getId() + "/SERVER_SCOPE?keys=testResult").andExpect(status().isOk()); + + doPost("/api/plugins/telemetry/DEVICE/" + device.getUuidId() + "/timeseries/unusedScope", + JacksonUtil.toJsonNode("{\"temperature\":25}")).andExpect(status().isOk()); + + // --- Assert propagated calculation (expression applied with new temperature argument and one relation removed) --- + await().alias("propagation expr mode evaluation after temperature update") + .atMost(TIMEOUT, TimeUnit.SECONDS) + .pollInterval(POLL_INTERVAL, TimeUnit.SECONDS) + .untilAsserted(() -> { + ArrayNode attrs1 = getServerAttributes(asset1.getId(), "testResult"); + ArrayNode attrs2 = getServerAttributes(asset2.getId(), "testResult"); + assertThat(attrs1).isNullOrEmpty(); + assertThat(attrs2).isNotNull(); + assertThat(attrs2.get(0).get("value").asDouble()).isEqualTo(50); + }); + } + + @Test + public void testPropagationCalculatedField_withoutExpression() throws Exception { + // --- Arrange entities --- + Device device = createDevice("Propagation Device Without Expression", "sn-prop-2"); + Asset asset1 = createAsset("Propagated Asset 1", null); + Asset asset2 = createAsset("Propagated Asset 2", null); + + // Create relations FROM assets TO device + EntityRelation rel1 = new EntityRelation(asset1.getId(), device.getId(), EntityRelation.CONTAINS_TYPE); + EntityRelation rel2 = new EntityRelation(asset2.getId(), device.getId(), EntityRelation.CONTAINS_TYPE); + doPost("/api/relation", rel1).andExpect(status().isOk()); + doPost("/api/relation", rel2).andExpect(status().isOk()); + + // Telemetry on device + long ts = System.currentTimeMillis() - 300000L; + postTelemetry(device.getId(), String.format("{\"ts\": %s, \"values\": {\"temperature\":12.5}}", ts)); + + // --- Build CF: PROPAGATION without expression --- + CalculatedField cf = new CalculatedField(); + cf.setEntityId(device.getId()); + cf.setType(CalculatedFieldType.PROPAGATION); + cf.setName("Propagation CF (args-only)"); + cf.setConfigurationVersion(1); + + PropagationCalculatedFieldConfiguration cfg = new PropagationCalculatedFieldConfiguration(); + cfg.setDirection(EntitySearchDirection.TO); + cfg.setRelationType(EntityRelation.CONTAINS_TYPE); + cfg.setApplyExpressionToResolvedArguments(false); // arguments-only mode + + Argument arg = new Argument(); + arg.setRefEntityKey(new ReferencedEntityKey("temperature", ArgumentType.TS_LATEST, null)); + cfg.setArguments(Map.of("t", arg)); + + Output output = new Output(); + output.setType(OutputType.TIME_SERIES); + cfg.setOutput(output); + + cf.setConfiguration(cfg); + + doPost("/api/calculatedField", cf, CalculatedField.class); + + // --- Assert propagated calculation (arguments-only mode) --- + await().alias("propagation args-only evaluation") + .atMost(TIMEOUT, TimeUnit.SECONDS) + .pollInterval(POLL_INTERVAL, TimeUnit.SECONDS) + .untilAsserted(() -> { + ObjectNode telemetry1 = getLatestTelemetry(asset1.getId(), "temperature"); + ObjectNode telemetry2 = getLatestTelemetry(asset2.getId(), "temperature"); + assertThat(telemetry1).isNotNull(); + assertThat(telemetry2).isNotNull(); + assertThat(telemetry1.get("temperature").get(0).get("ts").asText()).isEqualTo(Long.toString(ts)); + assertThat(telemetry1.get("temperature").get(0).get("value").asDouble()).isEqualTo(12.5); + assertThat(telemetry2.get("temperature").get(0).get("ts").asText()).isEqualTo(Long.toString(ts)); + assertThat(telemetry2.get("temperature").get(0).get("value").asDouble()).isEqualTo(12.5); + }); + + String deleteUrl = String.format("/api/v2/relation?fromId=%s&fromType=%s&relationType=%s&toId=%s&toType=%s", + asset1.getId().getId(), EntityType.ASSET, + EntityRelation.CONTAINS_TYPE, device.getId().getId(), EntityType.DEVICE + ); + doDelete(deleteUrl).andExpect(status().isOk()); + doDelete("/api/plugins/telemetry/ASSET/" + asset1.getId() + "/timeseries/delete?keys=temperature&deleteAllDataForKeys=true").andExpect(status().isOk()); + + // Update telemetry on device + long newTs = System.currentTimeMillis() - 300000L; + postTelemetry(device.getId(), String.format("{\"ts\": %s, \"values\": {\"temperature\":25}}", newTs)); + + // --- Assert propagated calculation (arguments-only mode after update) --- + await().alias("propagation args-only evaluation after temperature update") + .atMost(TIMEOUT, TimeUnit.SECONDS) + .pollInterval(POLL_INTERVAL, TimeUnit.SECONDS) + .untilAsserted(() -> { + ObjectNode telemetry1 = getLatestTelemetry(asset1.getId(), "temperature"); + ObjectNode telemetry2 = getLatestTelemetry(asset2.getId(), "temperature"); + assertThat(telemetry1).isNotNull(); + assertThat(telemetry2).isNotNull(); + assertThat(telemetry1.get("temperature").get(0).get("value")).isEqualTo(NullNode.instance); + assertThat(telemetry2.get("temperature").get(0).get("ts").asText()).isEqualTo(Long.toString(newTs)); + assertThat(telemetry2.get("temperature").get(0).get("value").asDouble()).isEqualTo(25); + }); + } + + private ObjectNode getLatestTelemetry(EntityId entityId, String... keys) throws Exception { return doGetAsync("/api/plugins/telemetry/" + entityId.getEntityType() + "/" + entityId.getId() + "/values/timeseries?keys=" + String.join(",", keys), ObjectNode.class); } diff --git a/application/src/test/java/org/thingsboard/server/service/cf/ctx/state/PropagationArgumentEntryTest.java b/application/src/test/java/org/thingsboard/server/service/cf/ctx/state/PropagationArgumentEntryTest.java new file mode 100644 index 0000000000..9c8a788e15 --- /dev/null +++ b/application/src/test/java/org/thingsboard/server/service/cf/ctx/state/PropagationArgumentEntryTest.java @@ -0,0 +1,143 @@ +/** + * 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.cf.ctx.state; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.thingsboard.script.api.tbel.TbelCfArg; +import org.thingsboard.script.api.tbel.TbelCfPropagationArg; +import org.thingsboard.server.common.data.id.AssetId; +import org.thingsboard.server.common.data.id.EntityId; +import org.thingsboard.server.service.cf.ctx.state.propagation.PropagationArgumentEntry; + +import java.util.ArrayList; +import java.util.List; +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +public class PropagationArgumentEntryTest { + + private final AssetId ENTITY_1_ID = new AssetId(UUID.fromString("b0a8637d-6d67-43d5-a483-c0e391afe805")); + private final AssetId ENTITY_2_ID = new AssetId(UUID.fromString("7bd85073-ded5-414f-a2ef-bd56ad3dbf6a")); + private final AssetId ENTITY_3_ID = new AssetId(UUID.fromString("d64f3e51-2ec2-472f-b475-b095ef8bdc70")); + + private PropagationArgumentEntry entry; + + @BeforeEach + void setUp() { + List propagationEntityIds = new ArrayList<>(); + propagationEntityIds.add(ENTITY_1_ID); + propagationEntityIds.add(ENTITY_2_ID); + entry = new PropagationArgumentEntry(propagationEntityIds); + } + + @Test + void testArgumentEntryType() { + assertThat(entry.getType()).isEqualTo(ArgumentEntryType.PROPAGATION); + } + + @Test + void testIsEmpty() { + PropagationArgumentEntry emptyEntry = new PropagationArgumentEntry(List.of()); + assertThat(emptyEntry.isEmpty()).isTrue(); + } + + @Test + void testIsEmptyWhenNullList() { + PropagationArgumentEntry nullListEntry = new PropagationArgumentEntry(null); + assertThat(nullListEntry.isEmpty()).isTrue(); + } + + @Test + void testGetValueReturnsPropagationIds() { + assertThat(entry.getValue()).isInstanceOf(List.class); + @SuppressWarnings("unchecked") + List value = (List) entry.getValue(); + assertThat(value).containsExactly(ENTITY_1_ID, ENTITY_2_ID); + } + + @Test + void testUpdateEntryWhenSingleEntryPassed() { + assertThatThrownBy(() -> entry.updateEntry(new SingleValueArgumentEntry())) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("Unsupported argument entry type for propagation argument entry: SINGLE_VALUE"); + } + + @Test + void testUpdateEntryWhenRollingEntryPassed() { + assertThatThrownBy(() -> entry.updateEntry(new TsRollingArgumentEntry(5, 30000L))) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("Unsupported argument entry type for propagation argument entry: TS_ROLLING"); + } + + @Test + void testUpdateEntryReplacesWithNewIds() { + var newIds = new ArrayList(List.of(ENTITY_3_ID, ENTITY_1_ID)); + var updated = new PropagationArgumentEntry(newIds); + + boolean changed = entry.updateEntry(updated); + + assertThat(changed).isTrue(); + assertThat(entry.getPropagationEntityIds()).containsExactlyElementsOf(newIds); + } + + @Test + void testUpdateEntryClearsWhenNewEntryIsEmpty() { + var updatedEmpty = new PropagationArgumentEntry(List.of()); + + boolean changed = entry.updateEntry(updatedEmpty); + + assertThat(changed).isTrue(); + assertThat(entry.getPropagationEntityIds()).isEmpty(); + } + + @Test + void testUpdateEntryClearsWhenNewEntryIsNullList() { + var updatedNull = new PropagationArgumentEntry(null); + + boolean changed = entry.updateEntry(updatedNull); + + assertThat(changed).isTrue(); + assertThat(entry.getPropagationEntityIds()).isEmpty(); + } + + @Test + @SuppressWarnings("unchecked") + void testToTbelCfArgWithValues() { + TbelCfArg arg = entry.toTbelCfArg(); + assertThat(arg).isInstanceOf(TbelCfPropagationArg.class); + + TbelCfPropagationArg tbelCfPropagationArg = (TbelCfPropagationArg) arg; + assertThat(tbelCfPropagationArg.getValue()).isInstanceOf(List.class); + assertThat((List) tbelCfPropagationArg.getValue()).containsExactly(ENTITY_1_ID, ENTITY_2_ID); + } + + + @Test + @SuppressWarnings("unchecked") + void testToTbelCfArgWithEmptyValues() { + var empty = new PropagationArgumentEntry(List.of()); + TbelCfArg emptyArg = empty.toTbelCfArg(); + assertThat(emptyArg).isInstanceOf(TbelCfPropagationArg.class); + + TbelCfPropagationArg tbelCfPropagationArg = (TbelCfPropagationArg) emptyArg; + assertThat(tbelCfPropagationArg.getValue()).isInstanceOf(List.class); + assertThat((List) tbelCfPropagationArg.getValue()).isEmpty(); + } + +} diff --git a/application/src/test/java/org/thingsboard/server/service/cf/ctx/state/PropagationCalculatedFieldStateTest.java b/application/src/test/java/org/thingsboard/server/service/cf/ctx/state/PropagationCalculatedFieldStateTest.java new file mode 100644 index 0000000000..234a0bdb06 --- /dev/null +++ b/application/src/test/java/org/thingsboard/server/service/cf/ctx/state/PropagationCalculatedFieldStateTest.java @@ -0,0 +1,246 @@ +/** + * 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.cf.ctx.state; + +import com.fasterxml.jackson.databind.node.ObjectNode; +import io.micrometer.core.instrument.simple.SimpleMeterRegistry; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.thingsboard.common.util.JacksonUtil; +import org.thingsboard.script.api.tbel.DefaultTbelInvokeService; +import org.thingsboard.script.api.tbel.TbelInvokeService; +import org.thingsboard.server.actors.ActorSystemContext; +import org.thingsboard.server.common.data.AttributeScope; +import org.thingsboard.server.common.data.cf.CalculatedField; +import org.thingsboard.server.common.data.cf.CalculatedFieldType; +import org.thingsboard.server.common.data.cf.configuration.Argument; +import org.thingsboard.server.common.data.cf.configuration.ArgumentType; +import org.thingsboard.server.common.data.cf.configuration.CalculatedFieldConfiguration; +import org.thingsboard.server.common.data.cf.configuration.Output; +import org.thingsboard.server.common.data.cf.configuration.OutputType; +import org.thingsboard.server.common.data.cf.configuration.PropagationCalculatedFieldConfiguration; +import org.thingsboard.server.common.data.cf.configuration.ReferencedEntityKey; +import org.thingsboard.server.common.data.id.AssetId; +import org.thingsboard.server.common.data.id.DeviceId; +import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.common.data.kv.DoubleDataEntry; +import org.thingsboard.server.common.data.relation.EntityRelation; +import org.thingsboard.server.common.data.relation.EntitySearchDirection; +import org.thingsboard.server.common.stats.DefaultStatsFactory; +import org.thingsboard.server.dao.usagerecord.ApiLimitService; +import org.thingsboard.server.service.cf.PropagationCalculatedFieldResult; +import org.thingsboard.server.service.cf.TelemetryCalculatedFieldResult; +import org.thingsboard.server.service.cf.ctx.state.propagation.PropagationArgumentEntry; +import org.thingsboard.server.service.cf.ctx.state.propagation.PropagationCalculatedFieldState; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.UUID; +import java.util.concurrent.ExecutionException; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.when; +import static org.thingsboard.server.common.data.cf.configuration.PropagationCalculatedFieldConfiguration.PROPAGATION_CONFIG_ARGUMENT; + +@SpringBootTest(classes = {SimpleMeterRegistry.class, DefaultStatsFactory.class, DefaultTbelInvokeService.class}) +public class PropagationCalculatedFieldStateTest { + + private static final String TEMPERATURE_ARGUMENT_NAME = "t"; + private static final String TEST_RESULT_EXPRESSION_KEY = "testResult"; + private static final double TEMPERATURE_VALUE = 12.5; + + private final TenantId TENANT_ID = TenantId.fromUUID(UUID.fromString("6c3513cb-85e7-4510-8746-1ba01859a8ce")); + private final DeviceId DEVICE_ID = new DeviceId(UUID.fromString("be960a50-c029-4698-b2ec-c56a543c561c")); + private final AssetId ASSET_ID_1 = new AssetId(UUID.fromString("d26f0e5b-7d7d-4a61-9f5e-08ab97b30734")); + private final AssetId ASSET_ID_2 = new AssetId(UUID.fromString("1933a317-4df5-4d36-9800-68aded74579b")); + + private final SingleValueArgumentEntry singleValueArgEntry = + new SingleValueArgumentEntry(System.currentTimeMillis(), new DoubleDataEntry("temperature", TEMPERATURE_VALUE), 99L); + + private final PropagationArgumentEntry propagationArgEntry = + new PropagationArgumentEntry(new ArrayList<>(List.of(ASSET_ID_2, ASSET_ID_1))); + + private PropagationCalculatedFieldState state; + private CalculatedFieldCtx ctx; + + @Autowired + private TbelInvokeService tbelInvokeService; + + @MockitoBean + private ApiLimitService apiLimitService; + + @MockitoBean + private ActorSystemContext actorSystemContext; + + @BeforeEach + void setUp() { + when(actorSystemContext.getTbelInvokeService()).thenReturn(tbelInvokeService); + when(actorSystemContext.getApiLimitService()).thenReturn(apiLimitService); + when(apiLimitService.getLimit(any(), any())).thenReturn(1000L); + } + + void initCtxAndState(boolean applyExpressionToResolvedArguments) { + ctx = new CalculatedFieldCtx(getCalculatedField(applyExpressionToResolvedArguments), actorSystemContext); + ctx.init(); + + state = new PropagationCalculatedFieldState(ctx.getEntityId()); + state.init(ctx); + } + + @Test + void testType() { + initCtxAndState(false); + assertThat(state.getType()).isEqualTo(CalculatedFieldType.PROPAGATION); + } + + @Test + void testInitAddsRequiredArgument() { + initCtxAndState(false); + assertThat(state.getRequiredArguments()).containsExactlyInAnyOrder(TEMPERATURE_ARGUMENT_NAME); + } + + @Test + void testIsReadyReturnFalseWhenNoArgumentsSet() { + initCtxAndState(false); + assertThat(state.isReady()).isFalse(); + } + + @Test + void testIsReadyWhenPropagationArgIsNull() { + initCtxAndState(false); + state.getArguments().put(TEMPERATURE_ARGUMENT_NAME, singleValueArgEntry); + assertThat(state.isReady()).isFalse(); + } + + @Test + void testIsReadyWhenPropagationArgIsEmpty() { + initCtxAndState(false); + state.getArguments().put(TEMPERATURE_ARGUMENT_NAME, singleValueArgEntry); + state.getArguments().put(PROPAGATION_CONFIG_ARGUMENT, new PropagationArgumentEntry(Collections.emptyList())); + assertThat(state.isReady()).isFalse(); + } + + @Test + void testIsReadyWhenPropagationArgHasEntities() { + initCtxAndState(false); + state.getArguments().put(TEMPERATURE_ARGUMENT_NAME, singleValueArgEntry); + state.getArguments().put(PROPAGATION_CONFIG_ARGUMENT, propagationArgEntry); + assertThat(state.isReady()).isTrue(); + } + + + @Test + void testPerformCalculationWithEmptyPropagationArg() throws Exception { + initCtxAndState(false); + state.getArguments().put(PROPAGATION_CONFIG_ARGUMENT, new PropagationArgumentEntry(Collections.emptyList())); + + PropagationCalculatedFieldResult result = performCalculation(); + + assertThat(result).isNotNull(); + assertThat(result.isEmpty()).isTrue(); + assertThat(result.getPropagationEntityIds()).isNullOrEmpty(); + } + + @Test + void testPerformCalculationWithArgumentsOnlyMode() throws Exception { + initCtxAndState(false); + state.getArguments().put(PROPAGATION_CONFIG_ARGUMENT, propagationArgEntry); + state.getArguments().put(TEMPERATURE_ARGUMENT_NAME, singleValueArgEntry); + + PropagationCalculatedFieldResult propagationResult = performCalculation(); + + assertThat(propagationResult).isNotNull(); + assertThat(propagationResult.isEmpty()).isFalse(); + assertThat(propagationResult.getPropagationEntityIds()).containsExactly(ASSET_ID_2, ASSET_ID_1); + + TelemetryCalculatedFieldResult result = propagationResult.getResult(); + assertThat(result).isNotNull(); + assertThat(result.getType()).isEqualTo(OutputType.ATTRIBUTES); + assertThat(result.getScope()).isEqualTo(AttributeScope.SERVER_SCOPE); + + ObjectNode expectedNode = JacksonUtil.newObjectNode(); + JacksonUtil.addKvEntry(expectedNode, singleValueArgEntry.getKvEntryValue()); + + assertThat(result.getResult()).isEqualTo(expectedNode); + } + + @Test + void testPerformCalculationWithExpressionResultMode() throws Exception { + initCtxAndState(true); + state.getArguments().put(PROPAGATION_CONFIG_ARGUMENT, propagationArgEntry); + state.getArguments().put(TEMPERATURE_ARGUMENT_NAME, singleValueArgEntry); + + PropagationCalculatedFieldResult propagationResult = performCalculation(); + + assertThat(propagationResult).isNotNull(); + assertThat(propagationResult.isEmpty()).isFalse(); + assertThat(propagationResult.getPropagationEntityIds()).containsExactly(ASSET_ID_2, ASSET_ID_1); + + TelemetryCalculatedFieldResult result = propagationResult.getResult(); + assertThat(result).isNotNull(); + assertThat(result.getType()).isEqualTo(OutputType.ATTRIBUTES); + assertThat(result.getScope()).isEqualTo(AttributeScope.SERVER_SCOPE); + + ObjectNode expectedNode = JacksonUtil.newObjectNode(); + expectedNode.put(TEST_RESULT_EXPRESSION_KEY, TEMPERATURE_VALUE * 2); + + assertThat(result.getResult()).isEqualTo(expectedNode); + } + + private CalculatedField getCalculatedField(boolean applyExpressionToResolvedArguments) { + CalculatedField calculatedField = new CalculatedField(); + calculatedField.setTenantId(TENANT_ID); + calculatedField.setEntityId(DEVICE_ID); + calculatedField.setType(CalculatedFieldType.PROPAGATION); + calculatedField.setName("Test Propagation CF"); + calculatedField.setConfigurationVersion(1); + calculatedField.setConfiguration(getCalculatedFieldConfig(applyExpressionToResolvedArguments)); + calculatedField.setVersion(1L); + return calculatedField; + } + + private CalculatedFieldConfiguration getCalculatedFieldConfig(boolean applyExpressionToResolvedArguments) { + var config = new PropagationCalculatedFieldConfiguration(); + + config.setDirection(EntitySearchDirection.TO); + config.setRelationType(EntityRelation.CONTAINS_TYPE); + config.setApplyExpressionToResolvedArguments(applyExpressionToResolvedArguments); + + Argument temperatureArg = new Argument(); + ReferencedEntityKey tempKey = new ReferencedEntityKey("temperature", ArgumentType.TS_LATEST, null); + temperatureArg.setRefEntityKey(tempKey); + + config.setArguments(Map.of(TEMPERATURE_ARGUMENT_NAME, temperatureArg)); + config.setExpression("{" + TEST_RESULT_EXPRESSION_KEY + ": " + TEMPERATURE_ARGUMENT_NAME + " * 2}"); + + Output output = new Output(); + output.setType(OutputType.ATTRIBUTES); + output.setScope(AttributeScope.SERVER_SCOPE); + config.setOutput(output); + + return config; + } + + private PropagationCalculatedFieldResult performCalculation() throws ExecutionException, InterruptedException { + return (PropagationCalculatedFieldResult) state.performCalculation(Collections.emptyMap(), ctx).get(); + } +} diff --git a/application/src/test/java/org/thingsboard/server/utils/CalculatedFieldUtilsTest.java b/application/src/test/java/org/thingsboard/server/utils/CalculatedFieldUtilsTest.java index 0d57f90206..505837ece2 100644 --- a/application/src/test/java/org/thingsboard/server/utils/CalculatedFieldUtilsTest.java +++ b/application/src/test/java/org/thingsboard/server/utils/CalculatedFieldUtilsTest.java @@ -50,7 +50,6 @@ import static org.mockito.Mockito.mock; import static org.thingsboard.server.common.data.cf.configuration.PropagationCalculatedFieldConfiguration.PROPAGATION_CONFIG_ARGUMENT; import static org.thingsboard.server.utils.CalculatedFieldUtils.toProto; - @ExtendWith(MockitoExtension.class) class CalculatedFieldUtilsTest { @@ -94,11 +93,9 @@ class CalculatedFieldUtilsTest { CalculatedFieldState state = new GeofencingCalculatedFieldState(DEVICE_ID); state.update(Map.of("geofencingArgumentTest", geofencingArgumentEntry), mock(CalculatedFieldCtx.class)); - // when CalculatedFieldStateProto proto = toProto(stateId, state); - - // then CalculatedFieldState fromProto = CalculatedFieldUtils.fromProto(stateId, proto); + assertThat(fromProto) .usingRecursiveComparison() .ignoringFields("requiredArguments") From b3147e82192a9ddb03b4c32264aa744e151e5142 Mon Sep 17 00:00:00 2001 From: dashevchenko Date: Tue, 7 Oct 2025 11:30:14 +0300 Subject: [PATCH 14/43] name conflict strategy: initial implementation --- .../server/controller/AssetController.java | 9 ++++-- .../server/controller/CustomerController.java | 7 ++++- .../server/controller/DeviceController.java | 18 ++++++++--- .../controller/EntityViewController.java | 9 ++++-- .../server/controller/Lwm2mController.java | 3 +- .../device/DeviceBulkImportService.java | 3 +- .../entitiy/asset/DefaultTbAssetService.java | 5 ++-- .../service/entitiy/asset/TbAssetService.java | 3 +- .../device/DefaultTbDeviceService.java | 9 +++--- .../entitiy/device/TbDeviceService.java | 5 ++-- .../DefaultTbEntityViewService.java | 5 ++-- .../entityview/TbEntityViewService.java | 3 +- .../server/dao/asset/AssetService.java | 6 ++++ .../server/dao/device/DeviceService.java | 5 ++++ .../dao/entityview/EntityViewService.java | 3 ++ .../common/data/NameConflictStrategy.java | 23 ++++++++++++++ .../java/org/thingsboard/server/dao/Dao.java | 3 ++ .../server/dao/asset/AssetDao.java | 2 ++ .../server/dao/asset/BaseAssetService.java | 13 ++++++++ .../server/dao/device/DeviceDao.java | 2 ++ .../server/dao/device/DeviceServiceImpl.java | 28 +++++++++++++++-- .../dao/entity/AbstractEntityService.java | 30 +++++++++++++++++++ .../dao/entityview/EntityViewServiceImpl.java | 13 ++++++++ .../server/dao/sql/asset/AssetRepository.java | 5 ++++ .../server/dao/sql/asset/JpaAssetDao.java | 7 +++++ .../dao/sql/customer/CustomerRepository.java | 5 ++++ .../dao/sql/device/DeviceRepository.java | 5 ++++ .../server/dao/sql/device/JpaDeviceDao.java | 6 ++++ .../sql/entityview/EntityViewRepository.java | 5 ++++ 29 files changed, 214 insertions(+), 26 deletions(-) create mode 100644 common/data/src/main/java/org/thingsboard/server/common/data/NameConflictStrategy.java diff --git a/application/src/main/java/org/thingsboard/server/controller/AssetController.java b/application/src/main/java/org/thingsboard/server/controller/AssetController.java index 2a4cce143a..9cade90860 100644 --- a/application/src/main/java/org/thingsboard/server/controller/AssetController.java +++ b/application/src/main/java/org/thingsboard/server/controller/AssetController.java @@ -34,6 +34,7 @@ import org.springframework.web.bind.annotation.ResponseStatus; import org.springframework.web.bind.annotation.RestController; import org.thingsboard.server.common.data.Customer; import org.thingsboard.server.common.data.EntitySubtype; +import org.thingsboard.server.common.data.NameConflictStrategy; import org.thingsboard.server.common.data.asset.Asset; import org.thingsboard.server.common.data.asset.AssetInfo; import org.thingsboard.server.common.data.asset.AssetSearchQuery; @@ -137,10 +138,14 @@ public class AssetController extends BaseController { @PreAuthorize("hasAnyAuthority('TENANT_ADMIN', 'CUSTOMER_USER')") @RequestMapping(value = "/asset", method = RequestMethod.POST) @ResponseBody - public Asset saveAsset(@io.swagger.v3.oas.annotations.parameters.RequestBody(description = "A JSON value representing the asset.") @RequestBody Asset asset) throws Exception { + public Asset saveAsset(@io.swagger.v3.oas.annotations.parameters.RequestBody(description = "A JSON value representing the asset.") @RequestBody Asset asset, + @Parameter(description = "Optional value of name conflict strategy. Possible values: FAIL or UNIQUIFY. " + + "If omitted, FAIL strategy is applied. FAIL strategy implies exception will be thrown if an entity with the same name already exists. " + + "UNIQUIFY strategy appends a numerical suffix to the entity name, if a name conflict occurs.") + @RequestParam(name = "nameConflictStrategy", defaultValue = "FAIL") NameConflictStrategy nameConflictStrategy) throws Exception { asset.setTenantId(getTenantId()); checkEntity(asset.getId(), asset, Resource.ASSET); - return tbAssetService.save(asset, getCurrentUser()); + return tbAssetService.save(asset, nameConflictStrategy, getCurrentUser()); } @ApiOperation(value = "Delete asset (deleteAsset)", diff --git a/application/src/main/java/org/thingsboard/server/controller/CustomerController.java b/application/src/main/java/org/thingsboard/server/controller/CustomerController.java index 03ac9c60fa..5c4acb6dbc 100644 --- a/application/src/main/java/org/thingsboard/server/controller/CustomerController.java +++ b/application/src/main/java/org/thingsboard/server/controller/CustomerController.java @@ -32,6 +32,7 @@ import org.springframework.web.bind.annotation.ResponseStatus; import org.springframework.web.bind.annotation.RestController; import org.thingsboard.common.util.JacksonUtil; import org.thingsboard.server.common.data.Customer; +import org.thingsboard.server.common.data.NameConflictStrategy; import org.thingsboard.server.common.data.exception.ThingsboardException; import org.thingsboard.server.common.data.id.CustomerId; import org.thingsboard.server.common.data.id.TenantId; @@ -128,7 +129,11 @@ public class CustomerController extends BaseController { @PreAuthorize("hasAuthority('TENANT_ADMIN')") @RequestMapping(value = "/customer", method = RequestMethod.POST) @ResponseBody - public Customer saveCustomer(@io.swagger.v3.oas.annotations.parameters.RequestBody(description = "A JSON value representing the customer.") @RequestBody Customer customer) throws Exception { + public Customer saveCustomer(@io.swagger.v3.oas.annotations.parameters.RequestBody(description = "A JSON value representing the customer.") @RequestBody Customer customer, + @Parameter(description = "Optional value of name conflict strategy. Possible values: FAIL or UNIQUIFY. " + + "If omitted, FAIL strategy is applied. FAIL strategy implies exception will be thrown if an entity with the same name already exists. " + + "UNIQUIFY strategy appends a numerical suffix to the entity name, if a name conflict occurs.") + @RequestParam(name = "nameConflictStrategy", defaultValue = "FAIL") NameConflictStrategy nameConflictStrategy) throws Exception { customer.setTenantId(getTenantId()); checkEntity(customer.getId(), customer, Resource.CUSTOMER); return tbCustomerService.save(customer, getCurrentUser()); diff --git a/application/src/main/java/org/thingsboard/server/controller/DeviceController.java b/application/src/main/java/org/thingsboard/server/controller/DeviceController.java index 61864dbf63..724e3a430c 100644 --- a/application/src/main/java/org/thingsboard/server/controller/DeviceController.java +++ b/application/src/main/java/org/thingsboard/server/controller/DeviceController.java @@ -46,6 +46,7 @@ import org.thingsboard.server.common.data.Device; import org.thingsboard.server.common.data.DeviceInfo; import org.thingsboard.server.common.data.DeviceInfoFilter; import org.thingsboard.server.common.data.EntitySubtype; +import org.thingsboard.server.common.data.NameConflictStrategy; import org.thingsboard.server.common.data.SaveDeviceWithCredentialsRequest; import org.thingsboard.server.common.data.Tenant; import org.thingsboard.server.common.data.device.DeviceSearchQuery; @@ -177,14 +178,19 @@ public class DeviceController extends BaseController { @ResponseBody public Device saveDevice(@io.swagger.v3.oas.annotations.parameters.RequestBody(description = "A JSON value representing the device.") @RequestBody Device device, @Parameter(description = "Optional value of the device credentials to be used during device creation. " + - "If omitted, access token will be auto-generated.") @RequestParam(name = "accessToken", required = false) String accessToken) throws Exception { + "If omitted, access token will be auto-generated.") + @RequestParam(name = "accessToken", required = false) String accessToken, + @Parameter(description = "Optional value of name conflict strategy. Possible values: FAIL or UNIQUIFY. " + + "If omitted, FAIL strategy is applied. FAIL strategy implies exception will be thrown if an entity with the same name already exists. " + + "UNIQUIFY strategy appends a numerical suffix to the entity name, if a name conflict occurs.") + @RequestParam(name = "nameConflictStrategy", defaultValue = "FAIL") NameConflictStrategy nameConflictStrategy) throws Exception { device.setTenantId(getCurrentUser().getTenantId()); if (device.getId() != null) { checkDeviceId(device.getId(), Operation.WRITE); } else { checkEntity(null, device, Resource.DEVICE); } - return tbDeviceService.save(device, accessToken, getCurrentUser()); + return tbDeviceService.save(device, accessToken, nameConflictStrategy, getCurrentUser()); } @ApiOperation(value = "Create Device (saveDevice) with credentials ", @@ -209,12 +215,16 @@ public class DeviceController extends BaseController { @RequestMapping(value = "/device-with-credentials", method = RequestMethod.POST) @ResponseBody public Device saveDeviceWithCredentials(@Parameter(description = "The JSON object with device and credentials. See method description above for example.") - @Valid @RequestBody SaveDeviceWithCredentialsRequest deviceAndCredentials) throws ThingsboardException { + @Valid @RequestBody SaveDeviceWithCredentialsRequest deviceAndCredentials, + @Parameter(description = "Optional value of name conflict strategy. Possible values: FAIL or UNIQUIFY. " + + "If omitted, FAIL strategy is applied. FAIL strategy implies exception will be thrown if an entity with the same name already exists. " + + "UNIQUIFY strategy appends a numerical suffix to the entity name, if a name conflict occurs.") + @RequestParam(name = "nameConflictStrategy", defaultValue = "FAIL") NameConflictStrategy nameConflictStrategy) throws ThingsboardException { Device device = deviceAndCredentials.getDevice(); DeviceCredentials credentials = deviceAndCredentials.getCredentials(); device.setTenantId(getCurrentUser().getTenantId()); checkEntity(device.getId(), device, Resource.DEVICE); - return tbDeviceService.saveDeviceWithCredentials(device, credentials, getCurrentUser()); + return tbDeviceService.saveDeviceWithCredentials(device, credentials, nameConflictStrategy, getCurrentUser()); } @ApiOperation(value = "Delete device (deleteDevice)", diff --git a/application/src/main/java/org/thingsboard/server/controller/EntityViewController.java b/application/src/main/java/org/thingsboard/server/controller/EntityViewController.java index b1b6b6b1e3..147fccda78 100644 --- a/application/src/main/java/org/thingsboard/server/controller/EntityViewController.java +++ b/application/src/main/java/org/thingsboard/server/controller/EntityViewController.java @@ -34,6 +34,7 @@ import org.thingsboard.server.common.data.Customer; import org.thingsboard.server.common.data.EntitySubtype; import org.thingsboard.server.common.data.EntityView; import org.thingsboard.server.common.data.EntityViewInfo; +import org.thingsboard.server.common.data.NameConflictStrategy; import org.thingsboard.server.common.data.edge.Edge; import org.thingsboard.server.common.data.entityview.EntityViewSearchQuery; import org.thingsboard.server.common.data.exception.ThingsboardException; @@ -128,7 +129,11 @@ public class EntityViewController extends BaseController { @ResponseBody public EntityView saveEntityView( @Parameter(description = "A JSON object representing the entity view.") - @RequestBody EntityView entityView) throws Exception { + @RequestBody EntityView entityView, + @Parameter(description = "Optional value of name conflict strategy. Possible values: FAIL or UNIQUIFY. " + + "If omitted, FAIL strategy is applied. FAIL strategy implies exception will be thrown if an entity with the same name already exists. " + + "UNIQUIFY strategy appends a numerical suffix to the entity name, if a name conflict occurs.") + @RequestParam(name = "nameConflictStrategy", defaultValue = "FAIL") NameConflictStrategy nameConflictStrategy) throws Exception { entityView.setTenantId(getCurrentUser().getTenantId()); EntityView existingEntityView = null; if (entityView.getId() == null) { @@ -137,7 +142,7 @@ public class EntityViewController extends BaseController { } else { existingEntityView = checkEntityViewId(entityView.getId(), Operation.WRITE); } - return tbEntityViewService.save(entityView, existingEntityView, getCurrentUser()); + return tbEntityViewService.save(entityView, existingEntityView, nameConflictStrategy, getCurrentUser()); } @ApiOperation(value = "Delete entity view (deleteEntityView)", diff --git a/application/src/main/java/org/thingsboard/server/controller/Lwm2mController.java b/application/src/main/java/org/thingsboard/server/controller/Lwm2mController.java index f0ec727896..1febbb9bfd 100644 --- a/application/src/main/java/org/thingsboard/server/controller/Lwm2mController.java +++ b/application/src/main/java/org/thingsboard/server/controller/Lwm2mController.java @@ -27,6 +27,7 @@ import org.springframework.web.bind.annotation.ResponseBody; import org.springframework.web.bind.annotation.RestController; import org.thingsboard.common.util.JacksonUtil; import org.thingsboard.server.common.data.Device; +import org.thingsboard.server.common.data.NameConflictStrategy; import org.thingsboard.server.common.data.SaveDeviceWithCredentialsRequest; import org.thingsboard.server.common.data.device.profile.lwm2m.bootstrap.LwM2MServerSecurityConfigDefault; import org.thingsboard.server.common.data.exception.ThingsboardException; @@ -73,6 +74,6 @@ public class Lwm2mController extends BaseController { public Device saveDeviceWithCredentials(@RequestBody Map, Object> deviceWithDeviceCredentials) throws ThingsboardException { Device device = checkNotNull(JacksonUtil.convertValue(deviceWithDeviceCredentials.get(Device.class), Device.class)); DeviceCredentials credentials = checkNotNull(JacksonUtil.convertValue(deviceWithDeviceCredentials.get(DeviceCredentials.class), DeviceCredentials.class)); - return deviceController.saveDeviceWithCredentials(new SaveDeviceWithCredentialsRequest(device, credentials)); + return deviceController.saveDeviceWithCredentials(new SaveDeviceWithCredentialsRequest(device, credentials), NameConflictStrategy.FAIL); } } diff --git a/application/src/main/java/org/thingsboard/server/service/device/DeviceBulkImportService.java b/application/src/main/java/org/thingsboard/server/service/device/DeviceBulkImportService.java index d042fb2657..316069280a 100644 --- a/application/src/main/java/org/thingsboard/server/service/device/DeviceBulkImportService.java +++ b/application/src/main/java/org/thingsboard/server/service/device/DeviceBulkImportService.java @@ -29,6 +29,7 @@ import org.thingsboard.server.common.data.DeviceProfileProvisionType; import org.thingsboard.server.common.data.DeviceProfileType; import org.thingsboard.server.common.data.DeviceTransportType; import org.thingsboard.server.common.data.EntityType; +import org.thingsboard.server.common.data.NameConflictStrategy; import org.thingsboard.server.common.data.StringUtils; import org.thingsboard.server.common.data.device.credentials.BasicMqttCredentials; import org.thingsboard.server.common.data.device.credentials.lwm2m.LwM2MClientCredential; @@ -128,7 +129,7 @@ public class DeviceBulkImportService extends AbstractBulkImportService { } device.setDeviceProfileId(deviceProfile.getId()); - return tbDeviceService.saveDeviceWithCredentials(device, deviceCredentials, user); + return tbDeviceService.saveDeviceWithCredentials(device, deviceCredentials, NameConflictStrategy.FAIL, user); } @Override diff --git a/application/src/main/java/org/thingsboard/server/service/entitiy/asset/DefaultTbAssetService.java b/application/src/main/java/org/thingsboard/server/service/entitiy/asset/DefaultTbAssetService.java index 3f69d00276..f55635d1d7 100644 --- a/application/src/main/java/org/thingsboard/server/service/entitiy/asset/DefaultTbAssetService.java +++ b/application/src/main/java/org/thingsboard/server/service/entitiy/asset/DefaultTbAssetService.java @@ -20,6 +20,7 @@ import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import org.thingsboard.server.common.data.Customer; import org.thingsboard.server.common.data.EntityType; +import org.thingsboard.server.common.data.NameConflictStrategy; import org.thingsboard.server.common.data.User; import org.thingsboard.server.common.data.asset.Asset; import org.thingsboard.server.common.data.audit.ActionType; @@ -39,11 +40,11 @@ public class DefaultTbAssetService extends AbstractTbEntityService implements Tb private final AssetService assetService; @Override - public Asset save(Asset asset, User user) throws Exception { + public Asset save(Asset asset, NameConflictStrategy nameConflictStrategy, User user) throws Exception { ActionType actionType = asset.getId() == null ? ActionType.ADDED : ActionType.UPDATED; TenantId tenantId = asset.getTenantId(); try { - Asset savedAsset = checkNotNull(assetService.saveAsset(asset)); + Asset savedAsset = checkNotNull(assetService.saveAsset(asset, nameConflictStrategy)); autoCommit(user, savedAsset.getId()); logEntityActionService.logEntityAction(tenantId, savedAsset.getId(), savedAsset, asset.getCustomerId(), actionType, user); diff --git a/application/src/main/java/org/thingsboard/server/service/entitiy/asset/TbAssetService.java b/application/src/main/java/org/thingsboard/server/service/entitiy/asset/TbAssetService.java index a2af8ffcdc..52b20fc52b 100644 --- a/application/src/main/java/org/thingsboard/server/service/entitiy/asset/TbAssetService.java +++ b/application/src/main/java/org/thingsboard/server/service/entitiy/asset/TbAssetService.java @@ -16,6 +16,7 @@ package org.thingsboard.server.service.entitiy.asset; import org.thingsboard.server.common.data.Customer; +import org.thingsboard.server.common.data.NameConflictStrategy; import org.thingsboard.server.common.data.User; import org.thingsboard.server.common.data.asset.Asset; import org.thingsboard.server.common.data.edge.Edge; @@ -25,7 +26,7 @@ import org.thingsboard.server.common.data.id.TenantId; public interface TbAssetService { - Asset save(Asset asset, User user) throws Exception; + Asset save(Asset asset, NameConflictStrategy nameConflictStrategy, User user) throws Exception; void delete(Asset asset, User user); diff --git a/application/src/main/java/org/thingsboard/server/service/entitiy/device/DefaultTbDeviceService.java b/application/src/main/java/org/thingsboard/server/service/entitiy/device/DefaultTbDeviceService.java index f182894239..85d5de5379 100644 --- a/application/src/main/java/org/thingsboard/server/service/entitiy/device/DefaultTbDeviceService.java +++ b/application/src/main/java/org/thingsboard/server/service/entitiy/device/DefaultTbDeviceService.java @@ -25,6 +25,7 @@ import org.springframework.transaction.annotation.Transactional; import org.thingsboard.server.common.data.Customer; import org.thingsboard.server.common.data.Device; import org.thingsboard.server.common.data.EntityType; +import org.thingsboard.server.common.data.NameConflictStrategy; import org.thingsboard.server.common.data.Tenant; import org.thingsboard.server.common.data.User; import org.thingsboard.server.common.data.audit.ActionType; @@ -55,11 +56,11 @@ public class DefaultTbDeviceService extends AbstractTbEntityService implements T private final ClaimDevicesService claimDevicesService; @Override - public Device save(Device device, String accessToken, User user) throws Exception { + public Device save(Device device, String accessToken, NameConflictStrategy nameConflictStrategy, User user) throws Exception { ActionType actionType = device.getId() == null ? ActionType.ADDED : ActionType.UPDATED; TenantId tenantId = device.getTenantId(); try { - Device savedDevice = checkNotNull(deviceService.saveDeviceWithAccessToken(device, accessToken)); + Device savedDevice = checkNotNull(deviceService.saveDeviceWithAccessToken(device, accessToken, nameConflictStrategy)); autoCommit(user, savedDevice.getId()); logEntityActionService.logEntityAction(tenantId, savedDevice.getId(), savedDevice, savedDevice.getCustomerId(), actionType, user); @@ -72,11 +73,11 @@ public class DefaultTbDeviceService extends AbstractTbEntityService implements T } @Override - public Device saveDeviceWithCredentials(Device device, DeviceCredentials credentials, User user) throws ThingsboardException { + public Device saveDeviceWithCredentials(Device device, DeviceCredentials credentials, NameConflictStrategy nameConflictStrategy, User user) throws ThingsboardException { ActionType actionType = device.getId() == null ? ActionType.ADDED : ActionType.UPDATED; TenantId tenantId = device.getTenantId(); try { - Device savedDevice = checkNotNull(deviceService.saveDeviceWithCredentials(device, credentials)); + Device savedDevice = checkNotNull(deviceService.saveDeviceWithCredentials(device, credentials, nameConflictStrategy)); logEntityActionService.logEntityAction(tenantId, savedDevice.getId(), savedDevice, savedDevice.getCustomerId(), actionType, user); diff --git a/application/src/main/java/org/thingsboard/server/service/entitiy/device/TbDeviceService.java b/application/src/main/java/org/thingsboard/server/service/entitiy/device/TbDeviceService.java index c234b3b597..26be8c5db4 100644 --- a/application/src/main/java/org/thingsboard/server/service/entitiy/device/TbDeviceService.java +++ b/application/src/main/java/org/thingsboard/server/service/entitiy/device/TbDeviceService.java @@ -18,6 +18,7 @@ package org.thingsboard.server.service.entitiy.device; import com.google.common.util.concurrent.ListenableFuture; import org.thingsboard.server.common.data.Customer; import org.thingsboard.server.common.data.Device; +import org.thingsboard.server.common.data.NameConflictStrategy; import org.thingsboard.server.common.data.Tenant; import org.thingsboard.server.common.data.User; import org.thingsboard.server.common.data.edge.Edge; @@ -31,9 +32,9 @@ import org.thingsboard.server.dao.device.claim.ReclaimResult; public interface TbDeviceService { - Device save(Device device, String accessToken, User user) throws Exception; + Device save(Device device, String accessToken, NameConflictStrategy nameConflictStrategy, User user) throws Exception; - Device saveDeviceWithCredentials(Device device, DeviceCredentials deviceCredentials, User user) throws ThingsboardException; + Device saveDeviceWithCredentials(Device device, DeviceCredentials deviceCredentials, NameConflictStrategy nameConflictStrategy, User user) throws ThingsboardException; void delete(Device device, User user); diff --git a/application/src/main/java/org/thingsboard/server/service/entitiy/entityview/DefaultTbEntityViewService.java b/application/src/main/java/org/thingsboard/server/service/entitiy/entityview/DefaultTbEntityViewService.java index 8fb99bfb0e..4daf54afdd 100644 --- a/application/src/main/java/org/thingsboard/server/service/entitiy/entityview/DefaultTbEntityViewService.java +++ b/application/src/main/java/org/thingsboard/server/service/entitiy/entityview/DefaultTbEntityViewService.java @@ -33,6 +33,7 @@ import org.thingsboard.server.common.data.AttributeScope; import org.thingsboard.server.common.data.Customer; import org.thingsboard.server.common.data.EntityType; import org.thingsboard.server.common.data.EntityView; +import org.thingsboard.server.common.data.NameConflictStrategy; import org.thingsboard.server.common.data.User; import org.thingsboard.server.common.data.audit.ActionType; import org.thingsboard.server.common.data.edge.Edge; @@ -79,11 +80,11 @@ public class DefaultTbEntityViewService extends AbstractTbEntityService implemen final Map>> localCache = new ConcurrentHashMap<>(); @Override - public EntityView save(EntityView entityView, EntityView existingEntityView, User user) throws Exception { + public EntityView save(EntityView entityView, EntityView existingEntityView, NameConflictStrategy nameConflictStrategy, User user) throws Exception { ActionType actionType = entityView.getId() == null ? ActionType.ADDED : ActionType.UPDATED; TenantId tenantId = entityView.getTenantId(); try { - EntityView savedEntityView = checkNotNull(entityViewService.saveEntityView(entityView)); + EntityView savedEntityView = checkNotNull(entityViewService.saveEntityView(entityView, nameConflictStrategy)); this.updateEntityViewAttributes(tenantId, savedEntityView, existingEntityView, user); autoCommit(user, savedEntityView.getId()); logEntityActionService.logEntityAction(savedEntityView.getTenantId(), savedEntityView.getId(), savedEntityView, diff --git a/application/src/main/java/org/thingsboard/server/service/entitiy/entityview/TbEntityViewService.java b/application/src/main/java/org/thingsboard/server/service/entitiy/entityview/TbEntityViewService.java index 3aec924f75..f34b4ed335 100644 --- a/application/src/main/java/org/thingsboard/server/service/entitiy/entityview/TbEntityViewService.java +++ b/application/src/main/java/org/thingsboard/server/service/entitiy/entityview/TbEntityViewService.java @@ -18,6 +18,7 @@ package org.thingsboard.server.service.entitiy.entityview; import com.google.common.util.concurrent.ListenableFuture; import org.thingsboard.server.common.data.Customer; import org.thingsboard.server.common.data.EntityView; +import org.thingsboard.server.common.data.NameConflictStrategy; import org.thingsboard.server.common.data.User; import org.thingsboard.server.common.data.edge.Edge; import org.thingsboard.server.common.data.exception.ThingsboardException; @@ -31,7 +32,7 @@ import java.util.List; public interface TbEntityViewService extends ComponentLifecycleListener { - EntityView save(EntityView entityView, EntityView existingEntityView, User user) throws Exception; + EntityView save(EntityView entityView, EntityView existingEntityView, NameConflictStrategy nameConflictStrategy, User user) throws Exception; void updateEntityViewAttributes(TenantId tenantId, EntityView savedEntityView, EntityView oldEntityView, User user) throws ThingsboardException; diff --git a/common/dao-api/src/main/java/org/thingsboard/server/dao/asset/AssetService.java b/common/dao-api/src/main/java/org/thingsboard/server/dao/asset/AssetService.java index c22c9c4140..a930c86ac9 100644 --- a/common/dao-api/src/main/java/org/thingsboard/server/dao/asset/AssetService.java +++ b/common/dao-api/src/main/java/org/thingsboard/server/dao/asset/AssetService.java @@ -17,6 +17,7 @@ package org.thingsboard.server.dao.asset; import com.google.common.util.concurrent.ListenableFuture; import org.thingsboard.server.common.data.EntitySubtype; +import org.thingsboard.server.common.data.NameConflictStrategy; import org.thingsboard.server.common.data.ProfileEntityIdInfo; import org.thingsboard.server.common.data.asset.Asset; import org.thingsboard.server.common.data.asset.AssetInfo; @@ -25,12 +26,15 @@ import org.thingsboard.server.common.data.id.AssetId; import org.thingsboard.server.common.data.id.AssetProfileId; import org.thingsboard.server.common.data.id.CustomerId; import org.thingsboard.server.common.data.id.EdgeId; +import org.thingsboard.server.common.data.id.EntityId; +import org.thingsboard.server.common.data.id.HasId; 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.dao.entity.EntityDaoService; import java.util.List; +import java.util.Optional; public interface AssetService extends EntityDaoService { @@ -48,6 +52,8 @@ public interface AssetService extends EntityDaoService { Asset saveAsset(Asset asset); + Asset saveAsset(Asset asset, NameConflictStrategy nameConflictStrategy); + Asset assignAssetToCustomer(TenantId tenantId, AssetId assetId, CustomerId customerId); Asset unassignAssetFromCustomer(TenantId tenantId, AssetId assetId); diff --git a/common/dao-api/src/main/java/org/thingsboard/server/dao/device/DeviceService.java b/common/dao-api/src/main/java/org/thingsboard/server/dao/device/DeviceService.java index 9eb258f182..759ab3a708 100644 --- a/common/dao-api/src/main/java/org/thingsboard/server/dao/device/DeviceService.java +++ b/common/dao-api/src/main/java/org/thingsboard/server/dao/device/DeviceService.java @@ -23,6 +23,7 @@ import org.thingsboard.server.common.data.DeviceInfoFilter; import org.thingsboard.server.common.data.DeviceProfile; import org.thingsboard.server.common.data.DeviceTransportType; import org.thingsboard.server.common.data.EntitySubtype; +import org.thingsboard.server.common.data.NameConflictStrategy; import org.thingsboard.server.common.data.ProfileEntityIdInfo; import org.thingsboard.server.common.data.device.DeviceSearchQuery; import org.thingsboard.server.common.data.id.CustomerId; @@ -58,8 +59,12 @@ public interface DeviceService extends EntityDaoService { Device saveDeviceWithAccessToken(Device device, String accessToken); + Device saveDeviceWithAccessToken(Device device, String accessToken, NameConflictStrategy nameConflictStrategy); + Device saveDeviceWithCredentials(Device device, DeviceCredentials deviceCredentials); + Device saveDeviceWithCredentials(Device device, DeviceCredentials deviceCredentials, NameConflictStrategy nameConflictStrategy); + Device saveDevice(ProvisionRequest provisionRequest, DeviceProfile profile); Device assignDeviceToCustomer(TenantId tenantId, DeviceId deviceId, CustomerId customerId); diff --git a/common/dao-api/src/main/java/org/thingsboard/server/dao/entityview/EntityViewService.java b/common/dao-api/src/main/java/org/thingsboard/server/dao/entityview/EntityViewService.java index 6e557106f8..b039dbafd1 100644 --- a/common/dao-api/src/main/java/org/thingsboard/server/dao/entityview/EntityViewService.java +++ b/common/dao-api/src/main/java/org/thingsboard/server/dao/entityview/EntityViewService.java @@ -19,6 +19,7 @@ import com.google.common.util.concurrent.ListenableFuture; import org.thingsboard.server.common.data.EntitySubtype; import org.thingsboard.server.common.data.EntityView; import org.thingsboard.server.common.data.EntityViewInfo; +import org.thingsboard.server.common.data.NameConflictStrategy; import org.thingsboard.server.common.data.entityview.EntityViewSearchQuery; import org.thingsboard.server.common.data.id.CustomerId; import org.thingsboard.server.common.data.id.EdgeId; @@ -38,6 +39,8 @@ public interface EntityViewService extends EntityDaoService { EntityView saveEntityView(EntityView entityView); + EntityView saveEntityView(EntityView entityView, NameConflictStrategy nameConflictStrategy); + EntityView saveEntityView(EntityView entityView, boolean doValidate); EntityView assignEntityViewToCustomer(TenantId tenantId, EntityViewId entityViewId, CustomerId customerId); diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/NameConflictStrategy.java b/common/data/src/main/java/org/thingsboard/server/common/data/NameConflictStrategy.java new file mode 100644 index 0000000000..b21ddce883 --- /dev/null +++ b/common/data/src/main/java/org/thingsboard/server/common/data/NameConflictStrategy.java @@ -0,0 +1,23 @@ +/** + * 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.common.data; + +public enum NameConflictStrategy { + + FAIL, + UNIQUIFY; + +} diff --git a/dao/src/main/java/org/thingsboard/server/dao/Dao.java b/dao/src/main/java/org/thingsboard/server/dao/Dao.java index 72883c55ef..450b030e08 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/Dao.java +++ b/dao/src/main/java/org/thingsboard/server/dao/Dao.java @@ -16,6 +16,7 @@ package org.thingsboard.server.dao; import com.google.common.util.concurrent.ListenableFuture; +import org.thingsboard.server.common.data.EntityInfo; import org.thingsboard.server.common.data.EntityType; import org.thingsboard.server.common.data.edqs.fields.EntityFields; import org.thingsboard.server.common.data.id.TenantId; @@ -32,6 +33,8 @@ public interface Dao { ListenableFuture findByIdAsync(TenantId tenantId, UUID id); + EntityInfo findEntityInfoByName(TenantId tenantId, String name); + boolean existsById(TenantId tenantId, UUID id); ListenableFuture existsByIdAsync(TenantId tenantId, UUID id); diff --git a/dao/src/main/java/org/thingsboard/server/dao/asset/AssetDao.java b/dao/src/main/java/org/thingsboard/server/dao/asset/AssetDao.java index 098dc4e83d..4b24e464bc 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/asset/AssetDao.java +++ b/dao/src/main/java/org/thingsboard/server/dao/asset/AssetDao.java @@ -16,6 +16,7 @@ package org.thingsboard.server.dao.asset; import com.google.common.util.concurrent.ListenableFuture; +import org.thingsboard.server.common.data.EntityInfo; import org.thingsboard.server.common.data.EntitySubtype; import org.thingsboard.server.common.data.ProfileEntityIdInfo; import org.thingsboard.server.common.data.asset.Asset; @@ -242,4 +243,5 @@ public interface AssetDao extends Dao, TenantEntityDao, Exportable PageData findProfileEntityIdInfosByTenantId(UUID tenantId, PageLink pageLink); + EntityInfo findEntityInfoByName(TenantId tenantId, String name); } diff --git a/dao/src/main/java/org/thingsboard/server/dao/asset/BaseAssetService.java b/dao/src/main/java/org/thingsboard/server/dao/asset/BaseAssetService.java index fed201d403..0414a0f4a7 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/asset/BaseAssetService.java +++ b/dao/src/main/java/org/thingsboard/server/dao/asset/BaseAssetService.java @@ -26,6 +26,7 @@ import org.springframework.transaction.annotation.Transactional; import org.springframework.transaction.event.TransactionalEventListener; import org.thingsboard.server.common.data.EntitySubtype; import org.thingsboard.server.common.data.EntityType; +import org.thingsboard.server.common.data.NameConflictStrategy; import org.thingsboard.server.common.data.ProfileEntityIdInfo; import org.thingsboard.server.common.data.StringUtils; import org.thingsboard.server.common.data.asset.Asset; @@ -146,8 +147,17 @@ public class BaseAssetService extends AbstractCachedEntityService, TenantEntityDao, Exporta PageData findDeviceInfosByFilter(DeviceInfoFilter filter, PageLink pageLink); + EntityInfo findEntityInfoByName(TenantId tenantId, String name); } diff --git a/dao/src/main/java/org/thingsboard/server/dao/device/DeviceServiceImpl.java b/dao/src/main/java/org/thingsboard/server/dao/device/DeviceServiceImpl.java index 6d993f3e3d..adce656bef 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/device/DeviceServiceImpl.java +++ b/dao/src/main/java/org/thingsboard/server/dao/device/DeviceServiceImpl.java @@ -36,9 +36,11 @@ import org.thingsboard.server.common.data.DeviceInfoFilter; import org.thingsboard.server.common.data.DeviceProfile; import org.thingsboard.server.common.data.DeviceProfileType; import org.thingsboard.server.common.data.DeviceTransportType; +import org.thingsboard.server.common.data.EntityInfo; import org.thingsboard.server.common.data.EntitySubtype; import org.thingsboard.server.common.data.EntityType; import org.thingsboard.server.common.data.EntityView; +import org.thingsboard.server.common.data.NameConflictStrategy; import org.thingsboard.server.common.data.ProfileEntityIdInfo; import org.thingsboard.server.common.data.StringUtils; import org.thingsboard.server.common.data.Tenant; @@ -88,6 +90,7 @@ import java.util.ArrayList; import java.util.List; import java.util.Optional; import java.util.UUID; +import java.util.function.Consumer; import static org.thingsboard.server.dao.DaoUtil.toUUIDs; import static org.thingsboard.server.dao.service.Validator.validateId; @@ -167,6 +170,12 @@ public class DeviceServiceImpl extends CachedVersionedEntityService & HasTenantId & HasName> void uniquifyEntityName(E entity, E oldEntity, Consumer setName, EntityType entityType) { + Dao dao = entityDaoRegistry.getDao(entityType); + EntityInfo existingEntity = dao.findEntityInfoByName(entity.getTenantId(), entity.getName()); + if (existingEntity != null && (oldEntity == null || !existingEntity.getId().equals(oldEntity.getId()))) { + int suffix = 1; + while (true) { + String newName = entity.getName() + "-" + suffix; + if (dao.findEntityInfoByName(entity.getTenantId(), newName) == null) { + setName.accept(newName); + break; + } + suffix++; + } + } + } + } diff --git a/dao/src/main/java/org/thingsboard/server/dao/entityview/EntityViewServiceImpl.java b/dao/src/main/java/org/thingsboard/server/dao/entityview/EntityViewServiceImpl.java index 0e742e20db..cd01473d69 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/entityview/EntityViewServiceImpl.java +++ b/dao/src/main/java/org/thingsboard/server/dao/entityview/EntityViewServiceImpl.java @@ -29,6 +29,7 @@ import org.thingsboard.server.common.data.EntitySubtype; import org.thingsboard.server.common.data.EntityType; import org.thingsboard.server.common.data.EntityView; import org.thingsboard.server.common.data.EntityViewInfo; +import org.thingsboard.server.common.data.NameConflictStrategy; import org.thingsboard.server.common.data.StringUtils; import org.thingsboard.server.common.data.audit.ActionType; import org.thingsboard.server.common.data.edge.Edge; @@ -110,8 +111,17 @@ public class EntityViewServiceImpl extends CachedVersionedEntityService, Expor AssetEntity findByTenantIdAndName(UUID tenantId, String name); + @Query("SELECT new org.thingsboard.server.common.data.EntityInfo(a.id, 'ASSET', a.name) " + + "FROM AssetEntity a WHERE a.tenantId = :tenantId AND a.name = :name") + EntityInfo findEntityInfoByName(UUID tenantId, String name); + @Query("SELECT a FROM AssetEntity a WHERE a.tenantId = :tenantId " + "AND a.type = :type " + "AND (:textSearch IS NULL OR ilike(a.name, CONCAT('%', :textSearch, '%')) = true " + diff --git a/dao/src/main/java/org/thingsboard/server/dao/sql/asset/JpaAssetDao.java b/dao/src/main/java/org/thingsboard/server/dao/sql/asset/JpaAssetDao.java index 4b55884792..123cd17a69 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/sql/asset/JpaAssetDao.java +++ b/dao/src/main/java/org/thingsboard/server/dao/sql/asset/JpaAssetDao.java @@ -21,6 +21,7 @@ import org.springframework.beans.factory.annotation.Autowired; import org.springframework.data.domain.Limit; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.stereotype.Component; +import org.thingsboard.server.common.data.EntityInfo; import org.thingsboard.server.common.data.EntitySubtype; import org.thingsboard.server.common.data.EntityType; import org.thingsboard.server.common.data.ProfileEntityIdInfo; @@ -267,6 +268,12 @@ public class JpaAssetDao extends JpaAbstractDao implements A return nativeAssetRepository.findProfileEntityIdInfosByTenantId(tenantId, DaoUtil.toPageable(pageLink)); } + @Override + public EntityInfo findEntityInfoByName(TenantId tenantId, String name) { + log.debug("Find asset entity info by name [{}]", name); + return assetRepository.findEntityInfoByName(tenantId.getId(), name); + } + @Override public Long countByTenantId(TenantId tenantId) { return assetRepository.countByTenantId(tenantId.getId()); diff --git a/dao/src/main/java/org/thingsboard/server/dao/sql/customer/CustomerRepository.java b/dao/src/main/java/org/thingsboard/server/dao/sql/customer/CustomerRepository.java index 8ad7311423..ea6276f37d 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/sql/customer/CustomerRepository.java +++ b/dao/src/main/java/org/thingsboard/server/dao/sql/customer/CustomerRepository.java @@ -21,6 +21,7 @@ import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; +import org.thingsboard.server.common.data.EntityInfo; import org.thingsboard.server.common.data.edqs.fields.CustomerFields; import org.thingsboard.server.dao.ExportableEntityRepository; import org.thingsboard.server.dao.model.sql.CustomerEntity; @@ -41,6 +42,10 @@ public interface CustomerRepository extends JpaRepository, CustomerEntity findByTenantIdAndTitle(UUID tenantId, String title); + @Query("SELECT new org.thingsboard.server.common.data.EntityInfo(a.id, 'CUSTOMER', a.title) " + + "FROM CustomerEntity a WHERE a.tenantId = :tenantId AND a.title = :name") + EntityInfo findEntityInfoByName(UUID tenantId, String name); + @Query(value = "SELECT * FROM customer c WHERE c.tenant_id = :tenantId " + "AND c.is_public IS TRUE ORDER BY c.id ASC LIMIT 1", nativeQuery = true) CustomerEntity findPublicCustomerByTenantId(@Param("tenantId") UUID tenantId); diff --git a/dao/src/main/java/org/thingsboard/server/dao/sql/device/DeviceRepository.java b/dao/src/main/java/org/thingsboard/server/dao/sql/device/DeviceRepository.java index f4c2fed9fa..14e66826ba 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/sql/device/DeviceRepository.java +++ b/dao/src/main/java/org/thingsboard/server/dao/sql/device/DeviceRepository.java @@ -22,6 +22,7 @@ import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; import org.thingsboard.server.common.data.DeviceTransportType; +import org.thingsboard.server.common.data.EntityInfo; import org.thingsboard.server.common.data.edqs.fields.DeviceFields; import org.thingsboard.server.dao.ExportableEntityRepository; import org.thingsboard.server.dao.model.sql.DeviceEntity; @@ -151,6 +152,10 @@ public interface DeviceRepository extends JpaRepository, Exp DeviceEntity findByTenantIdAndName(UUID tenantId, String name); + @Query("SELECT new org.thingsboard.server.common.data.EntityInfo(a.id, 'DEVICE', a.name) " + + "FROM DeviceEntity a WHERE a.tenantId = :tenantId AND a.name = :name") + EntityInfo findEntityInfoByName(UUID tenantId, String name); + List findDevicesByTenantIdAndCustomerIdAndIdIn(UUID tenantId, UUID customerId, List deviceIds); List findDevicesByTenantIdAndIdIn(UUID tenantId, List deviceIds); diff --git a/dao/src/main/java/org/thingsboard/server/dao/sql/device/JpaDeviceDao.java b/dao/src/main/java/org/thingsboard/server/dao/sql/device/JpaDeviceDao.java index 9c79637e39..5d70ee53e5 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/sql/device/JpaDeviceDao.java +++ b/dao/src/main/java/org/thingsboard/server/dao/sql/device/JpaDeviceDao.java @@ -28,6 +28,7 @@ import org.thingsboard.server.common.data.DeviceIdInfo; import org.thingsboard.server.common.data.DeviceInfo; import org.thingsboard.server.common.data.DeviceInfoFilter; import org.thingsboard.server.common.data.DeviceTransportType; +import org.thingsboard.server.common.data.EntityInfo; import org.thingsboard.server.common.data.EntitySubtype; import org.thingsboard.server.common.data.EntityType; import org.thingsboard.server.common.data.ProfileEntityIdInfo; @@ -114,6 +115,11 @@ public class JpaDeviceDao extends JpaAbstractDao implement DaoUtil.toPageable(pageLink))); } + @Override + public EntityInfo findEntityInfoByName(TenantId tenantId, String name) { + return deviceRepository.findEntityInfoByName(tenantId.getId(), name); + } + @Override public ListenableFuture> findDevicesByTenantIdAndIdsAsync(UUID tenantId, List deviceIds) { return service.submit(() -> DaoUtil.convertDataList(deviceRepository.findDevicesByTenantIdAndIdIn(tenantId, deviceIds))); diff --git a/dao/src/main/java/org/thingsboard/server/dao/sql/entityview/EntityViewRepository.java b/dao/src/main/java/org/thingsboard/server/dao/sql/entityview/EntityViewRepository.java index 6094e9b171..0e66850be8 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/sql/entityview/EntityViewRepository.java +++ b/dao/src/main/java/org/thingsboard/server/dao/sql/entityview/EntityViewRepository.java @@ -21,6 +21,7 @@ import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; +import org.thingsboard.server.common.data.EntityInfo; import org.thingsboard.server.common.data.edqs.fields.EntityViewFields; import org.thingsboard.server.dao.ExportableEntityRepository; import org.thingsboard.server.dao.model.sql.EntityViewEntity; @@ -118,6 +119,10 @@ public interface EntityViewRepository extends JpaRepository findAllByTenantIdAndEntityId(UUID tenantId, UUID entityId); boolean existsByTenantIdAndEntityId(UUID tenantId, UUID entityId); From 265b63dc06d6bd8fad15a0e152c621bc174be245 Mon Sep 17 00:00:00 2001 From: dshvaika Date: Tue, 7 Oct 2025 13:01:11 +0300 Subject: [PATCH 15/43] fixed NPE on reference entities check + added basic controller tests for Propagation CF --- .../CalculatedFieldControllerTest.java | 95 +++++++++++++++++-- .../AlarmCalculatedFieldConfiguration.java | 2 - ...entsBasedCalculatedFieldConfiguration.java | 7 ++ .../CalculatedFieldConfiguration.java | 3 +- ...eofencingCalculatedFieldConfiguration.java | 2 +- .../dao/cf/BaseCalculatedFieldService.java | 3 +- 6 files changed, 96 insertions(+), 16 deletions(-) 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 27622b347a..4da8da8564 100644 --- a/application/src/test/java/org/thingsboard/server/controller/CalculatedFieldControllerTest.java +++ b/application/src/test/java/org/thingsboard/server/controller/CalculatedFieldControllerTest.java @@ -28,6 +28,7 @@ import org.thingsboard.server.common.data.cf.configuration.ArgumentType; import org.thingsboard.server.common.data.cf.configuration.CalculatedFieldConfiguration; import org.thingsboard.server.common.data.cf.configuration.Output; import org.thingsboard.server.common.data.cf.configuration.OutputType; +import org.thingsboard.server.common.data.cf.configuration.PropagationCalculatedFieldConfiguration; import org.thingsboard.server.common.data.cf.configuration.ReferencedEntityKey; import org.thingsboard.server.common.data.cf.configuration.RelationPathQueryDynamicSourceConfiguration; import org.thingsboard.server.common.data.cf.configuration.SimpleCalculatedFieldConfiguration; @@ -35,6 +36,7 @@ import org.thingsboard.server.common.data.cf.configuration.geofencing.EntityCoor import org.thingsboard.server.common.data.cf.configuration.geofencing.GeofencingCalculatedFieldConfiguration; import org.thingsboard.server.common.data.cf.configuration.geofencing.ZoneGroupConfiguration; import org.thingsboard.server.common.data.id.DeviceId; +import org.thingsboard.server.common.data.relation.EntityRelation; import org.thingsboard.server.common.data.relation.EntitySearchDirection; import org.thingsboard.server.common.data.relation.RelationPathLevel; import org.thingsboard.server.common.data.security.Authority; @@ -44,6 +46,7 @@ import java.util.List; import java.util.Map; import static org.assertj.core.api.Assertions.assertThat; +import static org.hamcrest.Matchers.containsString; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; import static org.thingsboard.server.common.data.cf.configuration.geofencing.GeofencingReportStrategy.REPORT_TRANSITION_EVENTS_AND_PRESENCE_STATUS; @@ -81,7 +84,7 @@ public class CalculatedFieldControllerTest extends AbstractControllerTest { @Test public void testSaveCalculatedField() throws Exception { Device testDevice = createDevice("Test device", "1234567890"); - CalculatedField calculatedField = getCalculatedField(testDevice.getId()); + CalculatedField calculatedField = getSimpleCalculatedField(testDevice.getId()); CalculatedField savedCalculatedField = doPost("/api/calculatedField", calculatedField, CalculatedField.class); @@ -109,7 +112,7 @@ public class CalculatedFieldControllerTest extends AbstractControllerTest { @Test public void testSaveGeofencingCalculatedField() throws Exception { Device testDevice = createDevice("Test device", "1234567890"); - CalculatedField calculatedField = getCalculatedField(testDevice.getId(), getGeofencingCalculatedFieldConfig()); + CalculatedField calculatedField = getCalculatedField(testDevice.getId(), CalculatedFieldType.GEOFENCING); CalculatedField savedCalculatedField = doPost("/api/calculatedField", calculatedField, CalculatedField.class); @@ -134,10 +137,48 @@ public class CalculatedFieldControllerTest extends AbstractControllerTest { .andExpect(status().isOk()); } + @Test + public void testSavePropagationCalculatedField() throws Exception { + Device testDevice = createDevice("Test device", "1234567890"); + CalculatedField calculatedField = getCalculatedField(testDevice.getId(), CalculatedFieldType.PROPAGATION); + + CalculatedField savedCalculatedField = doPost("/api/calculatedField", calculatedField, CalculatedField.class); + + assertThat(savedCalculatedField).isNotNull(); + assertThat(savedCalculatedField.getId()).isNotNull(); + assertThat(savedCalculatedField.getCreatedTime()).isGreaterThan(0); + assertThat(savedCalculatedField.getTenantId()).isEqualTo(savedTenant.getId()); + assertThat(savedCalculatedField.getEntityId()).isEqualTo(calculatedField.getEntityId()); + assertThat(savedCalculatedField.getType()).isEqualTo(calculatedField.getType()); + assertThat(savedCalculatedField.getName()).isEqualTo(calculatedField.getName()); + assertThat(savedCalculatedField.getConfiguration()).isEqualTo(getPropagationCalculatedFieldConfig()); + assertThat(savedCalculatedField.getVersion()).isEqualTo(1L); + + savedCalculatedField.setName("Test CF"); + + CalculatedField updatedCalculatedField = doPost("/api/calculatedField", savedCalculatedField, CalculatedField.class); + + assertThat(updatedCalculatedField.getName()).isEqualTo(savedCalculatedField.getName()); + assertThat(updatedCalculatedField.getVersion()).isEqualTo(savedCalculatedField.getVersion() + 1); + + doDelete("/api/calculatedField/" + savedCalculatedField.getId().getId().toString()) + .andExpect(status().isOk()); + } + + @Test + public void testSavePropagationCalculatedFieldWithNullArguments() throws Exception { + Device testDevice = createDevice("Test device", "1234567890"); + CalculatedField calculatedField = getCalculatedField(testDevice.getId(), CalculatedFieldType.PROPAGATION, getPropagationCalculatedFieldConfig(null)); + + doPost("/api/calculatedField", calculatedField) + .andExpect(status().isBadRequest()) + .andExpect(statusReason(containsString("arguments must not be empty"))); + } + @Test public void testGetCalculatedFieldById() throws Exception { Device testDevice = createDevice("Test device", "1234567890"); - CalculatedField calculatedField = getCalculatedField(testDevice.getId()); + CalculatedField calculatedField = getSimpleCalculatedField(testDevice.getId()); CalculatedField savedCalculatedField = doPost("/api/calculatedField", calculatedField, CalculatedField.class); CalculatedField fetchedCalculatedField = doGet("/api/calculatedField/" + savedCalculatedField.getId().getId(), CalculatedField.class); @@ -152,7 +193,7 @@ public class CalculatedFieldControllerTest extends AbstractControllerTest { @Test public void testDeleteCalculatedField() throws Exception { Device testDevice = createDevice("Test device", "1234567890"); - CalculatedField calculatedField = getCalculatedField(testDevice.getId()); + CalculatedField calculatedField = getSimpleCalculatedField(testDevice.getId()); CalculatedField savedCalculatedField = doPost("/api/calculatedField", calculatedField, CalculatedField.class); @@ -163,17 +204,27 @@ public class CalculatedFieldControllerTest extends AbstractControllerTest { doGet("/api/calculatedField/" + savedCalculatedField.getId().getId()).andExpect(status().isNotFound()); } - private CalculatedField getCalculatedField(DeviceId deviceId) { - return getCalculatedField(deviceId, getSimpleCalculatedFieldConfig()); + private CalculatedField getSimpleCalculatedField(DeviceId deviceId) { + return getCalculatedField(deviceId, CalculatedFieldType.SIMPLE); + } + + private CalculatedField getCalculatedField(DeviceId deviceId, CalculatedFieldType cfType) { + return getCalculatedField(deviceId, cfType, null); } - private CalculatedField getCalculatedField(DeviceId deviceId, CalculatedFieldConfiguration configuration) { + private CalculatedField getCalculatedField(DeviceId deviceId, CalculatedFieldType cfType, CalculatedFieldConfiguration customConfiguration) { CalculatedField calculatedField = new CalculatedField(); calculatedField.setEntityId(deviceId); - calculatedField.setType(CalculatedFieldType.SIMPLE); + calculatedField.setType(cfType); calculatedField.setName("Test Calculated Field"); calculatedField.setConfigurationVersion(1); - calculatedField.setConfiguration(configuration); + if (customConfiguration != null) { + calculatedField.setConfiguration(customConfiguration); + } else switch (cfType) { + case SIMPLE -> calculatedField.setConfiguration(getSimpleCalculatedFieldConfig()); + case GEOFENCING -> calculatedField.setConfiguration(getGeofencingCalculatedFieldConfig()); + case PROPAGATION -> calculatedField.setConfiguration(getPropagationCalculatedFieldConfig()); + } calculatedField.setVersion(1L); return calculatedField; } @@ -198,6 +249,32 @@ public class CalculatedFieldControllerTest extends AbstractControllerTest { return config; } + private CalculatedFieldConfiguration getPropagationCalculatedFieldConfig() { + Argument arg = new Argument(); + arg.setRefEntityKey(new ReferencedEntityKey("temperature", ArgumentType.TS_LATEST, null)); + return getPropagationCalculatedFieldConfig(Map.of("t", arg)); + } + + private CalculatedFieldConfiguration getPropagationCalculatedFieldConfig(Map arguments) { + var config = new PropagationCalculatedFieldConfiguration(); + + config.setRelationType(EntityRelation.CONTAINS_TYPE); + config.setDirection(EntitySearchDirection.TO); + + config.setApplyExpressionToResolvedArguments(false); + config.setExpression(null); + + Output output = new Output(); + output.setType(OutputType.TIME_SERIES); + config.setOutput(output); + + Argument arg = new Argument(); + arg.setRefEntityKey(new ReferencedEntityKey("temperature", ArgumentType.TS_LATEST, null)); + config.setArguments(arguments); + + return config; + } + private CalculatedFieldConfiguration getSimpleCalculatedFieldConfig() { SimpleCalculatedFieldConfiguration config = new SimpleCalculatedFieldConfiguration(); diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/AlarmCalculatedFieldConfiguration.java b/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/AlarmCalculatedFieldConfiguration.java index c2925d5ed6..d1e0a33916 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/AlarmCalculatedFieldConfiguration.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/AlarmCalculatedFieldConfiguration.java @@ -29,8 +29,6 @@ import java.util.Map; @Data public class AlarmCalculatedFieldConfiguration implements ArgumentsBasedCalculatedFieldConfiguration { - @Valid - @NotEmpty private Map arguments; @Valid diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/ArgumentsBasedCalculatedFieldConfiguration.java b/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/ArgumentsBasedCalculatedFieldConfiguration.java index 31c95b2119..f422869c95 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/ArgumentsBasedCalculatedFieldConfiguration.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/ArgumentsBasedCalculatedFieldConfiguration.java @@ -15,6 +15,8 @@ */ package org.thingsboard.server.common.data.cf.configuration; +import jakarta.validation.Valid; +import jakarta.validation.constraints.NotEmpty; import org.thingsboard.server.common.data.id.EntityId; import java.util.List; @@ -24,9 +26,14 @@ import java.util.stream.Collectors; public interface ArgumentsBasedCalculatedFieldConfiguration extends CalculatedFieldConfiguration { + @Valid + @NotEmpty Map getArguments(); default List getReferencedEntities() { + if (getArguments() == null) { + return List.of(); + } return getArguments().values().stream() .map(Argument::getRefEntityId) .filter(Objects::nonNull) diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/CalculatedFieldConfiguration.java b/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/CalculatedFieldConfiguration.java index 8676c6060f..bdf2bdcb93 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/CalculatedFieldConfiguration.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/CalculatedFieldConfiguration.java @@ -27,7 +27,6 @@ import org.thingsboard.server.common.data.id.CalculatedFieldId; import org.thingsboard.server.common.data.id.EntityId; import org.thingsboard.server.common.data.id.TenantId; -import java.util.Collections; import java.util.List; import java.util.stream.Collectors; @@ -55,7 +54,7 @@ public interface CalculatedFieldConfiguration { @JsonIgnore default List getReferencedEntities() { - return Collections.emptyList(); + return List.of(); } default CalculatedFieldLink buildCalculatedFieldLink(TenantId tenantId, EntityId referencedEntityId, CalculatedFieldId calculatedFieldId) { diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/geofencing/GeofencingCalculatedFieldConfiguration.java b/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/geofencing/GeofencingCalculatedFieldConfiguration.java index ec97ad6116..786d740a26 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/geofencing/GeofencingCalculatedFieldConfiguration.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/geofencing/GeofencingCalculatedFieldConfiguration.java @@ -56,7 +56,7 @@ public class GeofencingCalculatedFieldConfiguration implements ArgumentsBasedCal @Override public List getReferencedEntities() { - return zoneGroups.values().stream().map(ZoneGroupConfiguration::getRefEntityId).filter(Objects::nonNull).toList(); + return zoneGroups == null ? List.of() : zoneGroups.values().stream().map(ZoneGroupConfiguration::getRefEntityId).filter(Objects::nonNull).toList(); } @Override diff --git a/dao/src/main/java/org/thingsboard/server/dao/cf/BaseCalculatedFieldService.java b/dao/src/main/java/org/thingsboard/server/dao/cf/BaseCalculatedFieldService.java index c0cb886747..7a9fccf401 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/cf/BaseCalculatedFieldService.java +++ b/dao/src/main/java/org/thingsboard/server/dao/cf/BaseCalculatedFieldService.java @@ -57,8 +57,7 @@ public class BaseCalculatedFieldService extends AbstractEntityService implements @Override public CalculatedField save(CalculatedField calculatedField) { - CalculatedField oldCalculatedField = calculatedFieldDataValidator.validate(calculatedField, CalculatedField::getTenantId); - return doSave(calculatedField, oldCalculatedField); + return save(calculatedField, true); } @Override From 2789acd2ddde1b405ff04cc891951e92f25916ea Mon Sep 17 00:00:00 2001 From: dshvaika Date: Tue, 7 Oct 2025 17:12:54 +0300 Subject: [PATCH 16/43] configuration updates + tests --- ...opagationCalculatedFieldConfiguration.java | 14 +-- ...SupportedCalculatedFieldConfiguration.java | 3 + .../geofencing/EntityCoordinates.java | 13 +- ...eofencingCalculatedFieldConfiguration.java | 14 +-- .../geofencing/ZoneGroupConfiguration.java | 10 +- ...ationCalculatedFieldConfigurationTest.java | 116 ++++++++++++++++++ ...ortedCalculatedFieldConfigurationTest.java | 2 +- .../geofencing/EntityCoordinatesTest.java | 31 ----- ...ncingCalculatedFieldConfigurationTest.java | 29 +---- .../ZoneGroupConfigurationTest.java | 18 --- 10 files changed, 142 insertions(+), 108 deletions(-) create mode 100644 common/data/src/test/java/org/thingsboard/server/common/data/cf/configuration/PropagationCalculatedFieldConfigurationTest.java diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/PropagationCalculatedFieldConfiguration.java b/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/PropagationCalculatedFieldConfiguration.java index b592264d6c..3394813d3f 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/PropagationCalculatedFieldConfiguration.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/PropagationCalculatedFieldConfiguration.java @@ -15,6 +15,8 @@ */ package org.thingsboard.server.common.data.cf.configuration; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; import lombok.Data; import lombok.EqualsAndHashCode; import org.thingsboard.server.common.data.StringUtils; @@ -30,7 +32,9 @@ public class PropagationCalculatedFieldConfiguration extends BaseCalculatedField public static final String PROPAGATION_CONFIG_ARGUMENT = "propagationCtx"; + @NotNull private EntitySearchDirection direction; + @NotBlank private String relationType; private boolean applyExpressionToResolvedArguments; @@ -44,20 +48,14 @@ public class PropagationCalculatedFieldConfiguration extends BaseCalculatedField public void validate() { baseCalculatedFieldRestriction(); propagationRestriction(); - if (direction == null) { - throw new IllegalArgumentException("Propagation calculated field direction must be specified!"); - } - if (StringUtils.isBlank(relationType)) { - throw new IllegalArgumentException("Propagation calculated field relation type must be specified!"); - } if (!applyExpressionToResolvedArguments) { arguments.forEach((name, argument) -> { if (argument.getRefEntityKey() == null) { throw new IllegalArgumentException("Argument: '" + name + "' doesn't have reference entity key configured!"); } if (argument.getRefEntityKey().getType() == ArgumentType.TS_ROLLING) { - throw new IllegalArgumentException("Argument type: 'Time series rolling' detected for argument: '" + name + "'! " + - "Only 'Attribute' or 'Latest telemetry' arguments are allowed for in 'Arguments only' propagation mode!"); + throw new IllegalArgumentException("Argument type: 'Time series rolling' detected for argument: '" + name + "'. " + + "Only 'Attribute' or 'Latest telemetry' arguments are allowed for 'Arguments only' propagation mode!"); } }); } else if (StringUtils.isBlank(expression)) { diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/ScheduledUpdateSupportedCalculatedFieldConfiguration.java b/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/ScheduledUpdateSupportedCalculatedFieldConfiguration.java index d0c5786f62..e1e8ca1a9b 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/ScheduledUpdateSupportedCalculatedFieldConfiguration.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/ScheduledUpdateSupportedCalculatedFieldConfiguration.java @@ -15,10 +15,13 @@ */ package org.thingsboard.server.common.data.cf.configuration; +import jakarta.validation.constraints.PositiveOrZero; + public interface ScheduledUpdateSupportedCalculatedFieldConfiguration extends CalculatedFieldConfiguration { boolean isScheduledUpdateEnabled(); + @PositiveOrZero int getScheduledUpdateInterval(); void setScheduledUpdateInterval(int interval); diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/geofencing/EntityCoordinates.java b/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/geofencing/EntityCoordinates.java index 9ea5c19e8c..ad31293061 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/geofencing/EntityCoordinates.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/geofencing/EntityCoordinates.java @@ -16,8 +16,8 @@ package org.thingsboard.server.common.data.cf.configuration.geofencing; +import jakarta.validation.constraints.NotBlank; import lombok.Data; -import org.thingsboard.server.common.data.StringUtils; import org.thingsboard.server.common.data.cf.configuration.Argument; import org.thingsboard.server.common.data.cf.configuration.ArgumentType; import org.thingsboard.server.common.data.cf.configuration.ReferencedEntityKey; @@ -30,18 +30,11 @@ public class EntityCoordinates { public static final String ENTITY_ID_LATITUDE_ARGUMENT_KEY = "latitude"; public static final String ENTITY_ID_LONGITUDE_ARGUMENT_KEY = "longitude"; + @NotBlank private final String latitudeKeyName; + @NotBlank private final String longitudeKeyName; - public void validate() { - if (StringUtils.isBlank(latitudeKeyName)) { - throw new IllegalArgumentException("Entity coordinates latitude key name must be specified!"); - } - if (StringUtils.isBlank(longitudeKeyName)) { - throw new IllegalArgumentException("Entity coordinates longitude key name must be specified!"); - } - } - public Map toArguments() { return Map.of( ENTITY_ID_LATITUDE_ARGUMENT_KEY, toArgument(latitudeKeyName), diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/geofencing/GeofencingCalculatedFieldConfiguration.java b/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/geofencing/GeofencingCalculatedFieldConfiguration.java index 786d740a26..47d344fe1b 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/geofencing/GeofencingCalculatedFieldConfiguration.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/geofencing/GeofencingCalculatedFieldConfiguration.java @@ -16,6 +16,8 @@ package org.thingsboard.server.common.data.cf.configuration.geofencing; import com.fasterxml.jackson.annotation.JsonIgnore; +import jakarta.validation.Valid; +import jakarta.validation.constraints.NotNull; import lombok.Data; import org.thingsboard.server.common.data.cf.CalculatedFieldType; import org.thingsboard.server.common.data.cf.configuration.Argument; @@ -32,7 +34,12 @@ import java.util.Objects; @Data public class GeofencingCalculatedFieldConfiguration implements ArgumentsBasedCalculatedFieldConfiguration, ScheduledUpdateSupportedCalculatedFieldConfiguration { + @Valid + @NotNull private EntityCoordinates entityCoordinates; + + @Valid + @NotNull private Map zoneGroups; private boolean scheduledUpdateEnabled; @@ -66,13 +73,6 @@ public class GeofencingCalculatedFieldConfiguration implements ArgumentsBasedCal @Override public void validate() { - if (entityCoordinates == null) { - throw new IllegalArgumentException("Geofencing calculated field entity coordinates must be specified!"); - } - entityCoordinates.validate(); - if (zoneGroups == null || zoneGroups.isEmpty()) { - throw new IllegalArgumentException("Geofencing calculated field must contain at least one geofencing zone group defined!"); - } zoneGroups.forEach((key, value) -> value.validate(key)); } diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/geofencing/ZoneGroupConfiguration.java b/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/geofencing/ZoneGroupConfiguration.java index 775f711a5e..a06cb242cf 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/geofencing/ZoneGroupConfiguration.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/geofencing/ZoneGroupConfiguration.java @@ -17,6 +17,8 @@ package org.thingsboard.server.common.data.cf.configuration.geofencing; import com.fasterxml.jackson.annotation.JsonIgnore; import com.fasterxml.jackson.annotation.JsonInclude; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; import lombok.Data; import org.springframework.lang.Nullable; import org.thingsboard.server.common.data.AttributeScope; @@ -36,8 +38,10 @@ public class ZoneGroupConfiguration { private EntityId refEntityId; private CfArgumentDynamicSourceConfiguration refDynamicSourceConfiguration; + @NotBlank private final String perimeterKeyName; + @NotNull private final GeofencingReportStrategy reportStrategy; private final boolean createRelationsWithMatchedZones; @@ -48,12 +52,6 @@ public class ZoneGroupConfiguration { if (EntityCoordinates.ENTITY_ID_LATITUDE_ARGUMENT_KEY.equals(name) || EntityCoordinates.ENTITY_ID_LONGITUDE_ARGUMENT_KEY.equals(name)) { throw new IllegalArgumentException("Name '" + name + "' is reserved and cannot be used for zone group!"); } - if (StringUtils.isBlank(perimeterKeyName)) { - throw new IllegalArgumentException("Perimeter key name must be specified for '" + name + "' zone group!"); - } - if (reportStrategy == null) { - throw new IllegalArgumentException("Report strategy must be specified for '" + name + "' zone group!"); - } if (refDynamicSourceConfiguration != null) { refDynamicSourceConfiguration.validate(); } diff --git a/common/data/src/test/java/org/thingsboard/server/common/data/cf/configuration/PropagationCalculatedFieldConfigurationTest.java b/common/data/src/test/java/org/thingsboard/server/common/data/cf/configuration/PropagationCalculatedFieldConfigurationTest.java new file mode 100644 index 0000000000..eb0591e32b --- /dev/null +++ b/common/data/src/test/java/org/thingsboard/server/common/data/cf/configuration/PropagationCalculatedFieldConfigurationTest.java @@ -0,0 +1,116 @@ +/** + * 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.common.data.cf.configuration; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.junit.jupiter.MockitoExtension; +import org.thingsboard.server.common.data.cf.CalculatedFieldType; +import org.thingsboard.server.common.data.relation.EntityRelation; +import org.thingsboard.server.common.data.relation.EntitySearchDirection; + +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.thingsboard.server.common.data.cf.configuration.PropagationCalculatedFieldConfiguration.PROPAGATION_CONFIG_ARGUMENT; + +@ExtendWith(MockitoExtension.class) +public class PropagationCalculatedFieldConfigurationTest { + + @Test + void typeShouldBePropagation() { + var cfg = new PropagationCalculatedFieldConfiguration(); + assertThat(cfg.getType()).isEqualTo(CalculatedFieldType.PROPAGATION); + } + + @Test + void validateShouldThrowWhenUsedReservedPropagationArgumentName() { + var cfg = new PropagationCalculatedFieldConfiguration(); + cfg.setArguments(Map.of(PROPAGATION_CONFIG_ARGUMENT, new Argument())); + assertThatThrownBy(cfg::validate) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("Argument name '" + PROPAGATION_CONFIG_ARGUMENT + "' is reserved and cannot be used."); + } + + @Test + void validateShouldThrowWhenUsedReservedCtxArgumentName() { + var cfg = new PropagationCalculatedFieldConfiguration(); + cfg.setArguments(Map.of("ctx", new Argument())); + assertThatThrownBy(cfg::validate) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("Argument name 'ctx' is reserved and cannot be used."); + } + + @Test + void validateShouldThrowWhenReferencedEntityKeyIsNotSet() { + var cfg = new PropagationCalculatedFieldConfiguration(); + Argument argument = new Argument(); + cfg.setArguments(Map.of("someArgumentName", argument)); + assertThatThrownBy(cfg::validate) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("Argument: 'someArgumentName' doesn't have reference entity key configured!"); + } + + @Test + void validateShouldThrowWhenReferencedEntityKeyTypeIsTsRolling() { + var cfg = new PropagationCalculatedFieldConfiguration(); + ReferencedEntityKey referencedEntityKey = new ReferencedEntityKey("someKey", ArgumentType.TS_ROLLING, null); + Argument argument = new Argument(); + argument.setRefEntityKey(referencedEntityKey); + cfg.setArguments(Map.of("someArgumentName", argument)); + assertThatThrownBy(cfg::validate) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("Argument type: 'Time series rolling' detected for argument: 'someArgumentName'. " + + "Only 'Attribute' or 'Latest telemetry' arguments are allowed for 'Arguments only' propagation mode!"); + } + + @Test + void validateShouldThrowWhenExpressionIsNotSet() { + var cfg = new PropagationCalculatedFieldConfiguration(); + cfg.setArguments(Map.of("someArgumentName", new Argument())); + cfg.setApplyExpressionToResolvedArguments(true); + assertThatThrownBy(cfg::validate) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("Expression must be specified for 'Expression result' propagation mode!"); + } + + @Test + void validateToPropagationArgumentMethodCallReturnCorrectArgument() { + var cfg = new PropagationCalculatedFieldConfiguration(); + cfg.setDirection(EntitySearchDirection.TO); + cfg.setRelationType(EntityRelation.CONTAINS_TYPE); + + Argument propagationArgument = cfg.toPropagationArgument(); + assertThat(propagationArgument).isNotNull(); + assertThat(propagationArgument.getRefEntityId()).isNull(); + assertThat(propagationArgument.getRefEntityKey()).isNull(); + assertThat(propagationArgument.getDefaultValue()).isNull(); + assertThat(propagationArgument.getTimeWindow()).isNull(); + assertThat(propagationArgument.getLimit()).isNull(); + + assertThat(propagationArgument.getRefDynamicSourceConfiguration()) + .isNotNull() + .isInstanceOf(RelationPathQueryDynamicSourceConfiguration.class); + var refDynamicSourceConfiguration = (RelationPathQueryDynamicSourceConfiguration) propagationArgument.getRefDynamicSourceConfiguration(); + assertThat(refDynamicSourceConfiguration.getLevels()).isNotEmpty().hasSize(1); + + var relationPathLevel = refDynamicSourceConfiguration.getLevels().get(0); + assertThat(relationPathLevel.direction()).isEqualTo(EntitySearchDirection.TO); + assertThat(relationPathLevel.relationType()).isEqualTo(EntityRelation.CONTAINS_TYPE); + } + +} diff --git a/common/data/src/test/java/org/thingsboard/server/common/data/cf/configuration/ScheduledUpdateSupportedCalculatedFieldConfigurationTest.java b/common/data/src/test/java/org/thingsboard/server/common/data/cf/configuration/ScheduledUpdateSupportedCalculatedFieldConfigurationTest.java index 3c0956bd08..15a191be97 100644 --- a/common/data/src/test/java/org/thingsboard/server/common/data/cf/configuration/ScheduledUpdateSupportedCalculatedFieldConfigurationTest.java +++ b/common/data/src/test/java/org/thingsboard/server/common/data/cf/configuration/ScheduledUpdateSupportedCalculatedFieldConfigurationTest.java @@ -29,7 +29,7 @@ import static org.assertj.core.api.Assertions.assertThatThrownBy; public class ScheduledUpdateSupportedCalculatedFieldConfigurationTest { @Test - void validateShouldThrowWhenScheduledUpdateIntervalIsSetButTimeUnitIsNotSupported() { + void validateDoesNotThrowAnyExceptionWhenScheduledUpdateIntervalIsGreaterThanMinAllowedIntervalInTenantProfile() { int scheduledUpdateInterval = 60; int minAllowedInterval = scheduledUpdateInterval - 1; diff --git a/common/data/src/test/java/org/thingsboard/server/common/data/cf/configuration/geofencing/EntityCoordinatesTest.java b/common/data/src/test/java/org/thingsboard/server/common/data/cf/configuration/geofencing/EntityCoordinatesTest.java index c5d627c3e6..a8ee18c7d7 100644 --- a/common/data/src/test/java/org/thingsboard/server/common/data/cf/configuration/geofencing/EntityCoordinatesTest.java +++ b/common/data/src/test/java/org/thingsboard/server/common/data/cf/configuration/geofencing/EntityCoordinatesTest.java @@ -16,47 +16,16 @@ package org.thingsboard.server.common.data.cf.configuration.geofencing; import org.junit.jupiter.api.Test; -import org.junit.jupiter.params.ParameterizedTest; -import org.junit.jupiter.params.provider.NullAndEmptySource; -import org.junit.jupiter.params.provider.ValueSource; import org.thingsboard.server.common.data.cf.configuration.Argument; import org.thingsboard.server.common.data.cf.configuration.ArgumentType; import org.thingsboard.server.common.data.cf.configuration.ReferencedEntityKey; import static org.assertj.core.api.Assertions.assertThat; -import static org.assertj.core.api.Assertions.assertThatCode; -import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.thingsboard.server.common.data.cf.configuration.geofencing.EntityCoordinates.ENTITY_ID_LATITUDE_ARGUMENT_KEY; import static org.thingsboard.server.common.data.cf.configuration.geofencing.EntityCoordinates.ENTITY_ID_LONGITUDE_ARGUMENT_KEY; public class EntityCoordinatesTest { - @ParameterizedTest - @ValueSource(strings = " ") - @NullAndEmptySource - void validateShouldThrowWhenLatitudeCoordinateIsNullEmptyOrBlank(String latitudeKey) { - var entityCoordinates = new EntityCoordinates(latitudeKey, "longitude"); - assertThatThrownBy(entityCoordinates::validate) - .isInstanceOf(IllegalArgumentException.class) - .hasMessage("Entity coordinates latitude key name must be specified!"); - } - - @ParameterizedTest - @ValueSource(strings = " ") - @NullAndEmptySource - void validateShouldThrowWhenLongitudeCoordinateIsNullEmptyOrBlank(String longitudeKey) { - var entityCoordinates = new EntityCoordinates("latitude", longitudeKey); - assertThatThrownBy(entityCoordinates::validate) - .isInstanceOf(IllegalArgumentException.class) - .hasMessage("Entity coordinates longitude key name must be specified!"); - } - - @Test - void validateShouldPassOnMinimalValidConfig() { - var entityCoordinates = new EntityCoordinates("latitude", "longitude"); - assertThatCode(entityCoordinates::validate).doesNotThrowAnyException(); - } - @Test void validateToArgumentsMethodCallWithoutRefEntityId() { var entityCoordinates = new EntityCoordinates("xPos", "yPos"); diff --git a/common/data/src/test/java/org/thingsboard/server/common/data/cf/configuration/geofencing/GeofencingCalculatedFieldConfigurationTest.java b/common/data/src/test/java/org/thingsboard/server/common/data/cf/configuration/geofencing/GeofencingCalculatedFieldConfigurationTest.java index 1a3e6b4eb1..2e9d5e4a88 100644 --- a/common/data/src/test/java/org/thingsboard/server/common/data/cf/configuration/geofencing/GeofencingCalculatedFieldConfigurationTest.java +++ b/common/data/src/test/java/org/thingsboard/server/common/data/cf/configuration/geofencing/GeofencingCalculatedFieldConfigurationTest.java @@ -28,7 +28,6 @@ import java.util.Map; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatCode; -import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.verify; import static org.thingsboard.server.common.data.cf.configuration.geofencing.EntityCoordinates.ENTITY_ID_LATITUDE_ARGUMENT_KEY; @@ -44,28 +43,7 @@ public class GeofencingCalculatedFieldConfigurationTest { } @Test - void validateShouldThrowWhenEntityCoordinatesNull() { - var cfg = new GeofencingCalculatedFieldConfiguration(); - cfg.setEntityCoordinates(null); - - assertThatThrownBy(cfg::validate) - .isInstanceOf(IllegalArgumentException.class) - .hasMessage("Geofencing calculated field entity coordinates must be specified!"); - } - - @Test - void validateShouldThrowWhenZoneGroupsNull() { - var cfg = new GeofencingCalculatedFieldConfiguration(); - cfg.setEntityCoordinates(new EntityCoordinates(ENTITY_ID_LATITUDE_ARGUMENT_KEY, ENTITY_ID_LONGITUDE_ARGUMENT_KEY)); - cfg.setZoneGroups(null); - - assertThatThrownBy(cfg::validate) - .isInstanceOf(IllegalArgumentException.class) - .hasMessage("Geofencing calculated field must contain at least one geofencing zone group defined!"); - } - - @Test - void validateShouldCallValidateOnEntityCoordinatesAndZoneGroups() { + void validateShouldCallValidateOnZoneGroups() { var cfg = new GeofencingCalculatedFieldConfiguration(); EntityCoordinates entityCoordinatesMock = mock(EntityCoordinates.class); cfg.setEntityCoordinates(entityCoordinatesMock); @@ -73,13 +51,11 @@ public class GeofencingCalculatedFieldConfigurationTest { cfg.setZoneGroups(Map.of("someGroupName", zoneGroupConfiguration)); cfg.validate(); - - verify(entityCoordinatesMock).validate(); verify(zoneGroupConfiguration).validate("someGroupName"); } @Test - void validateShouldCallValidateOnEntityCoordinatesAndZoneGroupsWithoutAnyExceptions() { + void validateShouldCallValidateOnZoneGroupsWithoutAnyExceptions() { var cfg = new GeofencingCalculatedFieldConfiguration(); EntityCoordinates entityCoordinatesMock = mock(EntityCoordinates.class); cfg.setEntityCoordinates(entityCoordinatesMock); @@ -93,7 +69,6 @@ public class GeofencingCalculatedFieldConfigurationTest { assertThatCode(cfg::validate).doesNotThrowAnyException(); - verify(entityCoordinatesMock).validate(); verify(zoneGroupConfigurationA).validate(zoneGroupAName); verify(zoneGroupConfigurationB).validate(zoneGroupBName); } diff --git a/common/data/src/test/java/org/thingsboard/server/common/data/cf/configuration/geofencing/ZoneGroupConfigurationTest.java b/common/data/src/test/java/org/thingsboard/server/common/data/cf/configuration/geofencing/ZoneGroupConfigurationTest.java index 0a5c3be166..e354a05af9 100644 --- a/common/data/src/test/java/org/thingsboard/server/common/data/cf/configuration/geofencing/ZoneGroupConfigurationTest.java +++ b/common/data/src/test/java/org/thingsboard/server/common/data/cf/configuration/geofencing/ZoneGroupConfigurationTest.java @@ -45,24 +45,6 @@ public class ZoneGroupConfigurationTest { .hasMessage("Name '" + name + "' is reserved and cannot be used for zone group!"); } - @ParameterizedTest - @ValueSource(strings = " ") - @NullAndEmptySource - void validateShouldThrowWhenPerimeterKeyNameIsNullEmptyOrBlank(String perimeterKeyName) { - var zoneGroupConfiguration = new ZoneGroupConfiguration(perimeterKeyName, REPORT_TRANSITION_EVENTS_AND_PRESENCE_STATUS, false); - assertThatThrownBy(() -> zoneGroupConfiguration.validate("allowedZonesGroup")) - .isInstanceOf(IllegalArgumentException.class) - .hasMessage("Perimeter key name must be specified for 'allowedZonesGroup' zone group!"); - } - - @Test - void validateShouldThrowWhenReportStrategyIsNull() { - var zoneGroupConfiguration = new ZoneGroupConfiguration("perimeter", null, false); - assertThatThrownBy(() -> zoneGroupConfiguration.validate("allowedZonesGroup")) - .isInstanceOf(IllegalArgumentException.class) - .hasMessage("Report strategy must be specified for 'allowedZonesGroup' zone group!"); - } - @ParameterizedTest @ValueSource(strings = " ") @NullAndEmptySource From 720eab396c76dc836eeddc4c0cadd6a7da366114 Mon Sep 17 00:00:00 2001 From: dashevchenko Date: Tue, 7 Oct 2025 17:20:11 +0300 Subject: [PATCH 17/43] extended name conflict strategy to include name suffix separator --- .../main/data/upgrade/basic/schema_update.sql | 3 ++ .../server/controller/AssetController.java | 15 ++++++---- .../server/controller/CustomerController.java | 15 ++++++---- .../server/controller/DeviceController.java | 29 ++++++++++++------- .../controller/EntityViewController.java | 15 ++++++---- .../server/controller/Lwm2mController.java | 4 ++- .../device/DeviceBulkImportService.java | 3 +- .../entitiy/SimpleTbEntityService.java | 5 ++++ .../entitiy/asset/DefaultTbAssetService.java | 5 ++++ .../service/entitiy/asset/TbAssetService.java | 2 ++ .../customer/DefaultTbCustomerService.java | 8 ++++- .../controller/AssetControllerTest.java | 18 ++++++++++++ .../controller/CustomerControllerTest.java | 15 ++++++++++ .../controller/DeviceControllerTest.java | 18 ++++++++++++ .../controller/EntityViewControllerTest.java | 19 ++++++++++++ .../server/dao/asset/AssetService.java | 3 -- .../server/dao/customer/CustomerService.java | 3 ++ .../common/data/NameConflictPolicy.java | 23 +++++++++++++++ .../common/data/NameConflictStrategy.java | 8 +++-- .../java/org/thingsboard/server/dao/Dao.java | 4 ++- .../server/dao/asset/AssetDao.java | 1 - .../server/dao/asset/BaseAssetService.java | 7 +++-- .../dao/customer/CustomerServiceImpl.java | 26 ++++++++++++----- .../server/dao/device/DeviceDao.java | 1 - .../server/dao/device/DeviceServiceImpl.java | 11 ++++--- .../dao/entity/AbstractEntityService.java | 12 ++++---- .../dao/entityview/EntityViewServiceImpl.java | 10 ++++--- .../validator/EntityViewDataValidator.java | 8 ----- .../dao/sql/customer/JpaCustomerDao.java | 6 ++++ .../dao/sql/entityview/JpaEntityViewDao.java | 6 ++++ .../main/resources/sql/schema-entities.sql | 1 + 31 files changed, 232 insertions(+), 72 deletions(-) create mode 100644 common/data/src/main/java/org/thingsboard/server/common/data/NameConflictPolicy.java diff --git a/application/src/main/data/upgrade/basic/schema_update.sql b/application/src/main/data/upgrade/basic/schema_update.sql index 0add4c0545..3986a3c222 100644 --- a/application/src/main/data/upgrade/basic/schema_update.sql +++ b/application/src/main/data/upgrade/basic/schema_update.sql @@ -46,3 +46,6 @@ WHERE NOT ( ); -- UPDATE TENANT PROFILE CONFIGURATION END + +ALTER TABLE entity_view ADD CONSTRAINT entity_view_name_unq_key UNIQUE (tenant_id, name); + diff --git a/application/src/main/java/org/thingsboard/server/controller/AssetController.java b/application/src/main/java/org/thingsboard/server/controller/AssetController.java index 9cade90860..15fcfa84ff 100644 --- a/application/src/main/java/org/thingsboard/server/controller/AssetController.java +++ b/application/src/main/java/org/thingsboard/server/controller/AssetController.java @@ -34,6 +34,7 @@ import org.springframework.web.bind.annotation.ResponseStatus; import org.springframework.web.bind.annotation.RestController; import org.thingsboard.server.common.data.Customer; import org.thingsboard.server.common.data.EntitySubtype; +import org.thingsboard.server.common.data.NameConflictPolicy; import org.thingsboard.server.common.data.NameConflictStrategy; import org.thingsboard.server.common.data.asset.Asset; import org.thingsboard.server.common.data.asset.AssetInfo; @@ -139,13 +140,17 @@ public class AssetController extends BaseController { @RequestMapping(value = "/asset", method = RequestMethod.POST) @ResponseBody public Asset saveAsset(@io.swagger.v3.oas.annotations.parameters.RequestBody(description = "A JSON value representing the asset.") @RequestBody Asset asset, - @Parameter(description = "Optional value of name conflict strategy. Possible values: FAIL or UNIQUIFY. " + - "If omitted, FAIL strategy is applied. FAIL strategy implies exception will be thrown if an entity with the same name already exists. " + - "UNIQUIFY strategy appends a numerical suffix to the entity name, if a name conflict occurs.") - @RequestParam(name = "nameConflictStrategy", defaultValue = "FAIL") NameConflictStrategy nameConflictStrategy) throws Exception { + @Parameter(description = "Optional value of name conflict policy. Possible values: FAIL or UNIQUIFY. " + + "If omitted, FAIL policy is applied. FAIL policy implies exception will be thrown if an entity with the same name already exists. " + + "UNIQUIFY policy appends a suffix to the entity name, if a name conflict occurs.") + @RequestParam(name = "policy", defaultValue = "FAIL") NameConflictPolicy policy, + @Parameter(description = "Optional value of name suffix separator used by UNIQUIFY policy. By default, underscore separator is used. " + + "For example, strategy is UNIQUIFY, separator is '-'; if a name conflict occurs for customer asset 'Office A', " + + "created asset will have name like 'Office A-7fsh4f'.") + @RequestParam(name = "separator", defaultValue = "_") String separator) throws Exception { asset.setTenantId(getTenantId()); checkEntity(asset.getId(), asset, Resource.ASSET); - return tbAssetService.save(asset, nameConflictStrategy, getCurrentUser()); + return tbAssetService.save(asset, new NameConflictStrategy(policy, separator), getCurrentUser()); } @ApiOperation(value = "Delete asset (deleteAsset)", diff --git a/application/src/main/java/org/thingsboard/server/controller/CustomerController.java b/application/src/main/java/org/thingsboard/server/controller/CustomerController.java index 5c4acb6dbc..8f37a55e60 100644 --- a/application/src/main/java/org/thingsboard/server/controller/CustomerController.java +++ b/application/src/main/java/org/thingsboard/server/controller/CustomerController.java @@ -33,6 +33,7 @@ import org.springframework.web.bind.annotation.RestController; import org.thingsboard.common.util.JacksonUtil; import org.thingsboard.server.common.data.Customer; import org.thingsboard.server.common.data.NameConflictStrategy; +import org.thingsboard.server.common.data.NameConflictPolicy; import org.thingsboard.server.common.data.exception.ThingsboardException; import org.thingsboard.server.common.data.id.CustomerId; import org.thingsboard.server.common.data.id.TenantId; @@ -130,13 +131,17 @@ public class CustomerController extends BaseController { @RequestMapping(value = "/customer", method = RequestMethod.POST) @ResponseBody public Customer saveCustomer(@io.swagger.v3.oas.annotations.parameters.RequestBody(description = "A JSON value representing the customer.") @RequestBody Customer customer, - @Parameter(description = "Optional value of name conflict strategy. Possible values: FAIL or UNIQUIFY. " + - "If omitted, FAIL strategy is applied. FAIL strategy implies exception will be thrown if an entity with the same name already exists. " + - "UNIQUIFY strategy appends a numerical suffix to the entity name, if a name conflict occurs.") - @RequestParam(name = "nameConflictStrategy", defaultValue = "FAIL") NameConflictStrategy nameConflictStrategy) throws Exception { + @Parameter(description = "Optional value of name conflict policy. Possible values: FAIL or UNIQUIFY. " + + "If omitted, FAIL policy is applied. FAIL policy implies exception will be thrown if an entity with the same name already exists. " + + "UNIQUIFY policy appends a suffix to the entity name, if a name conflict occurs.") + @RequestParam(name = "policy", defaultValue = "FAIL") NameConflictPolicy policy, + @Parameter(description = "Optional value of name suffix separator used by UNIQUIFY policy. By default, underscore separator is used. " + + "For example, strategy is UNIQUIFY, separator is '-'; if a name conflict occurs for customer name 'Customer A', " + + "created customer will have name like 'Customer A-7fsh4f'.") + @RequestParam(name = "separator", defaultValue = "_") String separator) throws Exception { customer.setTenantId(getTenantId()); checkEntity(customer.getId(), customer, Resource.CUSTOMER); - return tbCustomerService.save(customer, getCurrentUser()); + return tbCustomerService.save(customer, new NameConflictStrategy(policy, separator), getCurrentUser()); } @ApiOperation(value = "Delete Customer (deleteCustomer)", diff --git a/application/src/main/java/org/thingsboard/server/controller/DeviceController.java b/application/src/main/java/org/thingsboard/server/controller/DeviceController.java index 724e3a430c..73d615ffd3 100644 --- a/application/src/main/java/org/thingsboard/server/controller/DeviceController.java +++ b/application/src/main/java/org/thingsboard/server/controller/DeviceController.java @@ -46,6 +46,7 @@ import org.thingsboard.server.common.data.Device; import org.thingsboard.server.common.data.DeviceInfo; import org.thingsboard.server.common.data.DeviceInfoFilter; import org.thingsboard.server.common.data.EntitySubtype; +import org.thingsboard.server.common.data.NameConflictPolicy; import org.thingsboard.server.common.data.NameConflictStrategy; import org.thingsboard.server.common.data.SaveDeviceWithCredentialsRequest; import org.thingsboard.server.common.data.Tenant; @@ -180,17 +181,21 @@ public class DeviceController extends BaseController { @Parameter(description = "Optional value of the device credentials to be used during device creation. " + "If omitted, access token will be auto-generated.") @RequestParam(name = "accessToken", required = false) String accessToken, - @Parameter(description = "Optional value of name conflict strategy. Possible values: FAIL or UNIQUIFY. " + - "If omitted, FAIL strategy is applied. FAIL strategy implies exception will be thrown if an entity with the same name already exists. " + - "UNIQUIFY strategy appends a numerical suffix to the entity name, if a name conflict occurs.") - @RequestParam(name = "nameConflictStrategy", defaultValue = "FAIL") NameConflictStrategy nameConflictStrategy) throws Exception { + @Parameter(description = "Optional value of name conflict policy. Possible values: FAIL or UNIQUIFY. " + + "If omitted, FAIL policy is applied. FAIL policy implies exception will be thrown if an entity with the same name already exists. " + + "UNIQUIFY policy appends a suffix to the entity name, if a name conflict occurs.") + @RequestParam(name = "policy", defaultValue = "FAIL") NameConflictPolicy policy, + @Parameter(description = "Optional value of name suffix separator used by UNIQUIFY policy. By default, underscore separator is used. " + + "For example, strategy is UNIQUIFY, separator is '-'; if a name conflict occurs for device name 'thermostat', " + + "created device will have name like 'thermostat-7fsh4f'.") + @RequestParam(name = "separator", defaultValue = "_") String separator) throws Exception { device.setTenantId(getCurrentUser().getTenantId()); if (device.getId() != null) { checkDeviceId(device.getId(), Operation.WRITE); } else { checkEntity(null, device, Resource.DEVICE); } - return tbDeviceService.save(device, accessToken, nameConflictStrategy, getCurrentUser()); + return tbDeviceService.save(device, accessToken, new NameConflictStrategy(policy, separator), getCurrentUser()); } @ApiOperation(value = "Create Device (saveDevice) with credentials ", @@ -216,15 +221,19 @@ public class DeviceController extends BaseController { @ResponseBody public Device saveDeviceWithCredentials(@Parameter(description = "The JSON object with device and credentials. See method description above for example.") @Valid @RequestBody SaveDeviceWithCredentialsRequest deviceAndCredentials, - @Parameter(description = "Optional value of name conflict strategy. Possible values: FAIL or UNIQUIFY. " + - "If omitted, FAIL strategy is applied. FAIL strategy implies exception will be thrown if an entity with the same name already exists. " + - "UNIQUIFY strategy appends a numerical suffix to the entity name, if a name conflict occurs.") - @RequestParam(name = "nameConflictStrategy", defaultValue = "FAIL") NameConflictStrategy nameConflictStrategy) throws ThingsboardException { + @Parameter(description = "Optional value of name conflict policy. Possible values: FAIL or UNIQUIFY. " + + "If omitted, FAIL policy is applied. FAIL policy implies exception will be thrown if an entity with the same name already exists. " + + "UNIQUIFY policy appends a suffix to the entity name, if a name conflict occurs.") + @RequestParam(name = "policy", defaultValue = "FAIL") NameConflictPolicy policy, + @Parameter(description = "Optional value of name suffix separator used by UNIQUIFY policy. By default, underscore separator is used. " + + "For example, strategy is UNIQUIFY, separator is '-'; if a name conflict occurs for device name 'thermostat', " + + "created device will have name like 'thermostat-7fsh4f'.") + @RequestParam(name = "separator", defaultValue = "_") String separator) throws ThingsboardException { Device device = deviceAndCredentials.getDevice(); DeviceCredentials credentials = deviceAndCredentials.getCredentials(); device.setTenantId(getCurrentUser().getTenantId()); checkEntity(device.getId(), device, Resource.DEVICE); - return tbDeviceService.saveDeviceWithCredentials(device, credentials, nameConflictStrategy, getCurrentUser()); + return tbDeviceService.saveDeviceWithCredentials(device, credentials, new NameConflictStrategy(policy, separator), getCurrentUser()); } @ApiOperation(value = "Delete device (deleteDevice)", diff --git a/application/src/main/java/org/thingsboard/server/controller/EntityViewController.java b/application/src/main/java/org/thingsboard/server/controller/EntityViewController.java index 147fccda78..37cc7c7797 100644 --- a/application/src/main/java/org/thingsboard/server/controller/EntityViewController.java +++ b/application/src/main/java/org/thingsboard/server/controller/EntityViewController.java @@ -34,6 +34,7 @@ import org.thingsboard.server.common.data.Customer; import org.thingsboard.server.common.data.EntitySubtype; import org.thingsboard.server.common.data.EntityView; import org.thingsboard.server.common.data.EntityViewInfo; +import org.thingsboard.server.common.data.NameConflictPolicy; import org.thingsboard.server.common.data.NameConflictStrategy; import org.thingsboard.server.common.data.edge.Edge; import org.thingsboard.server.common.data.entityview.EntityViewSearchQuery; @@ -130,10 +131,14 @@ public class EntityViewController extends BaseController { public EntityView saveEntityView( @Parameter(description = "A JSON object representing the entity view.") @RequestBody EntityView entityView, - @Parameter(description = "Optional value of name conflict strategy. Possible values: FAIL or UNIQUIFY. " + - "If omitted, FAIL strategy is applied. FAIL strategy implies exception will be thrown if an entity with the same name already exists. " + - "UNIQUIFY strategy appends a numerical suffix to the entity name, if a name conflict occurs.") - @RequestParam(name = "nameConflictStrategy", defaultValue = "FAIL") NameConflictStrategy nameConflictStrategy) throws Exception { + @Parameter(description = "Optional value of name conflict policy. Possible values: FAIL or UNIQUIFY. " + + "If omitted, FAIL policy is applied. FAIL policy implies exception will be thrown if an entity with the same name already exists. " + + "UNIQUIFY policy appends a suffix to the entity name, if a name conflict occurs.") + @RequestParam(name = "policy", defaultValue = "FAIL") NameConflictPolicy policy, + @Parameter(description = "Optional value of name suffix separator used by UNIQUIFY policy. By default, underscore separator is used. " + + "For example, strategy is UNIQUIFY, separator is '-'; if a name conflict occurs for entity view name 'Device A', " + + "created customer will have name like 'Device A-7fsh4f'.") + @RequestParam(name = "separator", defaultValue = "_") String separator) throws Exception { entityView.setTenantId(getCurrentUser().getTenantId()); EntityView existingEntityView = null; if (entityView.getId() == null) { @@ -142,7 +147,7 @@ public class EntityViewController extends BaseController { } else { existingEntityView = checkEntityViewId(entityView.getId(), Operation.WRITE); } - return tbEntityViewService.save(entityView, existingEntityView, nameConflictStrategy, getCurrentUser()); + return tbEntityViewService.save(entityView, existingEntityView, new NameConflictStrategy(policy, separator), getCurrentUser()); } @ApiOperation(value = "Delete entity view (deleteEntityView)", diff --git a/application/src/main/java/org/thingsboard/server/controller/Lwm2mController.java b/application/src/main/java/org/thingsboard/server/controller/Lwm2mController.java index 1febbb9bfd..cdab0805a1 100644 --- a/application/src/main/java/org/thingsboard/server/controller/Lwm2mController.java +++ b/application/src/main/java/org/thingsboard/server/controller/Lwm2mController.java @@ -27,6 +27,7 @@ import org.springframework.web.bind.annotation.ResponseBody; import org.springframework.web.bind.annotation.RestController; import org.thingsboard.common.util.JacksonUtil; import org.thingsboard.server.common.data.Device; +import org.thingsboard.server.common.data.NameConflictPolicy; import org.thingsboard.server.common.data.NameConflictStrategy; import org.thingsboard.server.common.data.SaveDeviceWithCredentialsRequest; import org.thingsboard.server.common.data.device.profile.lwm2m.bootstrap.LwM2MServerSecurityConfigDefault; @@ -38,6 +39,7 @@ import org.thingsboard.server.service.lwm2m.LwM2MService; import java.util.Map; +import static org.thingsboard.server.common.data.NameConflictStrategy.DEFAULT; import static org.thingsboard.server.controller.ControllerConstants.IS_BOOTSTRAP_SERVER_PARAM_DESCRIPTION; import static org.thingsboard.server.controller.ControllerConstants.TENANT_OR_CUSTOMER_AUTHORITY_PARAGRAPH; @@ -74,6 +76,6 @@ public class Lwm2mController extends BaseController { public Device saveDeviceWithCredentials(@RequestBody Map, Object> deviceWithDeviceCredentials) throws ThingsboardException { Device device = checkNotNull(JacksonUtil.convertValue(deviceWithDeviceCredentials.get(Device.class), Device.class)); DeviceCredentials credentials = checkNotNull(JacksonUtil.convertValue(deviceWithDeviceCredentials.get(DeviceCredentials.class), DeviceCredentials.class)); - return deviceController.saveDeviceWithCredentials(new SaveDeviceWithCredentialsRequest(device, credentials), NameConflictStrategy.FAIL); + return deviceController.saveDeviceWithCredentials(new SaveDeviceWithCredentialsRequest(device, credentials), DEFAULT.policy(), DEFAULT.separator()); } } diff --git a/application/src/main/java/org/thingsboard/server/service/device/DeviceBulkImportService.java b/application/src/main/java/org/thingsboard/server/service/device/DeviceBulkImportService.java index 316069280a..952756210c 100644 --- a/application/src/main/java/org/thingsboard/server/service/device/DeviceBulkImportService.java +++ b/application/src/main/java/org/thingsboard/server/service/device/DeviceBulkImportService.java @@ -29,6 +29,7 @@ import org.thingsboard.server.common.data.DeviceProfileProvisionType; import org.thingsboard.server.common.data.DeviceProfileType; import org.thingsboard.server.common.data.DeviceTransportType; import org.thingsboard.server.common.data.EntityType; +import org.thingsboard.server.common.data.NameConflictPolicy; import org.thingsboard.server.common.data.NameConflictStrategy; import org.thingsboard.server.common.data.StringUtils; import org.thingsboard.server.common.data.device.credentials.BasicMqttCredentials; @@ -129,7 +130,7 @@ public class DeviceBulkImportService extends AbstractBulkImportService { } device.setDeviceProfileId(deviceProfile.getId()); - return tbDeviceService.saveDeviceWithCredentials(device, deviceCredentials, NameConflictStrategy.FAIL, user); + return tbDeviceService.saveDeviceWithCredentials(device, deviceCredentials, NameConflictStrategy.DEFAULT, user); } @Override diff --git a/application/src/main/java/org/thingsboard/server/service/entitiy/SimpleTbEntityService.java b/application/src/main/java/org/thingsboard/server/service/entitiy/SimpleTbEntityService.java index 61d52f00bb..84d606251c 100644 --- a/application/src/main/java/org/thingsboard/server/service/entitiy/SimpleTbEntityService.java +++ b/application/src/main/java/org/thingsboard/server/service/entitiy/SimpleTbEntityService.java @@ -15,6 +15,7 @@ */ package org.thingsboard.server.service.entitiy; +import org.thingsboard.server.common.data.NameConflictStrategy; import org.thingsboard.server.common.data.User; import org.thingsboard.server.service.security.model.SecurityUser; @@ -26,6 +27,10 @@ public interface SimpleTbEntityService { T save(T entity, SecurityUser user) throws Exception; + default T save(T entity, NameConflictStrategy nameConflictPolicy, SecurityUser user) throws Exception { + return save(entity, null); + } + void delete(T entity, User user); } diff --git a/application/src/main/java/org/thingsboard/server/service/entitiy/asset/DefaultTbAssetService.java b/application/src/main/java/org/thingsboard/server/service/entitiy/asset/DefaultTbAssetService.java index f55635d1d7..ef630c2df4 100644 --- a/application/src/main/java/org/thingsboard/server/service/entitiy/asset/DefaultTbAssetService.java +++ b/application/src/main/java/org/thingsboard/server/service/entitiy/asset/DefaultTbAssetService.java @@ -39,6 +39,11 @@ public class DefaultTbAssetService extends AbstractTbEntityService implements Tb private final AssetService assetService; + @Override + public Asset save(Asset asset, User user) throws Exception { + return save(asset, NameConflictStrategy.DEFAULT, user); + } + @Override public Asset save(Asset asset, NameConflictStrategy nameConflictStrategy, User user) throws Exception { ActionType actionType = asset.getId() == null ? ActionType.ADDED : ActionType.UPDATED; diff --git a/application/src/main/java/org/thingsboard/server/service/entitiy/asset/TbAssetService.java b/application/src/main/java/org/thingsboard/server/service/entitiy/asset/TbAssetService.java index 52b20fc52b..42fce5213e 100644 --- a/application/src/main/java/org/thingsboard/server/service/entitiy/asset/TbAssetService.java +++ b/application/src/main/java/org/thingsboard/server/service/entitiy/asset/TbAssetService.java @@ -26,6 +26,8 @@ import org.thingsboard.server.common.data.id.TenantId; public interface TbAssetService { + Asset save(Asset asset, User user) throws Exception; + Asset save(Asset asset, NameConflictStrategy nameConflictStrategy, User user) throws Exception; void delete(Asset asset, User user); diff --git a/application/src/main/java/org/thingsboard/server/service/entitiy/customer/DefaultTbCustomerService.java b/application/src/main/java/org/thingsboard/server/service/entitiy/customer/DefaultTbCustomerService.java index c070aea087..a2a0e6846d 100644 --- a/application/src/main/java/org/thingsboard/server/service/entitiy/customer/DefaultTbCustomerService.java +++ b/application/src/main/java/org/thingsboard/server/service/entitiy/customer/DefaultTbCustomerService.java @@ -19,6 +19,7 @@ import lombok.AllArgsConstructor; import org.springframework.stereotype.Service; import org.thingsboard.server.common.data.Customer; import org.thingsboard.server.common.data.EntityType; +import org.thingsboard.server.common.data.NameConflictStrategy; import org.thingsboard.server.common.data.User; import org.thingsboard.server.common.data.audit.ActionType; import org.thingsboard.server.common.data.id.CustomerId; @@ -32,10 +33,15 @@ public class DefaultTbCustomerService extends AbstractTbEntityService implements @Override public Customer save(Customer customer, SecurityUser user) throws Exception { + return save(customer, NameConflictStrategy.DEFAULT, user); + } + + @Override + public Customer save(Customer customer, NameConflictStrategy nameConflictStrategy, SecurityUser user) throws Exception { ActionType actionType = customer.getId() == null ? ActionType.ADDED : ActionType.UPDATED; TenantId tenantId = customer.getTenantId(); try { - Customer savedCustomer = checkNotNull(customerService.saveCustomer(customer)); + Customer savedCustomer = checkNotNull(customerService.saveCustomer(customer, nameConflictStrategy)); autoCommit(user, savedCustomer.getId()); logEntityActionService.logEntityAction(tenantId, savedCustomer.getId(), savedCustomer, null, actionType, user); return savedCustomer; diff --git a/application/src/test/java/org/thingsboard/server/controller/AssetControllerTest.java b/application/src/test/java/org/thingsboard/server/controller/AssetControllerTest.java index 90b5cbeef2..bb0d90e45a 100644 --- a/application/src/test/java/org/thingsboard/server/controller/AssetControllerTest.java +++ b/application/src/test/java/org/thingsboard/server/controller/AssetControllerTest.java @@ -1080,6 +1080,24 @@ public class AssetControllerTest extends AbstractControllerTest { testEntityDaoWithRelationsTransactionalException(assetDao, savedTenant.getId(), assetId, "/api/asset/" + assetId); } + @Test + public void testSaveAssetWithUniquifyStrategy() throws Exception { + Asset asset = new Asset(); + asset.setName("My asset"); + asset.setType("default"); + doPost("/api/asset", asset, Asset.class); + + doPost("/api/asset", asset).andExpect(status().isBadRequest()); + + doPost("/api/asset?policy=FAIL", asset).andExpect(status().isBadRequest()); + + Asset secondAsset = doPost("/api/asset?policy=UNIQUIFY", asset, Asset.class); + assertThat(secondAsset.getName()).startsWith("My asset_"); + + Asset thirdAsset = doPost("/api/asset?policy=UNIQUIFY&separator=-", asset, Asset.class); + assertThat(thirdAsset.getName()).startsWith("My asset-"); + } + private Asset createAsset(String name) { Asset asset = new Asset(); asset.setName(name); diff --git a/application/src/test/java/org/thingsboard/server/controller/CustomerControllerTest.java b/application/src/test/java/org/thingsboard/server/controller/CustomerControllerTest.java index f312e49f71..08eecf3f10 100644 --- a/application/src/test/java/org/thingsboard/server/controller/CustomerControllerTest.java +++ b/application/src/test/java/org/thingsboard/server/controller/CustomerControllerTest.java @@ -462,6 +462,21 @@ public class CustomerControllerTest extends AbstractControllerTest { testEntityDaoWithRelationsTransactionalException(customerDao, savedTenant.getId(), customerId, "/api/customer/" + customerId); } + @Test + public void testSaveCustomerWithUniquifyStrategy() throws Exception { + Customer customer = new Customer(); + customer.setTitle("My customer"); + Customer savedCustomer = doPost("/api/customer", customer, Customer.class); + + doPost("/api/customer?policy=FAIL", customer).andExpect(status().isBadRequest()); + + Customer secondCustomer = doPost("/api/customer?policy=UNIQUIFY", customer, Customer.class); + assertThat(secondCustomer.getName()).startsWith("My customer_"); + + Customer thirdCustomer = doPost("/api/customer?policy=UNIQUIFY&separator=-", customer, Customer.class); + assertThat(thirdCustomer.getName()).startsWith("My customer-"); + } + private Customer createCustomer(String title) { Customer customer = new Customer(); customer.setTitle(title); diff --git a/application/src/test/java/org/thingsboard/server/controller/DeviceControllerTest.java b/application/src/test/java/org/thingsboard/server/controller/DeviceControllerTest.java index 36cede9e84..118e0f1137 100644 --- a/application/src/test/java/org/thingsboard/server/controller/DeviceControllerTest.java +++ b/application/src/test/java/org/thingsboard/server/controller/DeviceControllerTest.java @@ -1608,6 +1608,24 @@ public class DeviceControllerTest extends AbstractControllerTest { assertThat(device.getVersion()).isEqualTo(3); } + @Test + public void testSaveDeviceWithUniquifyStrategy() throws Exception { + Device device = new Device(); + device.setName("My device"); + device.setType("default"); + Device savedDevice = doPost("/api/device", device, Device.class); + + doPost("/api/device", device).andExpect(status().isBadRequest()); + + doPost("/api/device?policy=FAIL", device).andExpect(status().isBadRequest()); + + Device secondDevice = doPost("/api/device?policy=UNIQUIFY", device, Device.class); + assertThat(secondDevice.getName()).startsWith("My device_"); + + Device thirdDevice = doPost("/api/device?policy=UNIQUIFY&separator=-", device, Device.class); + assertThat(thirdDevice.getName()).startsWith("My device-"); + } + private Device createDevice(String name) { Device device = new Device(); device.setName(name); diff --git a/application/src/test/java/org/thingsboard/server/controller/EntityViewControllerTest.java b/application/src/test/java/org/thingsboard/server/controller/EntityViewControllerTest.java index 10b49dfe1d..0549e17e43 100644 --- a/application/src/test/java/org/thingsboard/server/controller/EntityViewControllerTest.java +++ b/application/src/test/java/org/thingsboard/server/controller/EntityViewControllerTest.java @@ -853,4 +853,23 @@ public class EntityViewControllerTest extends AbstractControllerTest { EntityViewId entityViewId = getNewSavedEntityView("EntityView for Test WithRelations Transactional Exception").getId(); testEntityDaoWithRelationsTransactionalException(entityViewDao, tenantId, entityViewId, "/api/entityView/" + entityViewId); } + + @Test + public void testSaveEntityViewWithUniquifyStrategy() throws Exception { + EntityView view = new EntityView(); + view.setEntityId(testDevice.getId()); + view.setTenantId(tenantId); + view.setType("default"); + view.setName("Test device view"); + + EntityView savedView = doPost("/api/entityView", view, EntityView.class); + + doPost("/api/entityView?policy=FAIL", view).andExpect(status().isBadRequest()); + + EntityView secondView = doPost("/api/entityView?policy=UNIQUIFY", view, EntityView.class); + assertThat(secondView.getName()).startsWith("Test device view_"); + + EntityView thirdView = doPost("/api/entityView?policy=UNIQUIFY&separator=-", view, EntityView.class); + assertThat(thirdView.getName()).startsWith("Test device view-"); + } } diff --git a/common/dao-api/src/main/java/org/thingsboard/server/dao/asset/AssetService.java b/common/dao-api/src/main/java/org/thingsboard/server/dao/asset/AssetService.java index a930c86ac9..59c17fb4bd 100644 --- a/common/dao-api/src/main/java/org/thingsboard/server/dao/asset/AssetService.java +++ b/common/dao-api/src/main/java/org/thingsboard/server/dao/asset/AssetService.java @@ -26,15 +26,12 @@ import org.thingsboard.server.common.data.id.AssetId; import org.thingsboard.server.common.data.id.AssetProfileId; import org.thingsboard.server.common.data.id.CustomerId; import org.thingsboard.server.common.data.id.EdgeId; -import org.thingsboard.server.common.data.id.EntityId; -import org.thingsboard.server.common.data.id.HasId; 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.dao.entity.EntityDaoService; import java.util.List; -import java.util.Optional; public interface AssetService extends EntityDaoService { diff --git a/common/dao-api/src/main/java/org/thingsboard/server/dao/customer/CustomerService.java b/common/dao-api/src/main/java/org/thingsboard/server/dao/customer/CustomerService.java index d478e2099a..1cd5ff9ff1 100644 --- a/common/dao-api/src/main/java/org/thingsboard/server/dao/customer/CustomerService.java +++ b/common/dao-api/src/main/java/org/thingsboard/server/dao/customer/CustomerService.java @@ -17,6 +17,7 @@ package org.thingsboard.server.dao.customer; import com.google.common.util.concurrent.ListenableFuture; import org.thingsboard.server.common.data.Customer; +import org.thingsboard.server.common.data.NameConflictStrategy; import org.thingsboard.server.common.data.id.CustomerId; import org.thingsboard.server.common.data.id.TenantId; import org.thingsboard.server.common.data.page.PageData; @@ -37,6 +38,8 @@ public interface CustomerService extends EntityDaoService { Customer saveCustomer(Customer customer); + Customer saveCustomer(Customer customer, NameConflictStrategy nameConflictStrategy); + void deleteCustomer(TenantId tenantId, CustomerId customerId); Customer findOrCreatePublicCustomer(TenantId tenantId); diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/NameConflictPolicy.java b/common/data/src/main/java/org/thingsboard/server/common/data/NameConflictPolicy.java new file mode 100644 index 0000000000..1685dbc933 --- /dev/null +++ b/common/data/src/main/java/org/thingsboard/server/common/data/NameConflictPolicy.java @@ -0,0 +1,23 @@ +/** + * 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.common.data; + +public enum NameConflictPolicy { + + FAIL, + UNIQUIFY; + +} diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/NameConflictStrategy.java b/common/data/src/main/java/org/thingsboard/server/common/data/NameConflictStrategy.java index b21ddce883..00b72f7223 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/NameConflictStrategy.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/NameConflictStrategy.java @@ -15,9 +15,11 @@ */ package org.thingsboard.server.common.data; -public enum NameConflictStrategy { +import io.swagger.v3.oas.annotations.media.Schema; - FAIL, - UNIQUIFY; +@Schema +public record NameConflictStrategy(NameConflictPolicy policy, String separator) { + + public static final NameConflictStrategy DEFAULT = new NameConflictStrategy(NameConflictPolicy.FAIL, null); } diff --git a/dao/src/main/java/org/thingsboard/server/dao/Dao.java b/dao/src/main/java/org/thingsboard/server/dao/Dao.java index 450b030e08..6c1ab74764 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/Dao.java +++ b/dao/src/main/java/org/thingsboard/server/dao/Dao.java @@ -33,7 +33,9 @@ public interface Dao { ListenableFuture findByIdAsync(TenantId tenantId, UUID id); - EntityInfo findEntityInfoByName(TenantId tenantId, String name); + default EntityInfo findEntityInfoByName(TenantId tenantId, String name) { + throw new UnsupportedOperationException(); + } boolean existsById(TenantId tenantId, UUID id); diff --git a/dao/src/main/java/org/thingsboard/server/dao/asset/AssetDao.java b/dao/src/main/java/org/thingsboard/server/dao/asset/AssetDao.java index 4b24e464bc..7735e718c8 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/asset/AssetDao.java +++ b/dao/src/main/java/org/thingsboard/server/dao/asset/AssetDao.java @@ -243,5 +243,4 @@ public interface AssetDao extends Dao, TenantEntityDao, Exportable PageData findProfileEntityIdInfosByTenantId(UUID tenantId, PageLink pageLink); - EntityInfo findEntityInfoByName(TenantId tenantId, String name); } diff --git a/dao/src/main/java/org/thingsboard/server/dao/asset/BaseAssetService.java b/dao/src/main/java/org/thingsboard/server/dao/asset/BaseAssetService.java index 0414a0f4a7..7df76d9153 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/asset/BaseAssetService.java +++ b/dao/src/main/java/org/thingsboard/server/dao/asset/BaseAssetService.java @@ -26,6 +26,7 @@ import org.springframework.transaction.annotation.Transactional; import org.springframework.transaction.event.TransactionalEventListener; import org.thingsboard.server.common.data.EntitySubtype; import org.thingsboard.server.common.data.EntityType; +import org.thingsboard.server.common.data.NameConflictPolicy; import org.thingsboard.server.common.data.NameConflictStrategy; import org.thingsboard.server.common.data.ProfileEntityIdInfo; import org.thingsboard.server.common.data.StringUtils; @@ -154,7 +155,7 @@ public class BaseAssetService extends AbstractCachedEntityService, TenantEntityDao, Exporta PageData findDeviceInfosByFilter(DeviceInfoFilter filter, PageLink pageLink); - EntityInfo findEntityInfoByName(TenantId tenantId, String name); } diff --git a/dao/src/main/java/org/thingsboard/server/dao/device/DeviceServiceImpl.java b/dao/src/main/java/org/thingsboard/server/dao/device/DeviceServiceImpl.java index adce656bef..6a14805ca7 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/device/DeviceServiceImpl.java +++ b/dao/src/main/java/org/thingsboard/server/dao/device/DeviceServiceImpl.java @@ -36,10 +36,10 @@ import org.thingsboard.server.common.data.DeviceInfoFilter; import org.thingsboard.server.common.data.DeviceProfile; import org.thingsboard.server.common.data.DeviceProfileType; import org.thingsboard.server.common.data.DeviceTransportType; -import org.thingsboard.server.common.data.EntityInfo; import org.thingsboard.server.common.data.EntitySubtype; import org.thingsboard.server.common.data.EntityType; import org.thingsboard.server.common.data.EntityView; +import org.thingsboard.server.common.data.NameConflictPolicy; import org.thingsboard.server.common.data.NameConflictStrategy; import org.thingsboard.server.common.data.ProfileEntityIdInfo; import org.thingsboard.server.common.data.StringUtils; @@ -90,7 +90,6 @@ import java.util.ArrayList; import java.util.List; import java.util.Optional; import java.util.UUID; -import java.util.function.Consumer; import static org.thingsboard.server.dao.DaoUtil.toUUIDs; import static org.thingsboard.server.dao.service.Validator.validateId; @@ -190,7 +189,7 @@ public class DeviceServiceImpl extends CachedVersionedEntityService & HasTenantId & HasName> void uniquifyEntityName(E entity, E oldEntity, Consumer setName, EntityType entityType) { + protected & HasTenantId & HasName> void uniquifyEntityName(E entity, E oldEntity, Consumer setName, EntityType entityType, NameConflictStrategy nameConflictStrategy) { Dao dao = entityDaoRegistry.getDao(entityType); EntityInfo existingEntity = dao.findEntityInfoByName(entity.getTenantId(), entity.getName()); if (existingEntity != null && (oldEntity == null || !existingEntity.getId().equals(oldEntity.getId()))) { - int suffix = 1; + String suffix = StringUtils.randomAlphanumeric(6); while (true) { - String newName = entity.getName() + "-" + suffix; + String newName = entity.getName() + nameConflictStrategy.separator() + suffix; if (dao.findEntityInfoByName(entity.getTenantId(), newName) == null) { setName.accept(newName); break; } - suffix++; + suffix = StringUtils.randomAlphanumeric(6); } } } diff --git a/dao/src/main/java/org/thingsboard/server/dao/entityview/EntityViewServiceImpl.java b/dao/src/main/java/org/thingsboard/server/dao/entityview/EntityViewServiceImpl.java index cd01473d69..9b344a8848 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/entityview/EntityViewServiceImpl.java +++ b/dao/src/main/java/org/thingsboard/server/dao/entityview/EntityViewServiceImpl.java @@ -29,6 +29,7 @@ import org.thingsboard.server.common.data.EntitySubtype; import org.thingsboard.server.common.data.EntityType; import org.thingsboard.server.common.data.EntityView; import org.thingsboard.server.common.data.EntityViewInfo; +import org.thingsboard.server.common.data.NameConflictPolicy; import org.thingsboard.server.common.data.NameConflictStrategy; import org.thingsboard.server.common.data.StringUtils; import org.thingsboard.server.common.data.audit.ActionType; @@ -118,7 +119,7 @@ public class EntityViewServiceImpl extends CachedVersionedEntityService { private final TenantService tenantService; private final CustomerDao customerDao; - @Override - protected void validateCreate(TenantId tenantId, EntityView entityView) { - entityViewDao.findEntityViewByTenantIdAndName(entityView.getTenantId().getId(), entityView.getName()) - .ifPresent(e -> { - throw new DataValidationException("Entity view with such name already exists!"); - }); - } - @Override protected EntityView validateUpdate(TenantId tenantId, EntityView entityView) { var opt = entityViewDao.findEntityViewByTenantIdAndName(entityView.getTenantId().getId(), entityView.getName()); diff --git a/dao/src/main/java/org/thingsboard/server/dao/sql/customer/JpaCustomerDao.java b/dao/src/main/java/org/thingsboard/server/dao/sql/customer/JpaCustomerDao.java index 75e7179391..b6827383b2 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/sql/customer/JpaCustomerDao.java +++ b/dao/src/main/java/org/thingsboard/server/dao/sql/customer/JpaCustomerDao.java @@ -20,6 +20,7 @@ import org.springframework.data.domain.Limit; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.stereotype.Component; import org.thingsboard.server.common.data.Customer; +import org.thingsboard.server.common.data.EntityInfo; import org.thingsboard.server.common.data.EntityType; import org.thingsboard.server.common.data.edqs.fields.CustomerFields; import org.thingsboard.server.common.data.id.CustomerId; @@ -117,6 +118,11 @@ public class JpaCustomerDao extends JpaAbstractDao imp return customerRepository.findNextBatch(id, Limit.of(batchSize)); } + @Override + public EntityInfo findEntityInfoByName(TenantId tenantId, String name) { + return customerRepository.findEntityInfoByName(tenantId.getId(), name); + } + @Override public EntityType getEntityType() { return EntityType.CUSTOMER; diff --git a/dao/src/main/java/org/thingsboard/server/dao/sql/entityview/JpaEntityViewDao.java b/dao/src/main/java/org/thingsboard/server/dao/sql/entityview/JpaEntityViewDao.java index 44d8a09ff4..bd4f39162f 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/sql/entityview/JpaEntityViewDao.java +++ b/dao/src/main/java/org/thingsboard/server/dao/sql/entityview/JpaEntityViewDao.java @@ -21,6 +21,7 @@ import org.springframework.beans.factory.annotation.Autowired; import org.springframework.data.domain.Limit; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.stereotype.Component; +import org.thingsboard.server.common.data.EntityInfo; import org.thingsboard.server.common.data.EntitySubtype; import org.thingsboard.server.common.data.EntityType; import org.thingsboard.server.common.data.EntityView; @@ -230,6 +231,11 @@ public class JpaEntityViewDao extends JpaAbstractDao Date: Tue, 7 Oct 2025 17:43:16 +0300 Subject: [PATCH 18/43] MSA tests --- .../server/msa/TestRestClient.java | 45 +++++ .../server/msa/cf/CalculatedFieldTest.java | 183 ++++++++++++++++++ 2 files changed, 228 insertions(+) diff --git a/msa/black-box-tests/src/test/java/org/thingsboard/server/msa/TestRestClient.java b/msa/black-box-tests/src/test/java/org/thingsboard/server/msa/TestRestClient.java index 0a15d6e8aa..7bca833bf4 100644 --- a/msa/black-box-tests/src/test/java/org/thingsboard/server/msa/TestRestClient.java +++ b/msa/black-box-tests/src/test/java/org/thingsboard/server/msa/TestRestClient.java @@ -266,6 +266,33 @@ public class TestRestClient { .as(ArrayNode.class); } + + public ValidatableResponse deleteEntityAttributes(EntityId entityId, AttributeScope scope, String keys) { + Map pathParams = new HashMap<>(); + pathParams.put("entityId", entityId.getId().toString()); + pathParams.put("entityType", entityId.getEntityType().name()); + pathParams.put("scope", scope.name()); + return given().spec(requestSpec) + .pathParams(pathParams) + .queryParam("keys", keys) + .delete("/api/plugins/telemetry/{entityType}/{entityId}/{scope}") + .then() + .statusCode(HTTP_OK); + } + + public ValidatableResponse deleteEntityTimeseries(EntityId entityId, String keys, boolean deleteAllDataForKeys) { + Map pathParams = new HashMap<>(); + pathParams.put("entityType", entityId.getEntityType().name()); + pathParams.put("entityId", entityId.getId().toString()); + return given().spec(requestSpec) + .pathParams(pathParams) + .queryParam("keys", keys) + .queryParam("deleteAllDataForKeys", Boolean.toString(deleteAllDataForKeys)) + .delete("/api/plugins/telemetry/{entityType}/{entityId}/timeseries/delete") + .then() + .statusCode(HTTP_OK); + } + public JsonNode getLatestTelemetry(EntityId entityId) { return given().spec(requestSpec) .get("/api/plugins/telemetry/" + entityId.getEntityType().name() + "/" + entityId.getId() + "/values/timeseries") @@ -378,6 +405,24 @@ public class TestRestClient { .as(EntityRelation.class); } + + public EntityRelation deleteEntityRelation(EntityId fromId, String relationType, EntityId toId) { + Map queryParams = new HashMap<>(); + queryParams.put("fromId", fromId.getId().toString()); + queryParams.put("fromType", fromId.getEntityType().name()); + queryParams.put("relationType", relationType); + queryParams.put("toId", toId.getId().toString()); + queryParams.put("toType", toId.getEntityType().name()); + return given().spec(requestSpec) + .queryParams(queryParams) + //.delete("/api/v2/relation?fromId={fromId}&fromType={fromType}&relationType={relationType}&toId={toId}&toType={toType}") + .delete("/api/v2/relation") + .then() + .statusCode(HTTP_OK) + .extract() + .as(EntityRelation.class); + } + public JsonNode postServerSideRpc(DeviceId deviceId, JsonNode serverRpcPayload) { return given().spec(requestSpec) .body(serverRpcPayload) diff --git a/msa/black-box-tests/src/test/java/org/thingsboard/server/msa/cf/CalculatedFieldTest.java b/msa/black-box-tests/src/test/java/org/thingsboard/server/msa/cf/CalculatedFieldTest.java index 5e8d367538..4993f1fb8b 100644 --- a/msa/black-box-tests/src/test/java/org/thingsboard/server/msa/cf/CalculatedFieldTest.java +++ b/msa/black-box-tests/src/test/java/org/thingsboard/server/msa/cf/CalculatedFieldTest.java @@ -23,6 +23,7 @@ import org.testng.annotations.BeforeClass; import org.testng.annotations.BeforeMethod; import org.testng.annotations.Test; import org.thingsboard.common.util.JacksonUtil; +import org.thingsboard.server.common.data.AttributeScope; import org.thingsboard.server.common.data.Device; import org.thingsboard.server.common.data.DeviceProfile; import org.thingsboard.server.common.data.asset.Asset; @@ -32,6 +33,7 @@ import org.thingsboard.server.common.data.cf.configuration.Argument; import org.thingsboard.server.common.data.cf.configuration.ArgumentType; import org.thingsboard.server.common.data.cf.configuration.Output; import org.thingsboard.server.common.data.cf.configuration.OutputType; +import org.thingsboard.server.common.data.cf.configuration.PropagationCalculatedFieldConfiguration; import org.thingsboard.server.common.data.cf.configuration.ReferencedEntityKey; import org.thingsboard.server.common.data.cf.configuration.RelationPathQueryDynamicSourceConfiguration; import org.thingsboard.server.common.data.cf.configuration.ScriptCalculatedFieldConfiguration; @@ -419,6 +421,179 @@ public class CalculatedFieldTest extends AbstractContainerTest { testRestClient.deleteCalculatedFieldIfExists(saved.getId()); } + @Test + public void testPropagationCalculatedField_withExpression() { + // login tenant admin + testRestClient.getAndSetUserToken(tenantAdminId); + + // --- Arrange entities --- + String deviceToken = "propagationDeviceTokenA"; + Device device = testRestClient.postDevice(deviceToken, createDevice("Propagation Device With Expression", deviceProfileId)); + Asset asset1 = testRestClient.postAsset(createAsset("Propagated Asset 1", null)); + Asset asset2 = testRestClient.postAsset(createAsset("Propagated Asset 2", null)); + + // Create relations FROM assets TO device + EntityRelation rel1 = new EntityRelation(asset1.getId(), device.getId(), EntityRelation.CONTAINS_TYPE); + EntityRelation rel2 = new EntityRelation(asset2.getId(), device.getId(), EntityRelation.CONTAINS_TYPE); + testRestClient.postEntityRelation(rel1); + testRestClient.postEntityRelation(rel2); + + // Telemetry on device + testRestClient.postTelemetry(deviceToken, JacksonUtil.toJsonNode("{\"temperature\":12.5}")); + + // --- Build CF: PROPAGATION with expression --- + CalculatedField cf = new CalculatedField(); + cf.setEntityId(device.getId()); + cf.setType(CalculatedFieldType.PROPAGATION); + cf.setName("Propagation CF (expr)"); + cf.setConfigurationVersion(1); + + PropagationCalculatedFieldConfiguration cfg = new PropagationCalculatedFieldConfiguration(); + cfg.setDirection(EntitySearchDirection.TO); + cfg.setRelationType(EntityRelation.CONTAINS_TYPE); + cfg.setApplyExpressionToResolvedArguments(true); + + Argument arg = new Argument(); + arg.setRefEntityKey(new ReferencedEntityKey("temperature", ArgumentType.TS_LATEST, null)); + cfg.setArguments(Map.of("t", arg)); + + cfg.setExpression("{\"testResult\": t * 2}"); + + Output output = new Output(); + output.setType(OutputType.ATTRIBUTES); + output.setScope(AttributeScope.SERVER_SCOPE); + cfg.setOutput(output); + + cf.setConfiguration(cfg); + + CalculatedField saved = testRestClient.postCalculatedField(cf); + + // --- Assert propagated calculation (expression applied) --- + await().alias("propagation expr mode evaluation") + .atMost(TIMEOUT, TimeUnit.SECONDS) + .pollInterval(POLL_INTERVAL, TimeUnit.SECONDS) + .untilAsserted(() -> { + ArrayNode attrs1 = testRestClient.getAttributes(asset1.getId(), SERVER_SCOPE, "testResult"); + assertThat(attrs1).isNotNull().hasSize(1); + Map m1 = intKv(attrs1); + assertThat(m1).containsEntry("testResult", 25); + + ArrayNode attrs2 = testRestClient.getAttributes(asset2.getId(), SERVER_SCOPE, "testResult"); + assertThat(attrs2).isNotNull().hasSize(1); + Map m2 = intKv(attrs2); + assertThat(m2).containsEntry("testResult", 25); + }); + + testRestClient.deleteEntityRelation(asset1.getId(), EntityRelation.CONTAINS_TYPE, device.getId()); + testRestClient.deleteEntityAttributes(asset1.getId(), SERVER_SCOPE, "testResult"); + + testRestClient.postTelemetry(deviceToken, JacksonUtil.toJsonNode("{\"temperature\":25}")); + + // --- Assert propagated calculation (expression applied with new temperature argument and one relation removed) --- + await().alias("propagation expr mode evaluation after temperature update") + .atMost(TIMEOUT, TimeUnit.SECONDS) + .pollInterval(POLL_INTERVAL, TimeUnit.SECONDS) + .untilAsserted(() -> { + ArrayNode attrs1 = testRestClient.getAttributes(asset1.getId(), SERVER_SCOPE, "testResult"); + assertThat(attrs1).isNullOrEmpty(); + + ArrayNode attrs2 = testRestClient.getAttributes(asset2.getId(), SERVER_SCOPE, "testResult"); + assertThat(attrs2).isNotNull().hasSize(1); + Map m2 = intKv(attrs2); + assertThat(m2).containsEntry("testResult", 50); + }); + + testRestClient.deleteCalculatedFieldIfExists(saved.getId()); + } + + @Test + public void testPropagationCalculatedField_withoutExpression() { + // login tenant admin + testRestClient.getAndSetUserToken(tenantAdminId); + + // --- Arrange entities --- + String deviceToken = "propagationDeviceTokenB"; + Device device = testRestClient.postDevice(deviceToken, createDevice("Propagation Device Without Expression", deviceProfileId)); + Asset asset1 = testRestClient.postAsset(createAsset("Propagated Asset 3", null)); + Asset asset2 = testRestClient.postAsset(createAsset("Propagated Asset 4", null)); + + // Create relations FROM assets TO device + EntityRelation rel1 = new EntityRelation(asset1.getId(), device.getId(), EntityRelation.CONTAINS_TYPE); + EntityRelation rel2 = new EntityRelation(asset2.getId(), device.getId(), EntityRelation.CONTAINS_TYPE); + testRestClient.postEntityRelation(rel1); + testRestClient.postEntityRelation(rel2); + + // Telemetry on device + long ts = System.currentTimeMillis() - 300000L; + testRestClient.postTelemetry(deviceToken, JacksonUtil.toJsonNode(String.format("{\"ts\": %s, \"values\": {\"temperature\":12.5}}", ts))); + + // --- Build CF: PROPAGATION without expression --- + CalculatedField cf = new CalculatedField(); + cf.setEntityId(device.getId()); + cf.setType(CalculatedFieldType.PROPAGATION); + cf.setName("Propagation CF (args-only)"); + cf.setConfigurationVersion(1); + + PropagationCalculatedFieldConfiguration cfg = new PropagationCalculatedFieldConfiguration(); + cfg.setDirection(EntitySearchDirection.TO); + cfg.setRelationType(EntityRelation.CONTAINS_TYPE); + cfg.setApplyExpressionToResolvedArguments(false); // arguments-only mode + + Argument arg = new Argument(); + arg.setRefEntityKey(new ReferencedEntityKey("temperature", ArgumentType.TS_LATEST, null)); + cfg.setArguments(Map.of("t", arg)); + + Output output = new Output(); + output.setType(OutputType.TIME_SERIES); + cfg.setOutput(output); + + cf.setConfiguration(cfg); + + CalculatedField saved = testRestClient.postCalculatedField(cf); + + // --- Assert propagated calculation (arguments-only mode) --- + await().alias("propagation args-only evaluation") + .atMost(TIMEOUT, TimeUnit.SECONDS) + .pollInterval(POLL_INTERVAL, TimeUnit.SECONDS) + .untilAsserted(() -> { + JsonNode temperature1 = testRestClient.getLatestTelemetry(asset1.getId()); + assertThat(temperature1).isNotNull(); + assertThat(temperature1.get("temperature")).isNotNull(); + assertThat(temperature1.get("temperature").get(0).get("ts").asText()).isEqualTo(Long.toString(ts)); + assertThat(temperature1.get("temperature").get(0).get("value").asText()).isEqualTo("12.5"); + + JsonNode temperature2 = testRestClient.getLatestTelemetry(asset2.getId()); + assertThat(temperature2).isNotNull(); + assertThat(temperature2.get("temperature")).isNotNull(); + assertThat(temperature2.get("temperature").get(0).get("ts").asText()).isEqualTo(Long.toString(ts)); + assertThat(temperature2.get("temperature").get(0).get("value").asText()).isEqualTo("12.5"); + }); + + testRestClient.deleteEntityRelation(asset1.getId(), EntityRelation.CONTAINS_TYPE, device.getId()); + testRestClient.deleteEntityTimeseries(asset1.getId(), "temperature", true); + + // Update telemetry on device + long newTs = System.currentTimeMillis() - 300000L; + testRestClient.postTelemetry(deviceToken, JacksonUtil.toJsonNode(String.format("{\"ts\": %s, \"values\": {\"temperature\":25}}", newTs))); + + // --- Assert propagated calculation (arguments-only mode after update) --- + await().alias("propagation args-only evaluation after temperature update") + .atMost(TIMEOUT, TimeUnit.SECONDS) + .pollInterval(POLL_INTERVAL, TimeUnit.SECONDS) + .untilAsserted(() -> { + JsonNode temperature1 = testRestClient.getLatestTelemetry(asset1.getId()); + assertThat(temperature1).isNullOrEmpty(); + + JsonNode temperature2 = testRestClient.getLatestTelemetry(asset2.getId()); + assertThat(temperature2).isNotNull(); + assertThat(temperature2.get("temperature")).isNotNull(); + assertThat(temperature2.get("temperature").get(0).get("ts").asText()).isEqualTo(Long.toString(newTs)); + assertThat(temperature2.get("temperature").get(0).get("value").asInt()).isEqualTo(25); + }); + + testRestClient.deleteCalculatedFieldIfExists(saved.getId()); + } + private CalculatedField createSimpleCalculatedField() { return createSimpleCalculatedField(device.getId()); } @@ -514,4 +689,12 @@ public class CalculatedFieldTest extends AbstractContainerTest { return m; } + private static Map intKv(ArrayNode attrs) { + Map m = new HashMap<>(); + for (JsonNode n : attrs) { + m.put(n.get("key").asText(), n.get("value").asInt()); + } + return m; + } + } From 2d4b8a85f9a135088324dad654b5f8eb31685c36 Mon Sep 17 00:00:00 2001 From: dashevchenko Date: Tue, 7 Oct 2025 18:09:21 +0300 Subject: [PATCH 19/43] refactoring --- .../thingsboard/server/controller/AssetController.java | 2 +- .../server/service/device/DeviceBulkImportService.java | 2 +- .../server/service/entitiy/SimpleTbEntityService.java | 5 ----- .../service/entitiy/customer/TbCustomerService.java | 4 ++++ .../service/entitiy/device/DefaultTbDeviceService.java | 10 ++++++++++ .../server/service/entitiy/device/TbDeviceService.java | 4 ++++ .../entitiy/entityview/DefaultTbEntityViewService.java | 5 +++++ .../entitiy/entityview/TbEntityViewService.java | 2 ++ .../org/thingsboard/server/dao/asset/AssetDao.java | 1 - .../org/thingsboard/server/dao/device/DeviceDao.java | 1 - 10 files changed, 27 insertions(+), 9 deletions(-) diff --git a/application/src/main/java/org/thingsboard/server/controller/AssetController.java b/application/src/main/java/org/thingsboard/server/controller/AssetController.java index 15fcfa84ff..8de614d577 100644 --- a/application/src/main/java/org/thingsboard/server/controller/AssetController.java +++ b/application/src/main/java/org/thingsboard/server/controller/AssetController.java @@ -145,7 +145,7 @@ public class AssetController extends BaseController { "UNIQUIFY policy appends a suffix to the entity name, if a name conflict occurs.") @RequestParam(name = "policy", defaultValue = "FAIL") NameConflictPolicy policy, @Parameter(description = "Optional value of name suffix separator used by UNIQUIFY policy. By default, underscore separator is used. " + - "For example, strategy is UNIQUIFY, separator is '-'; if a name conflict occurs for customer asset 'Office A', " + + "For example, strategy is UNIQUIFY, separator is '-'; if a name conflict occurs for asset 'Office A', " + "created asset will have name like 'Office A-7fsh4f'.") @RequestParam(name = "separator", defaultValue = "_") String separator) throws Exception { asset.setTenantId(getTenantId()); diff --git a/application/src/main/java/org/thingsboard/server/service/device/DeviceBulkImportService.java b/application/src/main/java/org/thingsboard/server/service/device/DeviceBulkImportService.java index 952756210c..7bd0dc06ea 100644 --- a/application/src/main/java/org/thingsboard/server/service/device/DeviceBulkImportService.java +++ b/application/src/main/java/org/thingsboard/server/service/device/DeviceBulkImportService.java @@ -130,7 +130,7 @@ public class DeviceBulkImportService extends AbstractBulkImportService { } device.setDeviceProfileId(deviceProfile.getId()); - return tbDeviceService.saveDeviceWithCredentials(device, deviceCredentials, NameConflictStrategy.DEFAULT, user); + return tbDeviceService.saveDeviceWithCredentials(device, deviceCredentials, user); } @Override diff --git a/application/src/main/java/org/thingsboard/server/service/entitiy/SimpleTbEntityService.java b/application/src/main/java/org/thingsboard/server/service/entitiy/SimpleTbEntityService.java index 84d606251c..61d52f00bb 100644 --- a/application/src/main/java/org/thingsboard/server/service/entitiy/SimpleTbEntityService.java +++ b/application/src/main/java/org/thingsboard/server/service/entitiy/SimpleTbEntityService.java @@ -15,7 +15,6 @@ */ package org.thingsboard.server.service.entitiy; -import org.thingsboard.server.common.data.NameConflictStrategy; import org.thingsboard.server.common.data.User; import org.thingsboard.server.service.security.model.SecurityUser; @@ -27,10 +26,6 @@ public interface SimpleTbEntityService { T save(T entity, SecurityUser user) throws Exception; - default T save(T entity, NameConflictStrategy nameConflictPolicy, SecurityUser user) throws Exception { - return save(entity, null); - } - void delete(T entity, User user); } diff --git a/application/src/main/java/org/thingsboard/server/service/entitiy/customer/TbCustomerService.java b/application/src/main/java/org/thingsboard/server/service/entitiy/customer/TbCustomerService.java index f77e990620..5884ae2e48 100644 --- a/application/src/main/java/org/thingsboard/server/service/entitiy/customer/TbCustomerService.java +++ b/application/src/main/java/org/thingsboard/server/service/entitiy/customer/TbCustomerService.java @@ -16,8 +16,12 @@ package org.thingsboard.server.service.entitiy.customer; import org.thingsboard.server.common.data.Customer; +import org.thingsboard.server.common.data.NameConflictStrategy; import org.thingsboard.server.service.entitiy.SimpleTbEntityService; +import org.thingsboard.server.service.security.model.SecurityUser; public interface TbCustomerService extends SimpleTbEntityService { + Customer save(Customer customer, NameConflictStrategy nameConflictStrategy, SecurityUser user) throws Exception; + } diff --git a/application/src/main/java/org/thingsboard/server/service/entitiy/device/DefaultTbDeviceService.java b/application/src/main/java/org/thingsboard/server/service/entitiy/device/DefaultTbDeviceService.java index 85d5de5379..e982adbdf1 100644 --- a/application/src/main/java/org/thingsboard/server/service/entitiy/device/DefaultTbDeviceService.java +++ b/application/src/main/java/org/thingsboard/server/service/entitiy/device/DefaultTbDeviceService.java @@ -55,6 +55,11 @@ public class DefaultTbDeviceService extends AbstractTbEntityService implements T private final DeviceCredentialsService deviceCredentialsService; private final ClaimDevicesService claimDevicesService; + @Override + public Device save(Device device, String accessToken, User user) throws Exception { + return save(device, accessToken, NameConflictStrategy.DEFAULT, user); + } + @Override public Device save(Device device, String accessToken, NameConflictStrategy nameConflictStrategy, User user) throws Exception { ActionType actionType = device.getId() == null ? ActionType.ADDED : ActionType.UPDATED; @@ -72,6 +77,11 @@ public class DefaultTbDeviceService extends AbstractTbEntityService implements T } } + @Override + public Device saveDeviceWithCredentials(Device device, DeviceCredentials credentials, User user) throws ThingsboardException { + return saveDeviceWithCredentials(device, credentials, NameConflictStrategy.DEFAULT, user); + } + @Override public Device saveDeviceWithCredentials(Device device, DeviceCredentials credentials, NameConflictStrategy nameConflictStrategy, User user) throws ThingsboardException { ActionType actionType = device.getId() == null ? ActionType.ADDED : ActionType.UPDATED; diff --git a/application/src/main/java/org/thingsboard/server/service/entitiy/device/TbDeviceService.java b/application/src/main/java/org/thingsboard/server/service/entitiy/device/TbDeviceService.java index 26be8c5db4..e217d8de7b 100644 --- a/application/src/main/java/org/thingsboard/server/service/entitiy/device/TbDeviceService.java +++ b/application/src/main/java/org/thingsboard/server/service/entitiy/device/TbDeviceService.java @@ -32,8 +32,12 @@ import org.thingsboard.server.dao.device.claim.ReclaimResult; public interface TbDeviceService { + Device save(Device device, String accessToken, User user) throws Exception; + Device save(Device device, String accessToken, NameConflictStrategy nameConflictStrategy, User user) throws Exception; + Device saveDeviceWithCredentials(Device device, DeviceCredentials deviceCredentials, User user) throws ThingsboardException; + Device saveDeviceWithCredentials(Device device, DeviceCredentials deviceCredentials, NameConflictStrategy nameConflictStrategy, User user) throws ThingsboardException; void delete(Device device, User user); diff --git a/application/src/main/java/org/thingsboard/server/service/entitiy/entityview/DefaultTbEntityViewService.java b/application/src/main/java/org/thingsboard/server/service/entitiy/entityview/DefaultTbEntityViewService.java index 4daf54afdd..54699e0995 100644 --- a/application/src/main/java/org/thingsboard/server/service/entitiy/entityview/DefaultTbEntityViewService.java +++ b/application/src/main/java/org/thingsboard/server/service/entitiy/entityview/DefaultTbEntityViewService.java @@ -79,6 +79,11 @@ public class DefaultTbEntityViewService extends AbstractTbEntityService implemen final Map>> localCache = new ConcurrentHashMap<>(); + @Override + public EntityView save(EntityView entityView, EntityView existingEntityView, User user) throws Exception { + return save(entityView, existingEntityView, NameConflictStrategy.DEFAULT, user); + } + @Override public EntityView save(EntityView entityView, EntityView existingEntityView, NameConflictStrategy nameConflictStrategy, User user) throws Exception { ActionType actionType = entityView.getId() == null ? ActionType.ADDED : ActionType.UPDATED; diff --git a/application/src/main/java/org/thingsboard/server/service/entitiy/entityview/TbEntityViewService.java b/application/src/main/java/org/thingsboard/server/service/entitiy/entityview/TbEntityViewService.java index f34b4ed335..bb4bcf9cad 100644 --- a/application/src/main/java/org/thingsboard/server/service/entitiy/entityview/TbEntityViewService.java +++ b/application/src/main/java/org/thingsboard/server/service/entitiy/entityview/TbEntityViewService.java @@ -32,6 +32,8 @@ import java.util.List; public interface TbEntityViewService extends ComponentLifecycleListener { + EntityView save(EntityView entityView, EntityView existingEntityView, User user) throws Exception; + EntityView save(EntityView entityView, EntityView existingEntityView, NameConflictStrategy nameConflictStrategy, User user) throws Exception; void updateEntityViewAttributes(TenantId tenantId, EntityView savedEntityView, EntityView oldEntityView, User user) throws ThingsboardException; diff --git a/dao/src/main/java/org/thingsboard/server/dao/asset/AssetDao.java b/dao/src/main/java/org/thingsboard/server/dao/asset/AssetDao.java index 7735e718c8..098dc4e83d 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/asset/AssetDao.java +++ b/dao/src/main/java/org/thingsboard/server/dao/asset/AssetDao.java @@ -16,7 +16,6 @@ package org.thingsboard.server.dao.asset; import com.google.common.util.concurrent.ListenableFuture; -import org.thingsboard.server.common.data.EntityInfo; import org.thingsboard.server.common.data.EntitySubtype; import org.thingsboard.server.common.data.ProfileEntityIdInfo; import org.thingsboard.server.common.data.asset.Asset; diff --git a/dao/src/main/java/org/thingsboard/server/dao/device/DeviceDao.java b/dao/src/main/java/org/thingsboard/server/dao/device/DeviceDao.java index feeac3e336..6bc8903d20 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/device/DeviceDao.java +++ b/dao/src/main/java/org/thingsboard/server/dao/device/DeviceDao.java @@ -21,7 +21,6 @@ import org.thingsboard.server.common.data.DeviceIdInfo; import org.thingsboard.server.common.data.DeviceInfo; import org.thingsboard.server.common.data.DeviceInfoFilter; import org.thingsboard.server.common.data.DeviceTransportType; -import org.thingsboard.server.common.data.EntityInfo; import org.thingsboard.server.common.data.EntitySubtype; import org.thingsboard.server.common.data.ProfileEntityIdInfo; import org.thingsboard.server.common.data.id.DeviceId; From f7714c2f681d0fda69a6bc05481b156e18616fd7 Mon Sep 17 00:00:00 2001 From: dshvaika Date: Wed, 8 Oct 2025 11:17:52 +0300 Subject: [PATCH 20/43] Added output key support for Arguments only mode --- .../PropagationCalculatedFieldState.java | 6 ++--- .../cf/CalculatedFieldIntegrationTest.java | 26 +++++++++---------- .../PropagationCalculatedFieldStateTest.java | 2 +- .../server/msa/cf/CalculatedFieldTest.java | 22 ++++++++-------- 4 files changed, 28 insertions(+), 28 deletions(-) diff --git a/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/propagation/PropagationCalculatedFieldState.java b/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/propagation/PropagationCalculatedFieldState.java index 23c4af5bce..fed6786881 100644 --- a/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/propagation/PropagationCalculatedFieldState.java +++ b/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/propagation/PropagationCalculatedFieldState.java @@ -83,15 +83,15 @@ public class PropagationCalculatedFieldState extends ScriptCalculatedFieldState .type(output.getType()) .scope(output.getScope()); ObjectNode valuesNode = JacksonUtil.newObjectNode(); - arguments.forEach((argumentName, argumentEntry) -> { + arguments.forEach((outputKey, argumentEntry) -> { if (argumentEntry instanceof PropagationArgumentEntry) { return; } if (argumentEntry instanceof SingleValueArgumentEntry singleArgumentEntry) { - JacksonUtil.addKvEntry(valuesNode, singleArgumentEntry.getKvEntryValue()); + JacksonUtil.addKvEntry(valuesNode, singleArgumentEntry.getKvEntryValue(), outputKey); return; } - throw new IllegalArgumentException("Unsupported argument type: " + argumentEntry.getType() + " detected for argument: " + argumentName + ". " + + throw new IllegalArgumentException("Unsupported argument type: " + argumentEntry.getType() + " detected for argument: " + outputKey + ". " + "Only Latest telemetry or Attribute arguments supported for 'Arguments Only' propagation mode!"); }); ObjectNode result = toSimpleResult(output.getType() == OutputType.TIME_SERIES, valuesNode); diff --git a/application/src/test/java/org/thingsboard/server/cf/CalculatedFieldIntegrationTest.java b/application/src/test/java/org/thingsboard/server/cf/CalculatedFieldIntegrationTest.java index 92e4438ff3..86677ad66b 100644 --- a/application/src/test/java/org/thingsboard/server/cf/CalculatedFieldIntegrationTest.java +++ b/application/src/test/java/org/thingsboard/server/cf/CalculatedFieldIntegrationTest.java @@ -1058,7 +1058,7 @@ public class CalculatedFieldIntegrationTest extends CalculatedFieldControllerTes Argument arg = new Argument(); arg.setRefEntityKey(new ReferencedEntityKey("temperature", ArgumentType.TS_LATEST, null)); - cfg.setArguments(Map.of("t", arg)); + cfg.setArguments(Map.of("temperatureComputed", arg)); Output output = new Output(); output.setType(OutputType.TIME_SERIES); @@ -1073,14 +1073,14 @@ public class CalculatedFieldIntegrationTest extends CalculatedFieldControllerTes .atMost(TIMEOUT, TimeUnit.SECONDS) .pollInterval(POLL_INTERVAL, TimeUnit.SECONDS) .untilAsserted(() -> { - ObjectNode telemetry1 = getLatestTelemetry(asset1.getId(), "temperature"); - ObjectNode telemetry2 = getLatestTelemetry(asset2.getId(), "temperature"); + ObjectNode telemetry1 = getLatestTelemetry(asset1.getId(), "temperatureComputed"); + ObjectNode telemetry2 = getLatestTelemetry(asset2.getId(), "temperatureComputed"); assertThat(telemetry1).isNotNull(); assertThat(telemetry2).isNotNull(); - assertThat(telemetry1.get("temperature").get(0).get("ts").asText()).isEqualTo(Long.toString(ts)); - assertThat(telemetry1.get("temperature").get(0).get("value").asDouble()).isEqualTo(12.5); - assertThat(telemetry2.get("temperature").get(0).get("ts").asText()).isEqualTo(Long.toString(ts)); - assertThat(telemetry2.get("temperature").get(0).get("value").asDouble()).isEqualTo(12.5); + assertThat(telemetry1.get("temperatureComputed").get(0).get("ts").asText()).isEqualTo(Long.toString(ts)); + assertThat(telemetry1.get("temperatureComputed").get(0).get("value").asDouble()).isEqualTo(12.5); + assertThat(telemetry2.get("temperatureComputed").get(0).get("ts").asText()).isEqualTo(Long.toString(ts)); + assertThat(telemetry2.get("temperatureComputed").get(0).get("value").asDouble()).isEqualTo(12.5); }); String deleteUrl = String.format("/api/v2/relation?fromId=%s&fromType=%s&relationType=%s&toId=%s&toType=%s", @@ -1088,7 +1088,7 @@ public class CalculatedFieldIntegrationTest extends CalculatedFieldControllerTes EntityRelation.CONTAINS_TYPE, device.getId().getId(), EntityType.DEVICE ); doDelete(deleteUrl).andExpect(status().isOk()); - doDelete("/api/plugins/telemetry/ASSET/" + asset1.getId() + "/timeseries/delete?keys=temperature&deleteAllDataForKeys=true").andExpect(status().isOk()); + doDelete("/api/plugins/telemetry/ASSET/" + asset1.getId() + "/timeseries/delete?keys=temperatureComputed&deleteAllDataForKeys=true").andExpect(status().isOk()); // Update telemetry on device long newTs = System.currentTimeMillis() - 300000L; @@ -1099,13 +1099,13 @@ public class CalculatedFieldIntegrationTest extends CalculatedFieldControllerTes .atMost(TIMEOUT, TimeUnit.SECONDS) .pollInterval(POLL_INTERVAL, TimeUnit.SECONDS) .untilAsserted(() -> { - ObjectNode telemetry1 = getLatestTelemetry(asset1.getId(), "temperature"); - ObjectNode telemetry2 = getLatestTelemetry(asset2.getId(), "temperature"); + ObjectNode telemetry1 = getLatestTelemetry(asset1.getId(), "temperatureComputed"); + ObjectNode telemetry2 = getLatestTelemetry(asset2.getId(), "temperatureComputed"); assertThat(telemetry1).isNotNull(); assertThat(telemetry2).isNotNull(); - assertThat(telemetry1.get("temperature").get(0).get("value")).isEqualTo(NullNode.instance); - assertThat(telemetry2.get("temperature").get(0).get("ts").asText()).isEqualTo(Long.toString(newTs)); - assertThat(telemetry2.get("temperature").get(0).get("value").asDouble()).isEqualTo(25); + assertThat(telemetry1.get("temperatureComputed").get(0).get("value")).isEqualTo(NullNode.instance); + assertThat(telemetry2.get("temperatureComputed").get(0).get("ts").asText()).isEqualTo(Long.toString(newTs)); + assertThat(telemetry2.get("temperatureComputed").get(0).get("value").asDouble()).isEqualTo(25); }); } diff --git a/application/src/test/java/org/thingsboard/server/service/cf/ctx/state/PropagationCalculatedFieldStateTest.java b/application/src/test/java/org/thingsboard/server/service/cf/ctx/state/PropagationCalculatedFieldStateTest.java index 2e9b3d46c9..04a7ab5203 100644 --- a/application/src/test/java/org/thingsboard/server/service/cf/ctx/state/PropagationCalculatedFieldStateTest.java +++ b/application/src/test/java/org/thingsboard/server/service/cf/ctx/state/PropagationCalculatedFieldStateTest.java @@ -179,7 +179,7 @@ public class PropagationCalculatedFieldStateTest { assertThat(result.getScope()).isEqualTo(AttributeScope.SERVER_SCOPE); ObjectNode expectedNode = JacksonUtil.newObjectNode(); - JacksonUtil.addKvEntry(expectedNode, singleValueArgEntry.getKvEntryValue()); + JacksonUtil.addKvEntry(expectedNode, singleValueArgEntry.getKvEntryValue(), TEMPERATURE_ARGUMENT_NAME); assertThat(result.getResult()).isEqualTo(expectedNode); } diff --git a/msa/black-box-tests/src/test/java/org/thingsboard/server/msa/cf/CalculatedFieldTest.java b/msa/black-box-tests/src/test/java/org/thingsboard/server/msa/cf/CalculatedFieldTest.java index 4993f1fb8b..8046e0b1a6 100644 --- a/msa/black-box-tests/src/test/java/org/thingsboard/server/msa/cf/CalculatedFieldTest.java +++ b/msa/black-box-tests/src/test/java/org/thingsboard/server/msa/cf/CalculatedFieldTest.java @@ -541,7 +541,7 @@ public class CalculatedFieldTest extends AbstractContainerTest { Argument arg = new Argument(); arg.setRefEntityKey(new ReferencedEntityKey("temperature", ArgumentType.TS_LATEST, null)); - cfg.setArguments(Map.of("t", arg)); + cfg.setArguments(Map.of("temperatureComputed", arg)); Output output = new Output(); output.setType(OutputType.TIME_SERIES); @@ -558,19 +558,19 @@ public class CalculatedFieldTest extends AbstractContainerTest { .untilAsserted(() -> { JsonNode temperature1 = testRestClient.getLatestTelemetry(asset1.getId()); assertThat(temperature1).isNotNull(); - assertThat(temperature1.get("temperature")).isNotNull(); - assertThat(temperature1.get("temperature").get(0).get("ts").asText()).isEqualTo(Long.toString(ts)); - assertThat(temperature1.get("temperature").get(0).get("value").asText()).isEqualTo("12.5"); + assertThat(temperature1.get("temperatureComputed")).isNotNull(); + assertThat(temperature1.get("temperatureComputed").get(0).get("ts").asText()).isEqualTo(Long.toString(ts)); + assertThat(temperature1.get("temperatureComputed").get(0).get("value").asText()).isEqualTo("12.5"); JsonNode temperature2 = testRestClient.getLatestTelemetry(asset2.getId()); assertThat(temperature2).isNotNull(); - assertThat(temperature2.get("temperature")).isNotNull(); - assertThat(temperature2.get("temperature").get(0).get("ts").asText()).isEqualTo(Long.toString(ts)); - assertThat(temperature2.get("temperature").get(0).get("value").asText()).isEqualTo("12.5"); + assertThat(temperature2.get("temperatureComputed")).isNotNull(); + assertThat(temperature2.get("temperatureComputed").get(0).get("ts").asText()).isEqualTo(Long.toString(ts)); + assertThat(temperature2.get("temperatureComputed").get(0).get("value").asText()).isEqualTo("12.5"); }); testRestClient.deleteEntityRelation(asset1.getId(), EntityRelation.CONTAINS_TYPE, device.getId()); - testRestClient.deleteEntityTimeseries(asset1.getId(), "temperature", true); + testRestClient.deleteEntityTimeseries(asset1.getId(), "temperatureComputed", true); // Update telemetry on device long newTs = System.currentTimeMillis() - 300000L; @@ -586,9 +586,9 @@ public class CalculatedFieldTest extends AbstractContainerTest { JsonNode temperature2 = testRestClient.getLatestTelemetry(asset2.getId()); assertThat(temperature2).isNotNull(); - assertThat(temperature2.get("temperature")).isNotNull(); - assertThat(temperature2.get("temperature").get(0).get("ts").asText()).isEqualTo(Long.toString(newTs)); - assertThat(temperature2.get("temperature").get(0).get("value").asInt()).isEqualTo(25); + assertThat(temperature2.get("temperatureComputed")).isNotNull(); + assertThat(temperature2.get("temperatureComputed").get(0).get("ts").asText()).isEqualTo(Long.toString(newTs)); + assertThat(temperature2.get("temperatureComputed").get(0).get("value").asInt()).isEqualTo(25); }); testRestClient.deleteCalculatedFieldIfExists(saved.getId()); From cda9cc47827522e0c04afcd7a89cb07331eb6d79 Mon Sep 17 00:00:00 2001 From: dashevchenko Date: Wed, 8 Oct 2025 11:20:42 +0300 Subject: [PATCH 21/43] refactoring --- .../thingsboard/server/controller/AssetController.java | 10 ++++------ .../server/controller/ControllerConstants.java | 8 ++++++++ .../server/controller/CustomerController.java | 10 ++++------ .../server/controller/DeviceController.java | 10 ++++------ .../server/controller/EntityViewController.java | 10 ++++------ .../thingsboard/server/dao/asset/BaseAssetService.java | 6 +++--- .../server/dao/customer/CustomerServiceImpl.java | 6 +++--- .../server/dao/device/DeviceServiceImpl.java | 6 +++--- .../server/dao/entityview/EntityViewServiceImpl.java | 6 +++--- 9 files changed, 36 insertions(+), 36 deletions(-) diff --git a/application/src/main/java/org/thingsboard/server/controller/AssetController.java b/application/src/main/java/org/thingsboard/server/controller/AssetController.java index 8de614d577..d00725f1c1 100644 --- a/application/src/main/java/org/thingsboard/server/controller/AssetController.java +++ b/application/src/main/java/org/thingsboard/server/controller/AssetController.java @@ -78,6 +78,8 @@ import static org.thingsboard.server.controller.ControllerConstants.EDGE_ASSIGN_ import static org.thingsboard.server.controller.ControllerConstants.EDGE_ID_PARAM_DESCRIPTION; import static org.thingsboard.server.controller.ControllerConstants.EDGE_UNASSIGN_ASYNC_FIRST_STEP_DESCRIPTION; import static org.thingsboard.server.controller.ControllerConstants.EDGE_UNASSIGN_RECEIVE_STEP_DESCRIPTION; +import static org.thingsboard.server.controller.ControllerConstants.NAME_CONFLICT_POLICY_DESC; +import static org.thingsboard.server.controller.ControllerConstants.NAME_CONFLICT_SEPARATOR_DESC; import static org.thingsboard.server.controller.ControllerConstants.PAGE_DATA_PARAMETERS; import static org.thingsboard.server.controller.ControllerConstants.PAGE_NUMBER_DESCRIPTION; import static org.thingsboard.server.controller.ControllerConstants.PAGE_SIZE_DESCRIPTION; @@ -140,13 +142,9 @@ public class AssetController extends BaseController { @RequestMapping(value = "/asset", method = RequestMethod.POST) @ResponseBody public Asset saveAsset(@io.swagger.v3.oas.annotations.parameters.RequestBody(description = "A JSON value representing the asset.") @RequestBody Asset asset, - @Parameter(description = "Optional value of name conflict policy. Possible values: FAIL or UNIQUIFY. " + - "If omitted, FAIL policy is applied. FAIL policy implies exception will be thrown if an entity with the same name already exists. " + - "UNIQUIFY policy appends a suffix to the entity name, if a name conflict occurs.") + @Parameter(description = NAME_CONFLICT_POLICY_DESC) @RequestParam(name = "policy", defaultValue = "FAIL") NameConflictPolicy policy, - @Parameter(description = "Optional value of name suffix separator used by UNIQUIFY policy. By default, underscore separator is used. " + - "For example, strategy is UNIQUIFY, separator is '-'; if a name conflict occurs for asset 'Office A', " + - "created asset will have name like 'Office A-7fsh4f'.") + @Parameter(description = NAME_CONFLICT_SEPARATOR_DESC) @RequestParam(name = "separator", defaultValue = "_") String separator) throws Exception { asset.setTenantId(getTenantId()); checkEntity(asset.getId(), asset, Resource.ASSET); 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 a87864726b..7b5bf8b165 100644 --- a/application/src/main/java/org/thingsboard/server/controller/ControllerConstants.java +++ b/application/src/main/java/org/thingsboard/server/controller/ControllerConstants.java @@ -1744,4 +1744,12 @@ public class ControllerConstants { MARKDOWN_CODE_BLOCK_END ; protected static final String SECURITY_WRITE_CHECK = " Security check is performed to verify that the user has 'WRITE' permission for the entity (entities)."; + + public static final String NAME_CONFLICT_POLICY_DESC = "Optional value of name conflict policy. Possible values: FAIL or UNIQUIFY. " + + " If omitted, FAIL policy is applied. FAIL policy implies exception will be thrown if an entity with the same name already exists. " + + " UNIQUIFY policy appends a suffix to the entity name, if a name conflict occurs."; + + public static final String NAME_CONFLICT_SEPARATOR_DESC = "Optional value of name suffix separator used by UNIQUIFY policy. By default, underscore separator is used. " + + "For example, strategy is UNIQUIFY, separator is '-'; if a name conflict occurs for entity name 'test-name', " + + "created entity will have name like 'test-name-7fsh4f'."; } diff --git a/application/src/main/java/org/thingsboard/server/controller/CustomerController.java b/application/src/main/java/org/thingsboard/server/controller/CustomerController.java index 8f37a55e60..839bcc984f 100644 --- a/application/src/main/java/org/thingsboard/server/controller/CustomerController.java +++ b/application/src/main/java/org/thingsboard/server/controller/CustomerController.java @@ -49,6 +49,8 @@ import static org.thingsboard.server.controller.ControllerConstants.CUSTOMER_ID; import static org.thingsboard.server.controller.ControllerConstants.CUSTOMER_ID_PARAM_DESCRIPTION; import static org.thingsboard.server.controller.ControllerConstants.CUSTOMER_TEXT_SEARCH_DESCRIPTION; import static org.thingsboard.server.controller.ControllerConstants.HOME_DASHBOARD; +import static org.thingsboard.server.controller.ControllerConstants.NAME_CONFLICT_POLICY_DESC; +import static org.thingsboard.server.controller.ControllerConstants.NAME_CONFLICT_SEPARATOR_DESC; import static org.thingsboard.server.controller.ControllerConstants.PAGE_DATA_PARAMETERS; import static org.thingsboard.server.controller.ControllerConstants.PAGE_NUMBER_DESCRIPTION; import static org.thingsboard.server.controller.ControllerConstants.PAGE_SIZE_DESCRIPTION; @@ -131,13 +133,9 @@ public class CustomerController extends BaseController { @RequestMapping(value = "/customer", method = RequestMethod.POST) @ResponseBody public Customer saveCustomer(@io.swagger.v3.oas.annotations.parameters.RequestBody(description = "A JSON value representing the customer.") @RequestBody Customer customer, - @Parameter(description = "Optional value of name conflict policy. Possible values: FAIL or UNIQUIFY. " + - "If omitted, FAIL policy is applied. FAIL policy implies exception will be thrown if an entity with the same name already exists. " + - "UNIQUIFY policy appends a suffix to the entity name, if a name conflict occurs.") + @Parameter(description = NAME_CONFLICT_POLICY_DESC) @RequestParam(name = "policy", defaultValue = "FAIL") NameConflictPolicy policy, - @Parameter(description = "Optional value of name suffix separator used by UNIQUIFY policy. By default, underscore separator is used. " + - "For example, strategy is UNIQUIFY, separator is '-'; if a name conflict occurs for customer name 'Customer A', " + - "created customer will have name like 'Customer A-7fsh4f'.") + @Parameter(description = NAME_CONFLICT_SEPARATOR_DESC) @RequestParam(name = "separator", defaultValue = "_") String separator) throws Exception { customer.setTenantId(getTenantId()); checkEntity(customer.getId(), customer, Resource.CUSTOMER); diff --git a/application/src/main/java/org/thingsboard/server/controller/DeviceController.java b/application/src/main/java/org/thingsboard/server/controller/DeviceController.java index 73d615ffd3..4941c0383f 100644 --- a/application/src/main/java/org/thingsboard/server/controller/DeviceController.java +++ b/application/src/main/java/org/thingsboard/server/controller/DeviceController.java @@ -110,6 +110,8 @@ import static org.thingsboard.server.controller.ControllerConstants.EDGE_ASSIGN_ import static org.thingsboard.server.controller.ControllerConstants.EDGE_ID_PARAM_DESCRIPTION; import static org.thingsboard.server.controller.ControllerConstants.EDGE_UNASSIGN_ASYNC_FIRST_STEP_DESCRIPTION; import static org.thingsboard.server.controller.ControllerConstants.EDGE_UNASSIGN_RECEIVE_STEP_DESCRIPTION; +import static org.thingsboard.server.controller.ControllerConstants.NAME_CONFLICT_POLICY_DESC; +import static org.thingsboard.server.controller.ControllerConstants.NAME_CONFLICT_SEPARATOR_DESC; import static org.thingsboard.server.controller.ControllerConstants.PAGE_DATA_PARAMETERS; import static org.thingsboard.server.controller.ControllerConstants.PAGE_NUMBER_DESCRIPTION; import static org.thingsboard.server.controller.ControllerConstants.PAGE_SIZE_DESCRIPTION; @@ -181,13 +183,9 @@ public class DeviceController extends BaseController { @Parameter(description = "Optional value of the device credentials to be used during device creation. " + "If omitted, access token will be auto-generated.") @RequestParam(name = "accessToken", required = false) String accessToken, - @Parameter(description = "Optional value of name conflict policy. Possible values: FAIL or UNIQUIFY. " + - "If omitted, FAIL policy is applied. FAIL policy implies exception will be thrown if an entity with the same name already exists. " + - "UNIQUIFY policy appends a suffix to the entity name, if a name conflict occurs.") + @Parameter(description = NAME_CONFLICT_POLICY_DESC) @RequestParam(name = "policy", defaultValue = "FAIL") NameConflictPolicy policy, - @Parameter(description = "Optional value of name suffix separator used by UNIQUIFY policy. By default, underscore separator is used. " + - "For example, strategy is UNIQUIFY, separator is '-'; if a name conflict occurs for device name 'thermostat', " + - "created device will have name like 'thermostat-7fsh4f'.") + @Parameter(description = NAME_CONFLICT_SEPARATOR_DESC) @RequestParam(name = "separator", defaultValue = "_") String separator) throws Exception { device.setTenantId(getCurrentUser().getTenantId()); if (device.getId() != null) { diff --git a/application/src/main/java/org/thingsboard/server/controller/EntityViewController.java b/application/src/main/java/org/thingsboard/server/controller/EntityViewController.java index 37cc7c7797..4cf9a51227 100644 --- a/application/src/main/java/org/thingsboard/server/controller/EntityViewController.java +++ b/application/src/main/java/org/thingsboard/server/controller/EntityViewController.java @@ -71,6 +71,8 @@ import static org.thingsboard.server.controller.ControllerConstants.ENTITY_VIEW_ import static org.thingsboard.server.controller.ControllerConstants.ENTITY_VIEW_TEXT_SEARCH_DESCRIPTION; import static org.thingsboard.server.controller.ControllerConstants.ENTITY_VIEW_TYPE; import static org.thingsboard.server.controller.ControllerConstants.MODEL_DESCRIPTION; +import static org.thingsboard.server.controller.ControllerConstants.NAME_CONFLICT_POLICY_DESC; +import static org.thingsboard.server.controller.ControllerConstants.NAME_CONFLICT_SEPARATOR_DESC; import static org.thingsboard.server.controller.ControllerConstants.PAGE_DATA_PARAMETERS; import static org.thingsboard.server.controller.ControllerConstants.PAGE_NUMBER_DESCRIPTION; import static org.thingsboard.server.controller.ControllerConstants.PAGE_SIZE_DESCRIPTION; @@ -131,13 +133,9 @@ public class EntityViewController extends BaseController { public EntityView saveEntityView( @Parameter(description = "A JSON object representing the entity view.") @RequestBody EntityView entityView, - @Parameter(description = "Optional value of name conflict policy. Possible values: FAIL or UNIQUIFY. " + - "If omitted, FAIL policy is applied. FAIL policy implies exception will be thrown if an entity with the same name already exists. " + - "UNIQUIFY policy appends a suffix to the entity name, if a name conflict occurs.") + @Parameter(description = NAME_CONFLICT_POLICY_DESC) @RequestParam(name = "policy", defaultValue = "FAIL") NameConflictPolicy policy, - @Parameter(description = "Optional value of name suffix separator used by UNIQUIFY policy. By default, underscore separator is used. " + - "For example, strategy is UNIQUIFY, separator is '-'; if a name conflict occurs for entity view name 'Device A', " + - "created customer will have name like 'Device A-7fsh4f'.") + @Parameter(description = NAME_CONFLICT_SEPARATOR_DESC) @RequestParam(name = "separator", defaultValue = "_") String separator) throws Exception { entityView.setTenantId(getCurrentUser().getTenantId()); EntityView existingEntityView = null; diff --git a/dao/src/main/java/org/thingsboard/server/dao/asset/BaseAssetService.java b/dao/src/main/java/org/thingsboard/server/dao/asset/BaseAssetService.java index 7df76d9153..ad6adee1ee 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/asset/BaseAssetService.java +++ b/dao/src/main/java/org/thingsboard/server/dao/asset/BaseAssetService.java @@ -166,9 +166,6 @@ public class BaseAssetService extends AbstractCachedEntityService Date: Wed, 8 Oct 2025 11:30:40 +0300 Subject: [PATCH 22/43] minor refactoring --- .../thingsboard/server/controller/DeviceController.java | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/application/src/main/java/org/thingsboard/server/controller/DeviceController.java b/application/src/main/java/org/thingsboard/server/controller/DeviceController.java index 4941c0383f..a5efdc1ca4 100644 --- a/application/src/main/java/org/thingsboard/server/controller/DeviceController.java +++ b/application/src/main/java/org/thingsboard/server/controller/DeviceController.java @@ -219,13 +219,9 @@ public class DeviceController extends BaseController { @ResponseBody public Device saveDeviceWithCredentials(@Parameter(description = "The JSON object with device and credentials. See method description above for example.") @Valid @RequestBody SaveDeviceWithCredentialsRequest deviceAndCredentials, - @Parameter(description = "Optional value of name conflict policy. Possible values: FAIL or UNIQUIFY. " + - "If omitted, FAIL policy is applied. FAIL policy implies exception will be thrown if an entity with the same name already exists. " + - "UNIQUIFY policy appends a suffix to the entity name, if a name conflict occurs.") + @Parameter(description = NAME_CONFLICT_POLICY_DESC) @RequestParam(name = "policy", defaultValue = "FAIL") NameConflictPolicy policy, - @Parameter(description = "Optional value of name suffix separator used by UNIQUIFY policy. By default, underscore separator is used. " + - "For example, strategy is UNIQUIFY, separator is '-'; if a name conflict occurs for device name 'thermostat', " + - "created device will have name like 'thermostat-7fsh4f'.") + @Parameter(description = NAME_CONFLICT_SEPARATOR_DESC) @RequestParam(name = "separator", defaultValue = "_") String separator) throws ThingsboardException { Device device = deviceAndCredentials.getDevice(); DeviceCredentials credentials = deviceAndCredentials.getCredentials(); From 9f208b4abd7628de30f633493bece143fb6b7684 Mon Sep 17 00:00:00 2001 From: dshvaika Date: Thu, 9 Oct 2025 13:02:02 +0300 Subject: [PATCH 23/43] Added related entities limit + additional validation for CF configuration --- .../main/data/upgrade/basic/schema_update.sql | 10 +- .../cf/ctx/state/CalculatedFieldCtx.java | 8 +- ...opagationCalculatedFieldConfiguration.java | 3 + .../DefaultTenantProfileConfiguration.java | 4 +- ...ationCalculatedFieldConfigurationTest.java | 24 ++++ .../dao/relation/BaseRelationService.java | 15 ++- .../server/dao/relation/RelationDao.java | 2 +- .../dao/sql/relation/JpaRelationDao.java | 20 ++- .../dao/service/RelationServiceTest.java | 117 ++++++++++++++---- 9 files changed, 160 insertions(+), 43 deletions(-) diff --git a/application/src/main/data/upgrade/basic/schema_update.sql b/application/src/main/data/upgrade/basic/schema_update.sql index 0add4c0545..50c44c0e2d 100644 --- a/application/src/main/data/upgrade/basic/schema_update.sql +++ b/application/src/main/data/upgrade/basic/schema_update.sql @@ -34,7 +34,13 @@ SET profile_data = jsonb_set( WHEN (profile_data -> 'configuration') ? 'maxRelationLevelPerCfArgument' THEN NULL ELSE to_jsonb(10) - END + END, + 'maxRelatedEntitiesToReturnPerCfArgument', + CASE + WHEN (profile_data -> 'configuration') ? 'maxRelatedEntitiesToReturnPerCfArgument' + THEN NULL + ELSE to_jsonb(100) + END, ) ), false @@ -43,6 +49,8 @@ WHERE NOT ( (profile_data -> 'configuration') ? 'minAllowedScheduledUpdateIntervalInSecForCF' AND (profile_data -> 'configuration') ? 'maxRelationLevelPerCfArgument' + AND + (profile_data -> 'configuration') ? 'maxRelatedEntitiesToReturnPerCfArgument' ); -- UPDATE TENANT PROFILE CONFIGURATION END 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 bfb6483d18..5b6b708d26 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 @@ -498,12 +498,8 @@ public class CalculatedFieldCtx { } public boolean hasContextOnlyChanges(CalculatedFieldCtx other) { // has changes that do not require state reinit and will be picked up by the state on the fly - if (calculatedField.getConfiguration() instanceof ExpressionBasedCalculatedFieldConfiguration expressionConfig) { - boolean shouldCompareExpression = !(expressionConfig instanceof PropagationCalculatedFieldConfiguration propagationConfig) - || propagationConfig.isApplyExpressionToResolvedArguments(); - if (shouldCompareExpression && !expression.equals(other.expression)) { - return true; - } + if (calculatedField.getConfiguration() instanceof ExpressionBasedCalculatedFieldConfiguration && !Objects.equals(expression, other.expression)) { + return true; } if (!output.equals(other.output)) { return true; diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/PropagationCalculatedFieldConfiguration.java b/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/PropagationCalculatedFieldConfiguration.java index 3394813d3f..70ffd952cf 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/PropagationCalculatedFieldConfiguration.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/PropagationCalculatedFieldConfiguration.java @@ -50,6 +50,9 @@ public class PropagationCalculatedFieldConfiguration extends BaseCalculatedField propagationRestriction(); if (!applyExpressionToResolvedArguments) { arguments.forEach((name, argument) -> { + if (argument.getRefEntityId() != null || argument.getRefDynamicSourceConfiguration() != null) { + throw new IllegalArgumentException("Arguments in 'Arguments only' propagation mode support only the 'Current entity' source entity type!"); + } if (argument.getRefEntityKey() == null) { throw new IllegalArgumentException("Argument: '" + name + "' doesn't have reference entity key configured!"); } diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/tenant/profile/DefaultTenantProfileConfiguration.java b/common/data/src/main/java/org/thingsboard/server/common/data/tenant/profile/DefaultTenantProfileConfiguration.java index c6bd9a7f38..5d1c4f7018 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/tenant/profile/DefaultTenantProfileConfiguration.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/tenant/profile/DefaultTenantProfileConfiguration.java @@ -172,10 +172,12 @@ public class DefaultTenantProfileConfiguration implements TenantProfileConfigura private long maxCalculatedFieldsPerEntity = 5; @Schema(example = "10") private long maxArgumentsPerCF = 10; - @Schema(example = "3600") + @Schema(example = "60") private int minAllowedScheduledUpdateIntervalInSecForCF = 60; @Schema(example = "10") private int maxRelationLevelPerCfArgument = 10; + @Schema(example = "100") + private int maxRelatedEntitiesToReturnPerCfArgument = 100; @Builder.Default @Min(value = 1, message = "must be at least 1") @Schema(example = "1000") diff --git a/common/data/src/test/java/org/thingsboard/server/common/data/cf/configuration/PropagationCalculatedFieldConfigurationTest.java b/common/data/src/test/java/org/thingsboard/server/common/data/cf/configuration/PropagationCalculatedFieldConfigurationTest.java index eb0591e32b..64f7b0efd0 100644 --- a/common/data/src/test/java/org/thingsboard/server/common/data/cf/configuration/PropagationCalculatedFieldConfigurationTest.java +++ b/common/data/src/test/java/org/thingsboard/server/common/data/cf/configuration/PropagationCalculatedFieldConfigurationTest.java @@ -19,10 +19,12 @@ import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.junit.jupiter.MockitoExtension; import org.thingsboard.server.common.data.cf.CalculatedFieldType; +import org.thingsboard.server.common.data.id.DeviceId; import org.thingsboard.server.common.data.relation.EntityRelation; import org.thingsboard.server.common.data.relation.EntitySearchDirection; import java.util.Map; +import java.util.UUID; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; @@ -37,6 +39,28 @@ public class PropagationCalculatedFieldConfigurationTest { assertThat(cfg.getType()).isEqualTo(CalculatedFieldType.PROPAGATION); } + @Test + void validateShouldThrowWhenConfigurationDisallowArgumentsWithReferencedEntity() { + var cfg = new PropagationCalculatedFieldConfiguration(); + Argument argumentWithRefEntityIdSet = new Argument(); + argumentWithRefEntityIdSet.setRefEntityId(new DeviceId(UUID.fromString("bda14084-f40e-4acc-9b85-9d1dd209bb64"))); + cfg.setArguments(Map.of("argumentWithRefEntityIdSet", argumentWithRefEntityIdSet)); + assertThatThrownBy(cfg::validate) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("Arguments in 'Arguments only' propagation mode support only the 'Current entity' source entity type!"); + } + + @Test + void validateShouldThrowWhenConfigurationDisallowArgumentsWithDynamicReferenceConfiguration() { + var cfg = new PropagationCalculatedFieldConfiguration(); + Argument argumentWithRefEntityIdSet = new Argument(); + argumentWithRefEntityIdSet.setRefDynamicSourceConfiguration(new CurrentOwnerDynamicSourceConfiguration()); + cfg.setArguments(Map.of("argumentWithRefEntityIdSet", argumentWithRefEntityIdSet)); + assertThatThrownBy(cfg::validate) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("Arguments in 'Arguments only' propagation mode support only the 'Current entity' source entity type!"); + } + @Test void validateShouldThrowWhenUsedReservedPropagationArgumentName() { var cfg = new PropagationCalculatedFieldConfiguration(); diff --git a/dao/src/main/java/org/thingsboard/server/dao/relation/BaseRelationService.java b/dao/src/main/java/org/thingsboard/server/dao/relation/BaseRelationService.java index 18d1806fe4..34928c7e04 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/relation/BaseRelationService.java +++ b/dao/src/main/java/org/thingsboard/server/dao/relation/BaseRelationService.java @@ -50,12 +50,14 @@ import org.thingsboard.server.common.data.relation.RelationPathLevel; import org.thingsboard.server.common.data.relation.RelationTypeGroup; import org.thingsboard.server.common.data.relation.RelationsSearchParameters; import org.thingsboard.server.common.data.rule.RuleChainType; +import org.thingsboard.server.common.data.tenant.profile.DefaultTenantProfileConfiguration; import org.thingsboard.server.dao.entity.EntityService; import org.thingsboard.server.dao.eventsourcing.RelationActionEvent; import org.thingsboard.server.dao.exception.DataValidationException; import org.thingsboard.server.dao.service.ConstraintValidator; import org.thingsboard.server.dao.sql.JpaExecutorService; import org.thingsboard.server.dao.sql.relation.JpaRelationQueryExecutorService; +import org.thingsboard.server.dao.usagerecord.ApiLimitService; import java.util.ArrayList; import java.util.Collections; @@ -71,6 +73,7 @@ import java.util.concurrent.TimeUnit; import java.util.function.BiConsumer; import static org.thingsboard.server.dao.service.Validator.validateId; +import static org.thingsboard.server.dao.service.Validator.validatePositiveNumber; /** * Created by ashvayka on 28.04.17. @@ -85,6 +88,8 @@ public class BaseRelationService implements RelationService { private final ApplicationEventPublisher eventPublisher; private final JpaExecutorService executor; private final JpaRelationQueryExecutorService relationsExecutor; + private final ApiLimitService apiLimitService; + protected ScheduledExecutorService timeoutExecutorService; @Value("${sql.relations.query_timeout:20}") @@ -93,13 +98,14 @@ public class BaseRelationService implements RelationService { public BaseRelationService(RelationDao relationDao, @Lazy EntityService entityService, TbTransactionalCache cache, ApplicationEventPublisher eventPublisher, JpaExecutorService executor, - JpaRelationQueryExecutorService relationsExecutor) { + JpaRelationQueryExecutorService relationsExecutor, ApiLimitService apiLimitService) { this.relationDao = relationDao; this.entityService = entityService; this.cache = cache; this.eventPublisher = eventPublisher; this.executor = executor; this.relationsExecutor = relationsExecutor; + this.apiLimitService = apiLimitService; } @PostConstruct @@ -504,14 +510,17 @@ public class BaseRelationService implements RelationService { log.trace("Executing findByRelationPathQuery, tenantId [{}], relationPathQuery {}", tenantId, relationPathQuery); validateId(tenantId, id -> "Invalid tenant id: " + id); validate(relationPathQuery); + int limit = (int) apiLimitService.getLimit(tenantId, DefaultTenantProfileConfiguration::getMaxRelatedEntitiesToReturnPerCfArgument); + validatePositiveNumber(limit, "Invalid entities limit: " + limit); if (relationPathQuery.levels().size() == 1) { RelationPathLevel relationPathLevel = relationPathQuery.levels().get(0); - return switch (relationPathLevel.direction()) { + var relationsFuture = switch (relationPathLevel.direction()) { case FROM -> findByFromAndTypeAsync(tenantId, relationPathQuery.rootEntityId(), relationPathLevel.relationType(), RelationTypeGroup.COMMON); case TO -> findByToAndTypeAsync(tenantId, relationPathQuery.rootEntityId(), relationPathLevel.relationType(), RelationTypeGroup.COMMON); }; + return Futures.transform(relationsFuture, entityRelations -> entityRelations.subList(0, limit), MoreExecutors.directExecutor()); } - return executor.submit(() -> relationDao.findByRelationPathQuery(tenantId, relationPathQuery)); + return executor.submit(() -> relationDao.findByRelationPathQuery(tenantId, relationPathQuery, limit)); } private void validate(EntityRelationPathQuery relationPathQuery) { diff --git a/dao/src/main/java/org/thingsboard/server/dao/relation/RelationDao.java b/dao/src/main/java/org/thingsboard/server/dao/relation/RelationDao.java index ad53164ad7..2ec23a0d74 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/relation/RelationDao.java +++ b/dao/src/main/java/org/thingsboard/server/dao/relation/RelationDao.java @@ -72,6 +72,6 @@ public interface RelationDao { List findRuleNodeToRuleChainRelations(RuleChainType ruleChainType, int limit); - List findByRelationPathQuery(TenantId tenantId, EntityRelationPathQuery relationPathQuery); + List findByRelationPathQuery(TenantId tenantId, EntityRelationPathQuery relationPathQuery, int limit); } diff --git a/dao/src/main/java/org/thingsboard/server/dao/sql/relation/JpaRelationDao.java b/dao/src/main/java/org/thingsboard/server/dao/sql/relation/JpaRelationDao.java index b2871313ed..4c1b8f299b 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/sql/relation/JpaRelationDao.java +++ b/dao/src/main/java/org/thingsboard/server/dao/sql/relation/JpaRelationDao.java @@ -299,15 +299,18 @@ public class JpaRelationDao extends JpaAbstractDaoListeningExecutorService imple } @Override - public List findByRelationPathQuery(TenantId tenantId, EntityRelationPathQuery query) { + public List findByRelationPathQuery(TenantId tenantId, EntityRelationPathQuery query, int limit) { List levels = query.levels(); if (levels == null || levels.isEmpty()) { - return Collections.emptyList(); + return List.of(); + } + if (limit <= 0) { + return List.of(); } String sql = buildRelationPathSql(query); - Object[] params = buildRelationPathParams(query); + Object[] params = buildRelationPathParams(query, limit); - log.trace("[{}] relation path query: {}", tenantId, sql); + log.info("[{}] relation path query: {}", tenantId, sql); return jdbcTemplate.queryForList(sql, params).stream() .map(row -> { @@ -330,7 +333,7 @@ public class JpaRelationDao extends JpaAbstractDaoListeningExecutorService imple .collect(Collectors.toList()); } - private Object[] buildRelationPathParams(EntityRelationPathQuery query) { + private Object[] buildRelationPathParams(EntityRelationPathQuery query, int limit) { final List params = new ArrayList<>(); // seed params.add(query.rootEntityId().getId()); @@ -340,6 +343,10 @@ public class JpaRelationDao extends JpaAbstractDaoListeningExecutorService imple for (var lvl : query.levels()) { params.add(lvl.relationType()); } + + // limit + params.add(limit); + return params.toArray(); } @@ -387,7 +394,8 @@ public class JpaRelationDao extends JpaAbstractDaoListeningExecutorService imple .append("FROM ").append(RELATION_TABLE_NAME).append(" r\n") .append("JOIN ").append(prevForLast).append(" p ON ").append(lastJoin).append("\n") .append("WHERE r.relation_type_group = '").append(RelationTypeGroup.COMMON).append("'\n") - .append(" AND r.relation_type = ?"); + .append(" AND r.relation_type = ?\n") + .append("LIMIT ?"); return sb.toString(); } diff --git a/dao/src/test/java/org/thingsboard/server/dao/service/RelationServiceTest.java b/dao/src/test/java/org/thingsboard/server/dao/service/RelationServiceTest.java index bb7bfad677..e3827d6a6e 100644 --- a/dao/src/test/java/org/thingsboard/server/dao/service/RelationServiceTest.java +++ b/dao/src/test/java/org/thingsboard/server/dao/service/RelationServiceTest.java @@ -37,6 +37,7 @@ import org.thingsboard.server.common.data.relation.RelationTypeGroup; import org.thingsboard.server.common.data.relation.RelationsSearchParameters; import org.thingsboard.server.dao.exception.DataValidationException; import org.thingsboard.server.dao.relation.RelationService; +import org.thingsboard.server.dao.tenant.TbTenantProfileCache; import java.util.ArrayList; import java.util.Collections; @@ -52,6 +53,9 @@ public class RelationServiceTest extends AbstractServiceTest { @Autowired RelationService relationService; + @Autowired + private TbTenantProfileCache tbTenantProfileCache; + @Before public void before() { } @@ -628,48 +632,111 @@ public class RelationServiceTest extends AbstractServiceTest { } @Test - public void testFindByPathQuery() throws Exception { + public void testFindByPathQueryWithoutExceedingLimit() throws Exception { /* A └──[firstLevel, TO]→ B └──[secondLevel, TO]→ C - ├──[thirdLevel, FROM]→ D - ├──[thirdLevel, FROM]→ E - └──[thirdLevel, FROM]→ F + ├──[thirdLevel, FROM]→ D1 + ├──[thirdLevel, FROM]→ D2 + ├──[thirdLevel, FROM]→ ... + └──[thirdLevel, FROM]→ D{N - 1}, where N is the limit */ - // rootEntity AssetId assetA = new AssetId(Uuids.timeBased()); - // firstLevelEntity AssetId assetB = new AssetId(Uuids.timeBased()); - // secondLevelEntity AssetId assetC = new AssetId(Uuids.timeBased()); - // thirdLevelEntities - AssetId assetD = new AssetId(Uuids.timeBased()); - AssetId assetE = new AssetId(Uuids.timeBased()); - AssetId assetF = new AssetId(Uuids.timeBased()); - EntityRelation firstLevelRelation = new EntityRelation(assetB, assetA, "firstLevel"); - EntityRelation secondLevelRelation = new EntityRelation(assetC, assetB, "secondLevel"); - EntityRelation thirdLevelRelation1 = new EntityRelation(assetC, assetD, "thirdLevel"); - EntityRelation thirdLevelRelation2 = new EntityRelation(assetC, assetE, "thirdLevel"); - EntityRelation thirdLevelRelation3 = new EntityRelation(assetC, assetF, "thirdLevel"); + // create first and second level + saveRelation(new EntityRelation(assetB, assetA, "firstLevel")); + saveRelation(new EntityRelation(assetC, assetB, "secondLevel")); - firstLevelRelation = saveRelation(firstLevelRelation); - secondLevelRelation = saveRelation(secondLevelRelation); - thirdLevelRelation1 = saveRelation(thirdLevelRelation1); - thirdLevelRelation2 = saveRelation(thirdLevelRelation2); - thirdLevelRelation3 = saveRelation(thirdLevelRelation3); + int limit = tbTenantProfileCache.get(tenantId) + .getDefaultProfileConfiguration() + .getMaxRelatedEntitiesToReturnPerCfArgument(); - List expectedRelations = List.of(thirdLevelRelation1, thirdLevelRelation2, thirdLevelRelation3); + int totalCreated = limit - 1; - EntityRelationPathQuery relationPathQuery = new EntityRelationPathQuery(assetA, List.of( + List allThirdLevelRelations = new ArrayList<>(); + for (int i = 0; i < totalCreated; i++) { + AssetId leaf = new AssetId(Uuids.timeBased()); + allThirdLevelRelations.add(saveRelation(new EntityRelation(assetC, leaf, "thirdLevel"))); + } + + EntityRelationPathQuery query = new EntityRelationPathQuery(assetA, List.of( new RelationPathLevel(EntitySearchDirection.TO, "firstLevel"), new RelationPathLevel(EntitySearchDirection.TO, "secondLevel"), new RelationPathLevel(EntitySearchDirection.FROM, "thirdLevel") )); - List entityRelations = relationService.findByRelationPathQueryAsync(tenantId, relationPathQuery).get(); - assertThat(expectedRelations).containsExactlyInAnyOrderElementsOf(entityRelations); + // call a method that applies the default limit internally + List result = relationService.findByRelationPathQueryAsync(tenantId, query).get(); + + // verify that limit has been applied + assertThat(result).hasSize(totalCreated); + + // verify all returned are valid third-level relations under C + assertThat(result) + .allSatisfy(rel -> { + assertThat(rel.getType()).isEqualTo("thirdLevel"); + assertThat(rel.getFrom()).isEqualTo(assetC); + }); + + // verify the returned subset is part of all created relations + assertThat(result).isEqualTo(allThirdLevelRelations); + } + + @Test + public void testFindByPathQueryWithExceedingLimit() throws Exception { + /* + A + └──[firstLevel, TO]→ B + └──[secondLevel, TO]→ C + ├──[thirdLevel, FROM]→ D1 + ├──[thirdLevel, FROM]→ D2 + ├──[thirdLevel, FROM]→ ... + └──[thirdLevel, FROM]→ D{N + 20}, where N is the limit + */ + AssetId assetA = new AssetId(Uuids.timeBased()); + AssetId assetB = new AssetId(Uuids.timeBased()); + AssetId assetC = new AssetId(Uuids.timeBased()); + + // create first and second level + saveRelation(new EntityRelation(assetB, assetA, "firstLevel")); + saveRelation(new EntityRelation(assetC, assetB, "secondLevel")); + + int limit = tbTenantProfileCache.get(tenantId) + .getDefaultProfileConfiguration() + .getMaxRelatedEntitiesToReturnPerCfArgument(); + + int totalCreated = limit + 20; + + List allThirdLevelRelations = new ArrayList<>(); + for (int i = 0; i < totalCreated; i++) { + AssetId leaf = new AssetId(Uuids.timeBased()); + allThirdLevelRelations.add(saveRelation(new EntityRelation(assetC, leaf, "thirdLevel"))); + } + + EntityRelationPathQuery query = new EntityRelationPathQuery(assetA, List.of( + new RelationPathLevel(EntitySearchDirection.TO, "firstLevel"), + new RelationPathLevel(EntitySearchDirection.TO, "secondLevel"), + new RelationPathLevel(EntitySearchDirection.FROM, "thirdLevel") + )); + + // call a method that applies the default limit internally + List result = relationService.findByRelationPathQueryAsync(tenantId, query).get(); + + // verify that limit has been applied + assertThat(result).hasSize(limit); + + // verify all returned are valid third-level relations under C + assertThat(result) + .allSatisfy(rel -> { + assertThat(rel.getType()).isEqualTo("thirdLevel"); + assertThat(rel.getFrom()).isEqualTo(assetC); + }); + + // verify the returned subset is part of all created relations + assertThat(result).isSubsetOf(allThirdLevelRelations); } @Test From f381125d6957f79ba8dae15c9112cc630eb05a1a Mon Sep 17 00:00:00 2001 From: dshvaika Date: Thu, 9 Oct 2025 13:15:14 +0300 Subject: [PATCH 24/43] fix typo --- .../org/thingsboard/server/dao/sql/relation/JpaRelationDao.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dao/src/main/java/org/thingsboard/server/dao/sql/relation/JpaRelationDao.java b/dao/src/main/java/org/thingsboard/server/dao/sql/relation/JpaRelationDao.java index 4c1b8f299b..3ab382d2a4 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/sql/relation/JpaRelationDao.java +++ b/dao/src/main/java/org/thingsboard/server/dao/sql/relation/JpaRelationDao.java @@ -310,7 +310,7 @@ public class JpaRelationDao extends JpaAbstractDaoListeningExecutorService imple String sql = buildRelationPathSql(query); Object[] params = buildRelationPathParams(query, limit); - log.info("[{}] relation path query: {}", tenantId, sql); + log.trace("[{}] relation path query: {}", tenantId, sql); return jdbcTemplate.queryForList(sql, params).stream() .map(row -> { From f5804d928b093d3bb1ae60a2bb83fe8ead492ccb Mon Sep 17 00:00:00 2001 From: dshvaika Date: Thu, 9 Oct 2025 14:51:53 +0300 Subject: [PATCH 25/43] Added validation for Expression result propagation mode --- ...opagationCalculatedFieldConfiguration.java | 20 ++++++++++++++++--- ...ationCalculatedFieldConfigurationTest.java | 17 ++++++++++++++-- 2 files changed, 32 insertions(+), 5 deletions(-) diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/PropagationCalculatedFieldConfiguration.java b/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/PropagationCalculatedFieldConfiguration.java index 70ffd952cf..5e8c822d78 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/PropagationCalculatedFieldConfiguration.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/PropagationCalculatedFieldConfiguration.java @@ -50,7 +50,7 @@ public class PropagationCalculatedFieldConfiguration extends BaseCalculatedField propagationRestriction(); if (!applyExpressionToResolvedArguments) { arguments.forEach((name, argument) -> { - if (argument.getRefEntityId() != null || argument.getRefDynamicSourceConfiguration() != null) { + if (!currentEntitySource(argument)) { throw new IllegalArgumentException("Arguments in 'Arguments only' propagation mode support only the 'Current entity' source entity type!"); } if (argument.getRefEntityKey() == null) { @@ -61,8 +61,17 @@ public class PropagationCalculatedFieldConfiguration extends BaseCalculatedField "Only 'Attribute' or 'Latest telemetry' arguments are allowed for 'Arguments only' propagation mode!"); } }); - } else if (StringUtils.isBlank(expression)) { - throw new IllegalArgumentException("Expression must be specified for 'Expression result' propagation mode!"); + } else { + boolean noneMatchCurrentEntitySource = arguments.entrySet() + .stream() + .noneMatch(entry -> currentEntitySource(entry.getValue())); + if (noneMatchCurrentEntitySource) { + throw new IllegalArgumentException("At least one argument must be configured with the 'Current entity' " + + "source entity type for 'Expression result' propagation mode!"); + } + if (StringUtils.isBlank(expression)) { + throw new IllegalArgumentException("Expression must be specified for 'Expression result' propagation mode!"); + } } } @@ -79,4 +88,9 @@ public class PropagationCalculatedFieldConfiguration extends BaseCalculatedField throw new IllegalArgumentException("Argument name '" + PROPAGATION_CONFIG_ARGUMENT + "' is reserved and cannot be used."); } } + + private boolean currentEntitySource(Argument argument) { + return argument.getRefEntityId() == null && argument.getRefDynamicSourceConfiguration() == null; + } + } diff --git a/common/data/src/test/java/org/thingsboard/server/common/data/cf/configuration/PropagationCalculatedFieldConfigurationTest.java b/common/data/src/test/java/org/thingsboard/server/common/data/cf/configuration/PropagationCalculatedFieldConfigurationTest.java index 64f7b0efd0..36f63feed7 100644 --- a/common/data/src/test/java/org/thingsboard/server/common/data/cf/configuration/PropagationCalculatedFieldConfigurationTest.java +++ b/common/data/src/test/java/org/thingsboard/server/common/data/cf/configuration/PropagationCalculatedFieldConfigurationTest.java @@ -52,13 +52,26 @@ public class PropagationCalculatedFieldConfigurationTest { @Test void validateShouldThrowWhenConfigurationDisallowArgumentsWithDynamicReferenceConfiguration() { + var cfg = new PropagationCalculatedFieldConfiguration(); + Argument argumentWithDynamicRefEntitySource = new Argument(); + argumentWithDynamicRefEntitySource.setRefDynamicSourceConfiguration(new CurrentOwnerDynamicSourceConfiguration()); + cfg.setArguments(Map.of("argumentWithDynamicRefEntitySource", argumentWithDynamicRefEntitySource)); + assertThatThrownBy(cfg::validate) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("Arguments in 'Arguments only' propagation mode support only the 'Current entity' source entity type!"); + } + + @Test + void validateShouldThrowWhenConfigurationHasNoArgumentsWithCurrentEntitySource() { var cfg = new PropagationCalculatedFieldConfiguration(); Argument argumentWithRefEntityIdSet = new Argument(); - argumentWithRefEntityIdSet.setRefDynamicSourceConfiguration(new CurrentOwnerDynamicSourceConfiguration()); + argumentWithRefEntityIdSet.setRefEntityId(new DeviceId(UUID.fromString("3703e895-3f9b-4b75-a715-b68f1ad51944"))); cfg.setArguments(Map.of("argumentWithRefEntityIdSet", argumentWithRefEntityIdSet)); + cfg.setApplyExpressionToResolvedArguments(true); assertThatThrownBy(cfg::validate) .isInstanceOf(IllegalArgumentException.class) - .hasMessage("Arguments in 'Arguments only' propagation mode support only the 'Current entity' source entity type!"); + .hasMessage("At least one argument must be configured with the 'Current entity' " + + "source entity type for 'Expression result' propagation mode!"); } @Test From f1da967a7d0ee27d59ad0be213e052e1d1434aff Mon Sep 17 00:00:00 2001 From: dashevchenko Date: Fri, 10 Oct 2025 17:46:15 +0300 Subject: [PATCH 26/43] added uniquifyStrategy --- .../main/data/upgrade/basic/schema_update.sql | 2 -- .../server/controller/AssetController.java | 14 +++++++---- .../controller/ControllerConstants.java | 7 +++++- .../server/controller/CustomerController.java | 14 +++++++---- .../server/controller/DeviceController.java | 24 ++++++++++++------- .../controller/EntityViewController.java | 14 +++++++---- .../server/controller/Lwm2mController.java | 2 +- .../controller/AssetControllerTest.java | 18 +++++++++----- .../controller/CustomerControllerTest.java | 19 ++++++++++----- .../controller/DeviceControllerTest.java | 18 +++++++++----- .../controller/EntityViewControllerTest.java | 18 +++++++++----- .../common/data/NameConflictStrategy.java | 4 ++-- .../server/common/data/UniquifyStrategy.java | 23 ++++++++++++++++++ .../java/org/thingsboard/server/dao/Dao.java | 2 +- .../dao/entity/AbstractEntityService.java | 23 ++++++++++++------ .../dao/entityview/EntityViewServiceImpl.java | 12 ++++------ .../validator/EntityViewDataValidator.java | 8 +++++++ .../server/dao/sql/asset/AssetRepository.java | 4 ++-- .../server/dao/sql/asset/JpaAssetDao.java | 6 ++--- .../dao/sql/customer/CustomerRepository.java | 4 ++-- .../dao/sql/customer/JpaCustomerDao.java | 4 ++-- .../dao/sql/device/DeviceRepository.java | 4 ++-- .../server/dao/sql/device/JpaDeviceDao.java | 4 ++-- .../sql/entityview/EntityViewRepository.java | 4 ++-- .../dao/sql/entityview/JpaEntityViewDao.java | 4 ++-- .../main/resources/sql/schema-entities.sql | 1 - 26 files changed, 170 insertions(+), 87 deletions(-) create mode 100644 common/data/src/main/java/org/thingsboard/server/common/data/UniquifyStrategy.java diff --git a/application/src/main/data/upgrade/basic/schema_update.sql b/application/src/main/data/upgrade/basic/schema_update.sql index 3986a3c222..0e7b0d9455 100644 --- a/application/src/main/data/upgrade/basic/schema_update.sql +++ b/application/src/main/data/upgrade/basic/schema_update.sql @@ -47,5 +47,3 @@ WHERE NOT ( -- UPDATE TENANT PROFILE CONFIGURATION END -ALTER TABLE entity_view ADD CONSTRAINT entity_view_name_unq_key UNIQUE (tenant_id, name); - diff --git a/application/src/main/java/org/thingsboard/server/controller/AssetController.java b/application/src/main/java/org/thingsboard/server/controller/AssetController.java index d00725f1c1..d4223d30f1 100644 --- a/application/src/main/java/org/thingsboard/server/controller/AssetController.java +++ b/application/src/main/java/org/thingsboard/server/controller/AssetController.java @@ -36,6 +36,7 @@ import org.thingsboard.server.common.data.Customer; import org.thingsboard.server.common.data.EntitySubtype; import org.thingsboard.server.common.data.NameConflictPolicy; import org.thingsboard.server.common.data.NameConflictStrategy; +import org.thingsboard.server.common.data.UniquifyStrategy; import org.thingsboard.server.common.data.asset.Asset; import org.thingsboard.server.common.data.asset.AssetInfo; import org.thingsboard.server.common.data.asset.AssetSearchQuery; @@ -79,7 +80,7 @@ import static org.thingsboard.server.controller.ControllerConstants.EDGE_ID_PARA import static org.thingsboard.server.controller.ControllerConstants.EDGE_UNASSIGN_ASYNC_FIRST_STEP_DESCRIPTION; import static org.thingsboard.server.controller.ControllerConstants.EDGE_UNASSIGN_RECEIVE_STEP_DESCRIPTION; import static org.thingsboard.server.controller.ControllerConstants.NAME_CONFLICT_POLICY_DESC; -import static org.thingsboard.server.controller.ControllerConstants.NAME_CONFLICT_SEPARATOR_DESC; +import static org.thingsboard.server.controller.ControllerConstants.UNIQUIFY_SEPARATOR_DESC; import static org.thingsboard.server.controller.ControllerConstants.PAGE_DATA_PARAMETERS; import static org.thingsboard.server.controller.ControllerConstants.PAGE_NUMBER_DESCRIPTION; import static org.thingsboard.server.controller.ControllerConstants.PAGE_SIZE_DESCRIPTION; @@ -87,6 +88,7 @@ import static org.thingsboard.server.controller.ControllerConstants.SORT_ORDER_D import static org.thingsboard.server.controller.ControllerConstants.SORT_PROPERTY_DESCRIPTION; import static org.thingsboard.server.controller.ControllerConstants.TENANT_AUTHORITY_PARAGRAPH; import static org.thingsboard.server.controller.ControllerConstants.TENANT_OR_CUSTOMER_AUTHORITY_PARAGRAPH; +import static org.thingsboard.server.controller.ControllerConstants.UNIQUIFY_STRATEGY_DESC; import static org.thingsboard.server.controller.ControllerConstants.UUID_WIKI_LINK; import static org.thingsboard.server.controller.EdgeController.EDGE_ID; @@ -143,12 +145,14 @@ public class AssetController extends BaseController { @ResponseBody public Asset saveAsset(@io.swagger.v3.oas.annotations.parameters.RequestBody(description = "A JSON value representing the asset.") @RequestBody Asset asset, @Parameter(description = NAME_CONFLICT_POLICY_DESC) - @RequestParam(name = "policy", defaultValue = "FAIL") NameConflictPolicy policy, - @Parameter(description = NAME_CONFLICT_SEPARATOR_DESC) - @RequestParam(name = "separator", defaultValue = "_") String separator) throws Exception { + @RequestParam(name = "nameConflictPolicy", defaultValue = "FAIL") NameConflictPolicy nameConflictPolicy, + @Parameter(description = UNIQUIFY_SEPARATOR_DESC) + @RequestParam(name = "uniquifySeparator", defaultValue = "_") String uniquifySeparator, + @Parameter(description = UNIQUIFY_STRATEGY_DESC) + @RequestParam(name = "uniquifyStrategy", defaultValue = "RANDOM") UniquifyStrategy uniquifyStrategy) throws Exception { asset.setTenantId(getTenantId()); checkEntity(asset.getId(), asset, Resource.ASSET); - return tbAssetService.save(asset, new NameConflictStrategy(policy, separator), getCurrentUser()); + return tbAssetService.save(asset, new NameConflictStrategy(nameConflictPolicy, uniquifySeparator, uniquifyStrategy), getCurrentUser()); } @ApiOperation(value = "Delete asset (deleteAsset)", 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 7b5bf8b165..9e533f6d6e 100644 --- a/application/src/main/java/org/thingsboard/server/controller/ControllerConstants.java +++ b/application/src/main/java/org/thingsboard/server/controller/ControllerConstants.java @@ -1749,7 +1749,12 @@ public class ControllerConstants { " If omitted, FAIL policy is applied. FAIL policy implies exception will be thrown if an entity with the same name already exists. " + " UNIQUIFY policy appends a suffix to the entity name, if a name conflict occurs."; - public static final String NAME_CONFLICT_SEPARATOR_DESC = "Optional value of name suffix separator used by UNIQUIFY policy. By default, underscore separator is used. " + + public static final String UNIQUIFY_SEPARATOR_DESC = "Optional value of name suffix separator used by UNIQUIFY policy. By default, underscore separator is used. " + "For example, strategy is UNIQUIFY, separator is '-'; if a name conflict occurs for entity name 'test-name', " + "created entity will have name like 'test-name-7fsh4f'."; + + public static final String UNIQUIFY_STRATEGY_DESC = "Optional value of uniquify strategy used by UNIQUIFY policy. Possible values: RANDOM or INCREMENTAL. " + + "By default, RANDOM strategy is used, which means random alphanumeric string will be added as a suffix to entity name. " + + "For example, strategy is UNIQUIFY, uniquify strategy is INCREMENTAL; if a name conflict occurs for entity name 'test-name', " + + "created entity will have name like 'test-name-1."; } diff --git a/application/src/main/java/org/thingsboard/server/controller/CustomerController.java b/application/src/main/java/org/thingsboard/server/controller/CustomerController.java index 839bcc984f..fca7ce3e86 100644 --- a/application/src/main/java/org/thingsboard/server/controller/CustomerController.java +++ b/application/src/main/java/org/thingsboard/server/controller/CustomerController.java @@ -34,6 +34,7 @@ import org.thingsboard.common.util.JacksonUtil; import org.thingsboard.server.common.data.Customer; import org.thingsboard.server.common.data.NameConflictStrategy; import org.thingsboard.server.common.data.NameConflictPolicy; +import org.thingsboard.server.common.data.UniquifyStrategy; import org.thingsboard.server.common.data.exception.ThingsboardException; import org.thingsboard.server.common.data.id.CustomerId; import org.thingsboard.server.common.data.id.TenantId; @@ -50,7 +51,7 @@ import static org.thingsboard.server.controller.ControllerConstants.CUSTOMER_ID_ import static org.thingsboard.server.controller.ControllerConstants.CUSTOMER_TEXT_SEARCH_DESCRIPTION; import static org.thingsboard.server.controller.ControllerConstants.HOME_DASHBOARD; import static org.thingsboard.server.controller.ControllerConstants.NAME_CONFLICT_POLICY_DESC; -import static org.thingsboard.server.controller.ControllerConstants.NAME_CONFLICT_SEPARATOR_DESC; +import static org.thingsboard.server.controller.ControllerConstants.UNIQUIFY_SEPARATOR_DESC; import static org.thingsboard.server.controller.ControllerConstants.PAGE_DATA_PARAMETERS; import static org.thingsboard.server.controller.ControllerConstants.PAGE_NUMBER_DESCRIPTION; import static org.thingsboard.server.controller.ControllerConstants.PAGE_SIZE_DESCRIPTION; @@ -58,6 +59,7 @@ import static org.thingsboard.server.controller.ControllerConstants.SORT_ORDER_D import static org.thingsboard.server.controller.ControllerConstants.SORT_PROPERTY_DESCRIPTION; import static org.thingsboard.server.controller.ControllerConstants.TENANT_AUTHORITY_PARAGRAPH; import static org.thingsboard.server.controller.ControllerConstants.TENANT_OR_CUSTOMER_AUTHORITY_PARAGRAPH; +import static org.thingsboard.server.controller.ControllerConstants.UNIQUIFY_STRATEGY_DESC; import static org.thingsboard.server.controller.ControllerConstants.UUID_WIKI_LINK; @RestController @@ -134,12 +136,14 @@ public class CustomerController extends BaseController { @ResponseBody public Customer saveCustomer(@io.swagger.v3.oas.annotations.parameters.RequestBody(description = "A JSON value representing the customer.") @RequestBody Customer customer, @Parameter(description = NAME_CONFLICT_POLICY_DESC) - @RequestParam(name = "policy", defaultValue = "FAIL") NameConflictPolicy policy, - @Parameter(description = NAME_CONFLICT_SEPARATOR_DESC) - @RequestParam(name = "separator", defaultValue = "_") String separator) throws Exception { + @RequestParam(name = "nameConflictPolicy", defaultValue = "FAIL") NameConflictPolicy nameConflictPolicy, + @Parameter(description = UNIQUIFY_SEPARATOR_DESC) + @RequestParam(name = "uniquifySeparator", defaultValue = "_") String uniquifySeparator, + @Parameter(description = UNIQUIFY_STRATEGY_DESC) + @RequestParam(name = "uniquifyStrategy", defaultValue = "RANDOM") UniquifyStrategy uniquifyStrategy) throws Exception { customer.setTenantId(getTenantId()); checkEntity(customer.getId(), customer, Resource.CUSTOMER); - return tbCustomerService.save(customer, new NameConflictStrategy(policy, separator), getCurrentUser()); + return tbCustomerService.save(customer, new NameConflictStrategy(nameConflictPolicy, uniquifySeparator, uniquifyStrategy), getCurrentUser()); } @ApiOperation(value = "Delete Customer (deleteCustomer)", diff --git a/application/src/main/java/org/thingsboard/server/controller/DeviceController.java b/application/src/main/java/org/thingsboard/server/controller/DeviceController.java index a5efdc1ca4..8bbf4ae2f6 100644 --- a/application/src/main/java/org/thingsboard/server/controller/DeviceController.java +++ b/application/src/main/java/org/thingsboard/server/controller/DeviceController.java @@ -50,6 +50,7 @@ import org.thingsboard.server.common.data.NameConflictPolicy; import org.thingsboard.server.common.data.NameConflictStrategy; import org.thingsboard.server.common.data.SaveDeviceWithCredentialsRequest; import org.thingsboard.server.common.data.Tenant; +import org.thingsboard.server.common.data.UniquifyStrategy; import org.thingsboard.server.common.data.device.DeviceSearchQuery; import org.thingsboard.server.common.data.edge.Edge; import org.thingsboard.server.common.data.exception.ThingsboardErrorCode; @@ -111,7 +112,7 @@ import static org.thingsboard.server.controller.ControllerConstants.EDGE_ID_PARA import static org.thingsboard.server.controller.ControllerConstants.EDGE_UNASSIGN_ASYNC_FIRST_STEP_DESCRIPTION; import static org.thingsboard.server.controller.ControllerConstants.EDGE_UNASSIGN_RECEIVE_STEP_DESCRIPTION; import static org.thingsboard.server.controller.ControllerConstants.NAME_CONFLICT_POLICY_DESC; -import static org.thingsboard.server.controller.ControllerConstants.NAME_CONFLICT_SEPARATOR_DESC; +import static org.thingsboard.server.controller.ControllerConstants.UNIQUIFY_SEPARATOR_DESC; import static org.thingsboard.server.controller.ControllerConstants.PAGE_DATA_PARAMETERS; import static org.thingsboard.server.controller.ControllerConstants.PAGE_NUMBER_DESCRIPTION; import static org.thingsboard.server.controller.ControllerConstants.PAGE_SIZE_DESCRIPTION; @@ -121,6 +122,7 @@ import static org.thingsboard.server.controller.ControllerConstants.TENANT_AUTHO import static org.thingsboard.server.controller.ControllerConstants.TENANT_ID; import static org.thingsboard.server.controller.ControllerConstants.TENANT_ID_PARAM_DESCRIPTION; import static org.thingsboard.server.controller.ControllerConstants.TENANT_OR_CUSTOMER_AUTHORITY_PARAGRAPH; +import static org.thingsboard.server.controller.ControllerConstants.UNIQUIFY_STRATEGY_DESC; import static org.thingsboard.server.controller.ControllerConstants.UUID_WIKI_LINK; import static org.thingsboard.server.controller.EdgeController.EDGE_ID; @@ -184,16 +186,18 @@ public class DeviceController extends BaseController { "If omitted, access token will be auto-generated.") @RequestParam(name = "accessToken", required = false) String accessToken, @Parameter(description = NAME_CONFLICT_POLICY_DESC) - @RequestParam(name = "policy", defaultValue = "FAIL") NameConflictPolicy policy, - @Parameter(description = NAME_CONFLICT_SEPARATOR_DESC) - @RequestParam(name = "separator", defaultValue = "_") String separator) throws Exception { + @RequestParam(name = "nameConflictPolicy", defaultValue = "FAIL") NameConflictPolicy nameConflictPolicy, + @Parameter(description = UNIQUIFY_SEPARATOR_DESC) + @RequestParam(name = "uniquifySeparator", defaultValue = "_") String uniquifySeparator, + @Parameter(description = UNIQUIFY_STRATEGY_DESC) + @RequestParam(name = "uniquifyStrategy", defaultValue = "RANDOM") UniquifyStrategy uniquifyStrategy) throws Exception { device.setTenantId(getCurrentUser().getTenantId()); if (device.getId() != null) { checkDeviceId(device.getId(), Operation.WRITE); } else { checkEntity(null, device, Resource.DEVICE); } - return tbDeviceService.save(device, accessToken, new NameConflictStrategy(policy, separator), getCurrentUser()); + return tbDeviceService.save(device, accessToken, new NameConflictStrategy(nameConflictPolicy, uniquifySeparator, uniquifyStrategy), getCurrentUser()); } @ApiOperation(value = "Create Device (saveDevice) with credentials ", @@ -220,14 +224,16 @@ public class DeviceController extends BaseController { public Device saveDeviceWithCredentials(@Parameter(description = "The JSON object with device and credentials. See method description above for example.") @Valid @RequestBody SaveDeviceWithCredentialsRequest deviceAndCredentials, @Parameter(description = NAME_CONFLICT_POLICY_DESC) - @RequestParam(name = "policy", defaultValue = "FAIL") NameConflictPolicy policy, - @Parameter(description = NAME_CONFLICT_SEPARATOR_DESC) - @RequestParam(name = "separator", defaultValue = "_") String separator) throws ThingsboardException { + @RequestParam(name = "nameConflictPolicy", defaultValue = "FAIL") NameConflictPolicy nameConflictPolicy, + @Parameter(description = UNIQUIFY_SEPARATOR_DESC) + @RequestParam(name = "uniquifySeparator", defaultValue = "_") String uniquifySeparator, + @Parameter(description = UNIQUIFY_STRATEGY_DESC) + @RequestParam(name = "uniquifyStrategy", defaultValue = "RANDOM") UniquifyStrategy uniquifyStrategy) throws ThingsboardException { Device device = deviceAndCredentials.getDevice(); DeviceCredentials credentials = deviceAndCredentials.getCredentials(); device.setTenantId(getCurrentUser().getTenantId()); checkEntity(device.getId(), device, Resource.DEVICE); - return tbDeviceService.saveDeviceWithCredentials(device, credentials, new NameConflictStrategy(policy, separator), getCurrentUser()); + return tbDeviceService.saveDeviceWithCredentials(device, credentials, new NameConflictStrategy(nameConflictPolicy, uniquifySeparator, uniquifyStrategy), getCurrentUser()); } @ApiOperation(value = "Delete device (deleteDevice)", diff --git a/application/src/main/java/org/thingsboard/server/controller/EntityViewController.java b/application/src/main/java/org/thingsboard/server/controller/EntityViewController.java index 4cf9a51227..67fc02ab29 100644 --- a/application/src/main/java/org/thingsboard/server/controller/EntityViewController.java +++ b/application/src/main/java/org/thingsboard/server/controller/EntityViewController.java @@ -36,6 +36,7 @@ import org.thingsboard.server.common.data.EntityView; import org.thingsboard.server.common.data.EntityViewInfo; import org.thingsboard.server.common.data.NameConflictPolicy; import org.thingsboard.server.common.data.NameConflictStrategy; +import org.thingsboard.server.common.data.UniquifyStrategy; import org.thingsboard.server.common.data.edge.Edge; import org.thingsboard.server.common.data.entityview.EntityViewSearchQuery; import org.thingsboard.server.common.data.exception.ThingsboardException; @@ -72,7 +73,7 @@ import static org.thingsboard.server.controller.ControllerConstants.ENTITY_VIEW_ import static org.thingsboard.server.controller.ControllerConstants.ENTITY_VIEW_TYPE; import static org.thingsboard.server.controller.ControllerConstants.MODEL_DESCRIPTION; import static org.thingsboard.server.controller.ControllerConstants.NAME_CONFLICT_POLICY_DESC; -import static org.thingsboard.server.controller.ControllerConstants.NAME_CONFLICT_SEPARATOR_DESC; +import static org.thingsboard.server.controller.ControllerConstants.UNIQUIFY_SEPARATOR_DESC; import static org.thingsboard.server.controller.ControllerConstants.PAGE_DATA_PARAMETERS; import static org.thingsboard.server.controller.ControllerConstants.PAGE_NUMBER_DESCRIPTION; import static org.thingsboard.server.controller.ControllerConstants.PAGE_SIZE_DESCRIPTION; @@ -80,6 +81,7 @@ import static org.thingsboard.server.controller.ControllerConstants.SORT_ORDER_D import static org.thingsboard.server.controller.ControllerConstants.SORT_PROPERTY_DESCRIPTION; import static org.thingsboard.server.controller.ControllerConstants.TENANT_AUTHORITY_PARAGRAPH; import static org.thingsboard.server.controller.ControllerConstants.TENANT_OR_CUSTOMER_AUTHORITY_PARAGRAPH; +import static org.thingsboard.server.controller.ControllerConstants.UNIQUIFY_STRATEGY_DESC; import static org.thingsboard.server.controller.EdgeController.EDGE_ID; /** @@ -134,9 +136,11 @@ public class EntityViewController extends BaseController { @Parameter(description = "A JSON object representing the entity view.") @RequestBody EntityView entityView, @Parameter(description = NAME_CONFLICT_POLICY_DESC) - @RequestParam(name = "policy", defaultValue = "FAIL") NameConflictPolicy policy, - @Parameter(description = NAME_CONFLICT_SEPARATOR_DESC) - @RequestParam(name = "separator", defaultValue = "_") String separator) throws Exception { + @RequestParam(name = "nameConflictPolicy", defaultValue = "FAIL") NameConflictPolicy nameConflictPolicy, + @Parameter(description = UNIQUIFY_SEPARATOR_DESC) + @RequestParam(name = "uniquifySeparator", defaultValue = "_") String uniquifySeparator, + @Parameter(description = UNIQUIFY_STRATEGY_DESC) + @RequestParam(name = "uniquifyStrategy", defaultValue = "RANDOM") UniquifyStrategy uniquifyStrategy) throws Exception { entityView.setTenantId(getCurrentUser().getTenantId()); EntityView existingEntityView = null; if (entityView.getId() == null) { @@ -145,7 +149,7 @@ public class EntityViewController extends BaseController { } else { existingEntityView = checkEntityViewId(entityView.getId(), Operation.WRITE); } - return tbEntityViewService.save(entityView, existingEntityView, new NameConflictStrategy(policy, separator), getCurrentUser()); + return tbEntityViewService.save(entityView, existingEntityView, new NameConflictStrategy(nameConflictPolicy, uniquifySeparator, uniquifyStrategy), getCurrentUser()); } @ApiOperation(value = "Delete entity view (deleteEntityView)", diff --git a/application/src/main/java/org/thingsboard/server/controller/Lwm2mController.java b/application/src/main/java/org/thingsboard/server/controller/Lwm2mController.java index cdab0805a1..7e7cd84a12 100644 --- a/application/src/main/java/org/thingsboard/server/controller/Lwm2mController.java +++ b/application/src/main/java/org/thingsboard/server/controller/Lwm2mController.java @@ -76,6 +76,6 @@ public class Lwm2mController extends BaseController { public Device saveDeviceWithCredentials(@RequestBody Map, Object> deviceWithDeviceCredentials) throws ThingsboardException { Device device = checkNotNull(JacksonUtil.convertValue(deviceWithDeviceCredentials.get(Device.class), Device.class)); DeviceCredentials credentials = checkNotNull(JacksonUtil.convertValue(deviceWithDeviceCredentials.get(DeviceCredentials.class), DeviceCredentials.class)); - return deviceController.saveDeviceWithCredentials(new SaveDeviceWithCredentialsRequest(device, credentials), DEFAULT.policy(), DEFAULT.separator()); + return deviceController.saveDeviceWithCredentials(new SaveDeviceWithCredentialsRequest(device, credentials), DEFAULT.policy(), DEFAULT.separator(), DEFAULT.uniquifyStrategy()); } } diff --git a/application/src/test/java/org/thingsboard/server/controller/AssetControllerTest.java b/application/src/test/java/org/thingsboard/server/controller/AssetControllerTest.java index bb0d90e45a..e160fff59e 100644 --- a/application/src/test/java/org/thingsboard/server/controller/AssetControllerTest.java +++ b/application/src/test/java/org/thingsboard/server/controller/AssetControllerTest.java @@ -1083,19 +1083,25 @@ public class AssetControllerTest extends AbstractControllerTest { @Test public void testSaveAssetWithUniquifyStrategy() throws Exception { Asset asset = new Asset(); - asset.setName("My asset"); + asset.setName("My unique asset"); asset.setType("default"); doPost("/api/asset", asset, Asset.class); doPost("/api/asset", asset).andExpect(status().isBadRequest()); - doPost("/api/asset?policy=FAIL", asset).andExpect(status().isBadRequest()); + doPost("/api/asset?nameConflictPolicy=FAIL", asset).andExpect(status().isBadRequest()); + + Asset secondAsset = doPost("/api/asset?nameConflictPolicy=UNIQUIFY", asset, Asset.class); + assertThat(secondAsset.getName()).startsWith("My unique asset_"); + + Asset thirdAsset = doPost("/api/asset?nameConflictPolicy=UNIQUIFY&uniquifySeparator=-", asset, Asset.class); + assertThat(thirdAsset.getName()).startsWith("My unique asset-"); - Asset secondAsset = doPost("/api/asset?policy=UNIQUIFY", asset, Asset.class); - assertThat(secondAsset.getName()).startsWith("My asset_"); + Asset fourthAsset = doPost("/api/asset?nameConflictPolicy=UNIQUIFY&uniquifyStrategy=INCREMENTAL", asset, Asset.class); + assertThat(fourthAsset.getName()).isEqualTo("My unique asset_1"); - Asset thirdAsset = doPost("/api/asset?policy=UNIQUIFY&separator=-", asset, Asset.class); - assertThat(thirdAsset.getName()).startsWith("My asset-"); + Asset fifthAsset = doPost("/api/asset?nameConflictPolicy=UNIQUIFY&uniquifyStrategy=INCREMENTAL", asset, Asset.class); + assertThat(fifthAsset.getName()).isEqualTo("My unique asset_2"); } private Asset createAsset(String name) { diff --git a/application/src/test/java/org/thingsboard/server/controller/CustomerControllerTest.java b/application/src/test/java/org/thingsboard/server/controller/CustomerControllerTest.java index 08eecf3f10..f4e91f993e 100644 --- a/application/src/test/java/org/thingsboard/server/controller/CustomerControllerTest.java +++ b/application/src/test/java/org/thingsboard/server/controller/CustomerControllerTest.java @@ -33,6 +33,7 @@ import org.springframework.context.annotation.Primary; import org.springframework.test.context.ContextConfiguration; import org.thingsboard.common.util.ThingsBoardExecutors; import org.thingsboard.server.common.data.Customer; +import org.thingsboard.server.common.data.Device; import org.thingsboard.server.common.data.StringUtils; import org.thingsboard.server.common.data.Tenant; import org.thingsboard.server.common.data.User; @@ -465,16 +466,22 @@ public class CustomerControllerTest extends AbstractControllerTest { @Test public void testSaveCustomerWithUniquifyStrategy() throws Exception { Customer customer = new Customer(); - customer.setTitle("My customer"); + customer.setTitle("My unique customer"); Customer savedCustomer = doPost("/api/customer", customer, Customer.class); - doPost("/api/customer?policy=FAIL", customer).andExpect(status().isBadRequest()); + doPost("/api/customer?nameConflictPolicy=FAIL", customer).andExpect(status().isBadRequest()); + + Customer secondCustomer = doPost("/api/customer?nameConflictPolicy=UNIQUIFY", customer, Customer.class); + assertThat(secondCustomer.getName()).startsWith("My unique customer_"); + + Customer thirdCustomer = doPost("/api/customer?nameConflictPolicy=UNIQUIFY&uniquifySeparator=-", customer, Customer.class); + assertThat(thirdCustomer.getName()).startsWith("My unique customer-"); - Customer secondCustomer = doPost("/api/customer?policy=UNIQUIFY", customer, Customer.class); - assertThat(secondCustomer.getName()).startsWith("My customer_"); + Customer fourthCustomer = doPost("/api/customer?nameConflictPolicy=UNIQUIFY&uniquifyStrategy=INCREMENTAL", customer, Customer.class); + assertThat(fourthCustomer.getName()).isEqualTo("My unique customer_1"); - Customer thirdCustomer = doPost("/api/customer?policy=UNIQUIFY&separator=-", customer, Customer.class); - assertThat(thirdCustomer.getName()).startsWith("My customer-"); + Customer fifthCustomer = doPost("/api/customer?nameConflictPolicy=UNIQUIFY&uniquifyStrategy=INCREMENTAL", customer, Customer.class); + assertThat(fifthCustomer.getName()).isEqualTo("My unique customer_2"); } private Customer createCustomer(String title) { diff --git a/application/src/test/java/org/thingsboard/server/controller/DeviceControllerTest.java b/application/src/test/java/org/thingsboard/server/controller/DeviceControllerTest.java index 118e0f1137..ca5f146a88 100644 --- a/application/src/test/java/org/thingsboard/server/controller/DeviceControllerTest.java +++ b/application/src/test/java/org/thingsboard/server/controller/DeviceControllerTest.java @@ -1611,19 +1611,25 @@ public class DeviceControllerTest extends AbstractControllerTest { @Test public void testSaveDeviceWithUniquifyStrategy() throws Exception { Device device = new Device(); - device.setName("My device"); + device.setName("My unique device"); device.setType("default"); Device savedDevice = doPost("/api/device", device, Device.class); doPost("/api/device", device).andExpect(status().isBadRequest()); - doPost("/api/device?policy=FAIL", device).andExpect(status().isBadRequest()); + doPost("/api/device?nameConflictPolicy=FAIL", device).andExpect(status().isBadRequest()); + + Device secondDevice = doPost("/api/device?nameConflictPolicy=UNIQUIFY", device, Device.class); + assertThat(secondDevice.getName()).startsWith("My unique device_"); + + Device thirdDevice = doPost("/api/device?nameConflictPolicy=UNIQUIFY&uniquifySeparator=-", device, Device.class); + assertThat(thirdDevice.getName()).startsWith("My unique device-"); - Device secondDevice = doPost("/api/device?policy=UNIQUIFY", device, Device.class); - assertThat(secondDevice.getName()).startsWith("My device_"); + Device fourthDevice = doPost("/api/device?nameConflictPolicy=UNIQUIFY&uniquifyStrategy=INCREMENTAL", device, Device.class); + assertThat(fourthDevice.getName()).isEqualTo("My unique device_1"); - Device thirdDevice = doPost("/api/device?policy=UNIQUIFY&separator=-", device, Device.class); - assertThat(thirdDevice.getName()).startsWith("My device-"); + Device fifthDevice = doPost("/api/device?nameConflictPolicy=UNIQUIFY&uniquifyStrategy=INCREMENTAL", device, Device.class); + assertThat(fifthDevice.getName()).isEqualTo("My unique device_2"); } private Device createDevice(String name) { diff --git a/application/src/test/java/org/thingsboard/server/controller/EntityViewControllerTest.java b/application/src/test/java/org/thingsboard/server/controller/EntityViewControllerTest.java index 0549e17e43..167048a969 100644 --- a/application/src/test/java/org/thingsboard/server/controller/EntityViewControllerTest.java +++ b/application/src/test/java/org/thingsboard/server/controller/EntityViewControllerTest.java @@ -860,16 +860,22 @@ public class EntityViewControllerTest extends AbstractControllerTest { view.setEntityId(testDevice.getId()); view.setTenantId(tenantId); view.setType("default"); - view.setName("Test device view"); + view.setName("My unique view"); EntityView savedView = doPost("/api/entityView", view, EntityView.class); - doPost("/api/entityView?policy=FAIL", view).andExpect(status().isBadRequest()); + doPost("/api/entityView?nameConflictPolicy=FAIL", view).andExpect(status().isBadRequest()); - EntityView secondView = doPost("/api/entityView?policy=UNIQUIFY", view, EntityView.class); - assertThat(secondView.getName()).startsWith("Test device view_"); + EntityView secondView = doPost("/api/entityView?nameConflictPolicy=UNIQUIFY", view, EntityView.class); + assertThat(secondView.getName()).startsWith("My unique view_"); - EntityView thirdView = doPost("/api/entityView?policy=UNIQUIFY&separator=-", view, EntityView.class); - assertThat(thirdView.getName()).startsWith("Test device view-"); + EntityView thirdView = doPost("/api/entityView?nameConflictPolicy=UNIQUIFY&uniquifySeparator=-", view, EntityView.class); + assertThat(thirdView.getName()).startsWith("My unique view-"); + + EntityView fourthView = doPost("/api/entityView?nameConflictPolicy=UNIQUIFY&uniquifyStrategy=INCREMENTAL", view, EntityView.class); + assertThat(fourthView.getName()).isEqualTo("My unique view_1"); + + EntityView fifthEntityView = doPost("/api/entityView?nameConflictPolicy=UNIQUIFY&uniquifyStrategy=INCREMENTAL", view, EntityView.class); + assertThat(fifthEntityView.getName()).isEqualTo("My unique view_2"); } } diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/NameConflictStrategy.java b/common/data/src/main/java/org/thingsboard/server/common/data/NameConflictStrategy.java index 00b72f7223..9624b8c978 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/NameConflictStrategy.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/NameConflictStrategy.java @@ -18,8 +18,8 @@ package org.thingsboard.server.common.data; import io.swagger.v3.oas.annotations.media.Schema; @Schema -public record NameConflictStrategy(NameConflictPolicy policy, String separator) { +public record NameConflictStrategy(NameConflictPolicy policy, String separator, UniquifyStrategy uniquifyStrategy) { - public static final NameConflictStrategy DEFAULT = new NameConflictStrategy(NameConflictPolicy.FAIL, null); + public static final NameConflictStrategy DEFAULT = new NameConflictStrategy(NameConflictPolicy.FAIL, null, null); } diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/UniquifyStrategy.java b/common/data/src/main/java/org/thingsboard/server/common/data/UniquifyStrategy.java new file mode 100644 index 0000000000..5c9841f096 --- /dev/null +++ b/common/data/src/main/java/org/thingsboard/server/common/data/UniquifyStrategy.java @@ -0,0 +1,23 @@ +/** + * 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.common.data; + +public enum UniquifyStrategy { + + RANDOM, + INCREMENTAL; + +} diff --git a/dao/src/main/java/org/thingsboard/server/dao/Dao.java b/dao/src/main/java/org/thingsboard/server/dao/Dao.java index 6c1ab74764..4934059cd5 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/Dao.java +++ b/dao/src/main/java/org/thingsboard/server/dao/Dao.java @@ -33,7 +33,7 @@ public interface Dao { ListenableFuture findByIdAsync(TenantId tenantId, UUID id); - default EntityInfo findEntityInfoByName(TenantId tenantId, String name) { + default List findEntityInfosByNamePrefix(TenantId tenantId, String name) { throw new UnsupportedOperationException(); } diff --git a/dao/src/main/java/org/thingsboard/server/dao/entity/AbstractEntityService.java b/dao/src/main/java/org/thingsboard/server/dao/entity/AbstractEntityService.java index 5f83646fd4..bf3f0f346d 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/entity/AbstractEntityService.java +++ b/dao/src/main/java/org/thingsboard/server/dao/entity/AbstractEntityService.java @@ -51,8 +51,12 @@ import java.util.Collections; import java.util.List; import java.util.Map; import java.util.Optional; +import java.util.Set; import java.util.concurrent.TimeUnit; import java.util.function.Consumer; +import java.util.stream.Collectors; + +import static org.thingsboard.server.common.data.UniquifyStrategy.RANDOM; @Slf4j public abstract class AbstractEntityService { @@ -167,18 +171,23 @@ public abstract class AbstractEntityService { return now + TimeUnit.MINUTES.toMillis(DebugModeUtil.getMaxDebugAllDuration(tbTenantProfileCache.get(tenantId).getDefaultProfileConfiguration().getMaxDebugModeDurationMinutes(), defaultDebugDurationMinutes)); } - protected & HasTenantId & HasName> void uniquifyEntityName(E entity, E oldEntity, Consumer setName, EntityType entityType, NameConflictStrategy nameConflictStrategy) { + protected & HasTenantId & HasName> void uniquifyEntityName(E entity, E oldEntity, Consumer setName, EntityType entityType, NameConflictStrategy strategy) { Dao dao = entityDaoRegistry.getDao(entityType); - EntityInfo existingEntity = dao.findEntityInfoByName(entity.getTenantId(), entity.getName()); - if (existingEntity != null && (oldEntity == null || !existingEntity.getId().equals(oldEntity.getId()))) { - String suffix = StringUtils.randomAlphanumeric(6); + List existingEntities = dao.findEntityInfosByNamePrefix(entity.getTenantId(), entity.getName()); + Set existingNames = existingEntities.stream() + .filter(e -> (oldEntity == null || !e.getId().equals(oldEntity.getId()))) + .map(EntityInfo::getName) + .collect(Collectors.toSet()); + if (!existingNames.isEmpty()) { + int idx = 1; + String suffix = (strategy.uniquifyStrategy() == RANDOM) ? StringUtils.randomAlphanumeric(6) : String.valueOf(idx); while (true) { - String newName = entity.getName() + nameConflictStrategy.separator() + suffix; - if (dao.findEntityInfoByName(entity.getTenantId(), newName) == null) { + String newName = entity.getName() + strategy.separator() + suffix; + if (!existingNames.contains(newName)) { setName.accept(newName); break; } - suffix = StringUtils.randomAlphanumeric(6); + suffix = (strategy.uniquifyStrategy() == RANDOM) ? StringUtils.randomAlphanumeric(6) : String.valueOf(idx++); } } } diff --git a/dao/src/main/java/org/thingsboard/server/dao/entityview/EntityViewServiceImpl.java b/dao/src/main/java/org/thingsboard/server/dao/entityview/EntityViewServiceImpl.java index 6588e06e85..4c7269f17b 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/entityview/EntityViewServiceImpl.java +++ b/dao/src/main/java/org/thingsboard/server/dao/entityview/EntityViewServiceImpl.java @@ -124,16 +124,14 @@ public class EntityViewServiceImpl extends CachedVersionedEntityService { private final TenantService tenantService; private final CustomerDao customerDao; + @Override + protected void validateCreate(TenantId tenantId, EntityView entityView) { + entityViewDao.findEntityViewByTenantIdAndName(entityView.getTenantId().getId(), entityView.getName()) + .ifPresent(e -> { + throw new DataValidationException("Entity view with such name already exists!"); + }); + } + @Override protected EntityView validateUpdate(TenantId tenantId, EntityView entityView) { var opt = entityViewDao.findEntityViewByTenantIdAndName(entityView.getTenantId().getId(), entityView.getName()); diff --git a/dao/src/main/java/org/thingsboard/server/dao/sql/asset/AssetRepository.java b/dao/src/main/java/org/thingsboard/server/dao/sql/asset/AssetRepository.java index 0ac50432bb..aa9c29df49 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/sql/asset/AssetRepository.java +++ b/dao/src/main/java/org/thingsboard/server/dao/sql/asset/AssetRepository.java @@ -105,8 +105,8 @@ public interface AssetRepository extends JpaRepository, Expor AssetEntity findByTenantIdAndName(UUID tenantId, String name); @Query("SELECT new org.thingsboard.server.common.data.EntityInfo(a.id, 'ASSET', a.name) " + - "FROM AssetEntity a WHERE a.tenantId = :tenantId AND a.name = :name") - EntityInfo findEntityInfoByName(UUID tenantId, String name); + "FROM AssetEntity a WHERE a.tenantId = :tenantId AND a.name LIKE CONCAT(:prefix, '%')") + List findEntityInfosByNamePrefix(UUID tenantId, String prefix); @Query("SELECT a FROM AssetEntity a WHERE a.tenantId = :tenantId " + "AND a.type = :type " + diff --git a/dao/src/main/java/org/thingsboard/server/dao/sql/asset/JpaAssetDao.java b/dao/src/main/java/org/thingsboard/server/dao/sql/asset/JpaAssetDao.java index 123cd17a69..593a672d8a 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/sql/asset/JpaAssetDao.java +++ b/dao/src/main/java/org/thingsboard/server/dao/sql/asset/JpaAssetDao.java @@ -269,9 +269,9 @@ public class JpaAssetDao extends JpaAbstractDao implements A } @Override - public EntityInfo findEntityInfoByName(TenantId tenantId, String name) { - log.debug("Find asset entity info by name [{}]", name); - return assetRepository.findEntityInfoByName(tenantId.getId(), name); + public List findEntityInfosByNamePrefix(TenantId tenantId, String name) { + log.debug("Find asset entity infos by name [{}]", name); + return assetRepository.findEntityInfosByNamePrefix(tenantId.getId(), name); } @Override diff --git a/dao/src/main/java/org/thingsboard/server/dao/sql/customer/CustomerRepository.java b/dao/src/main/java/org/thingsboard/server/dao/sql/customer/CustomerRepository.java index ea6276f37d..9c196a5072 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/sql/customer/CustomerRepository.java +++ b/dao/src/main/java/org/thingsboard/server/dao/sql/customer/CustomerRepository.java @@ -43,8 +43,8 @@ public interface CustomerRepository extends JpaRepository, CustomerEntity findByTenantIdAndTitle(UUID tenantId, String title); @Query("SELECT new org.thingsboard.server.common.data.EntityInfo(a.id, 'CUSTOMER', a.title) " + - "FROM CustomerEntity a WHERE a.tenantId = :tenantId AND a.title = :name") - EntityInfo findEntityInfoByName(UUID tenantId, String name); + "FROM CustomerEntity a WHERE a.tenantId = :tenantId AND a.title LIKE CONCAT(:prefix, '%')") + List findEntityInfosByNamePrefix(UUID tenantId, String prefix); @Query(value = "SELECT * FROM customer c WHERE c.tenant_id = :tenantId " + "AND c.is_public IS TRUE ORDER BY c.id ASC LIMIT 1", nativeQuery = true) diff --git a/dao/src/main/java/org/thingsboard/server/dao/sql/customer/JpaCustomerDao.java b/dao/src/main/java/org/thingsboard/server/dao/sql/customer/JpaCustomerDao.java index b6827383b2..2e1d75a738 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/sql/customer/JpaCustomerDao.java +++ b/dao/src/main/java/org/thingsboard/server/dao/sql/customer/JpaCustomerDao.java @@ -119,8 +119,8 @@ public class JpaCustomerDao extends JpaAbstractDao imp } @Override - public EntityInfo findEntityInfoByName(TenantId tenantId, String name) { - return customerRepository.findEntityInfoByName(tenantId.getId(), name); + public List findEntityInfosByNamePrefix(TenantId tenantId, String name) { + return customerRepository.findEntityInfosByNamePrefix(tenantId.getId(), name); } @Override diff --git a/dao/src/main/java/org/thingsboard/server/dao/sql/device/DeviceRepository.java b/dao/src/main/java/org/thingsboard/server/dao/sql/device/DeviceRepository.java index 14e66826ba..a9ec9d1d36 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/sql/device/DeviceRepository.java +++ b/dao/src/main/java/org/thingsboard/server/dao/sql/device/DeviceRepository.java @@ -153,8 +153,8 @@ public interface DeviceRepository extends JpaRepository, Exp DeviceEntity findByTenantIdAndName(UUID tenantId, String name); @Query("SELECT new org.thingsboard.server.common.data.EntityInfo(a.id, 'DEVICE', a.name) " + - "FROM DeviceEntity a WHERE a.tenantId = :tenantId AND a.name = :name") - EntityInfo findEntityInfoByName(UUID tenantId, String name); + "FROM DeviceEntity a WHERE a.tenantId = :tenantId AND a.name LIKE CONCAT(:prefix, '%')") + List findEntityInfosByNamePrefix(UUID tenantId, String prefix); List findDevicesByTenantIdAndCustomerIdAndIdIn(UUID tenantId, UUID customerId, List deviceIds); diff --git a/dao/src/main/java/org/thingsboard/server/dao/sql/device/JpaDeviceDao.java b/dao/src/main/java/org/thingsboard/server/dao/sql/device/JpaDeviceDao.java index 5d70ee53e5..beb3b7c913 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/sql/device/JpaDeviceDao.java +++ b/dao/src/main/java/org/thingsboard/server/dao/sql/device/JpaDeviceDao.java @@ -116,8 +116,8 @@ public class JpaDeviceDao extends JpaAbstractDao implement } @Override - public EntityInfo findEntityInfoByName(TenantId tenantId, String name) { - return deviceRepository.findEntityInfoByName(tenantId.getId(), name); + public List findEntityInfosByNamePrefix(TenantId tenantId, String name) { + return deviceRepository.findEntityInfosByNamePrefix(tenantId.getId(), name); } @Override diff --git a/dao/src/main/java/org/thingsboard/server/dao/sql/entityview/EntityViewRepository.java b/dao/src/main/java/org/thingsboard/server/dao/sql/entityview/EntityViewRepository.java index 0e66850be8..9d51d024b8 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/sql/entityview/EntityViewRepository.java +++ b/dao/src/main/java/org/thingsboard/server/dao/sql/entityview/EntityViewRepository.java @@ -120,8 +120,8 @@ public interface EntityViewRepository extends JpaRepository findEntityInfosByNamePrefix(UUID tenantId, String prefix); List findAllByTenantIdAndEntityId(UUID tenantId, UUID entityId); diff --git a/dao/src/main/java/org/thingsboard/server/dao/sql/entityview/JpaEntityViewDao.java b/dao/src/main/java/org/thingsboard/server/dao/sql/entityview/JpaEntityViewDao.java index bd4f39162f..27400961ef 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/sql/entityview/JpaEntityViewDao.java +++ b/dao/src/main/java/org/thingsboard/server/dao/sql/entityview/JpaEntityViewDao.java @@ -232,8 +232,8 @@ public class JpaEntityViewDao extends JpaAbstractDao findEntityInfosByNamePrefix(TenantId tenantId, String name) { + return entityViewRepository.findEntityInfosByNamePrefix(tenantId.getId(), name); } @Override diff --git a/dao/src/main/resources/sql/schema-entities.sql b/dao/src/main/resources/sql/schema-entities.sql index b2033c079e..6ccf2f6d95 100644 --- a/dao/src/main/resources/sql/schema-entities.sql +++ b/dao/src/main/resources/sql/schema-entities.sql @@ -551,7 +551,6 @@ CREATE TABLE IF NOT EXISTS entity_view ( additional_info varchar, external_id uuid, version BIGINT DEFAULT 1, - CONSTRAINT entity_view_name_unq_key UNIQUE (tenant_id, name), CONSTRAINT entity_view_external_id_unq_key UNIQUE (tenant_id, external_id) ); From e8d888e22b2a5746bf2992ed288f8b8d55ec5697 Mon Sep 17 00:00:00 2001 From: Vladyslav_Prykhodko Date: Fri, 10 Oct 2025 18:35:41 +0300 Subject: [PATCH 27/43] UI: Add support name conflict strategy --- ui-ngx/src/app/core/http/asset.service.ts | 9 +++-- ui-ngx/src/app/core/http/customer.service.ts | 9 +++-- ui-ngx/src/app/core/http/device.service.ts | 18 ++++++---- .../src/app/core/http/entity-view.service.ts | 9 +++-- ui-ngx/src/app/core/http/http-utils.ts | 34 ++++++++++++++++--- .../interceptors/interceptor-http-params.ts | 2 +- ui-ngx/src/app/shared/models/device.models.ts | 6 +++- ui-ngx/src/app/shared/models/entity.models.ts | 16 +++++++++ 8 files changed, 81 insertions(+), 22 deletions(-) diff --git a/ui-ngx/src/app/core/http/asset.service.ts b/ui-ngx/src/app/core/http/asset.service.ts index a24be23593..0efeec3eef 100644 --- a/ui-ngx/src/app/core/http/asset.service.ts +++ b/ui-ngx/src/app/core/http/asset.service.ts @@ -15,7 +15,7 @@ /// import { Injectable } from '@angular/core'; -import { defaultHttpOptionsFromConfig, RequestConfig } from './http-utils'; +import { createDefaultHttpOptions, defaultHttpOptionsFromConfig, RequestConfig } from './http-utils'; import { Observable } from 'rxjs'; import { HttpClient } from '@angular/common/http'; import { PageLink } from '@shared/models/page/page-link'; @@ -23,6 +23,7 @@ import { PageData } from '@shared/models/page/page-data'; import { EntitySubtype } from '@shared/models/entity-type.models'; import { Asset, AssetInfo, AssetSearchQuery } from '@shared/models/asset.models'; import { BulkImportRequest, BulkImportResult } from '@shared/import-export/import-export.models'; +import { SaveEntityParams } from '@shared/models/entity.models'; @Injectable({ providedIn: 'root' @@ -69,8 +70,10 @@ export class AssetService { return this.http.get(`/api/asset/info/${assetId}`, defaultHttpOptionsFromConfig(config)); } - public saveAsset(asset: Asset, config?: RequestConfig): Observable { - return this.http.post('/api/asset', asset, defaultHttpOptionsFromConfig(config)); + public saveAsset(asset: Asset, config?: RequestConfig): Observable; + public saveAsset(asset: Asset, saveParams: SaveEntityParams, config?: RequestConfig): Observable; + public saveAsset(asset: Asset, saveParamsOrConfig?: SaveEntityParams | RequestConfig, config?: RequestConfig): Observable { + return this.http.post('/api/asset', asset, createDefaultHttpOptions(saveParamsOrConfig, config)); } public deleteAsset(assetId: string, config?: RequestConfig) { diff --git a/ui-ngx/src/app/core/http/customer.service.ts b/ui-ngx/src/app/core/http/customer.service.ts index faf78f6f75..ec8955ca4b 100644 --- a/ui-ngx/src/app/core/http/customer.service.ts +++ b/ui-ngx/src/app/core/http/customer.service.ts @@ -15,12 +15,13 @@ /// import { Injectable } from '@angular/core'; -import { defaultHttpOptionsFromConfig, RequestConfig } from './http-utils'; +import { createDefaultHttpOptions, defaultHttpOptionsFromConfig, RequestConfig } from './http-utils'; import { Observable } from 'rxjs'; import { HttpClient } from '@angular/common/http'; import { PageLink } from '@shared/models/page/page-link'; import { PageData } from '@shared/models/page/page-data'; import { Customer } from '@shared/models/customer.model'; +import { SaveEntityParams } from '@shared/models/entity.models'; @Injectable({ providedIn: 'root' @@ -40,8 +41,10 @@ export class CustomerService { return this.http.get(`/api/customer/${customerId}`, defaultHttpOptionsFromConfig(config)); } - public saveCustomer(customer: Customer, config?: RequestConfig): Observable { - return this.http.post('/api/customer', customer, defaultHttpOptionsFromConfig(config)); + public saveCustomer(customer: Customer, config?: RequestConfig): Observable; + public saveCustomer(customer: Customer, saveParams: SaveEntityParams, config?: RequestConfig): Observable; + public saveCustomer(customer: Customer, saveParamsOrConfig?: SaveEntityParams | RequestConfig, config?: RequestConfig): Observable { + return this.http.post('/api/customer', customer, createDefaultHttpOptions(saveParamsOrConfig, config)); } public deleteCustomer(customerId: string, config?: RequestConfig) { diff --git a/ui-ngx/src/app/core/http/device.service.ts b/ui-ngx/src/app/core/http/device.service.ts index 1e3a304774..69ed2f8a66 100644 --- a/ui-ngx/src/app/core/http/device.service.ts +++ b/ui-ngx/src/app/core/http/device.service.ts @@ -15,7 +15,7 @@ /// import { Injectable } from '@angular/core'; -import { defaultHttpOptionsFromConfig, RequestConfig } from './http-utils'; +import { createDefaultHttpOptions, defaultHttpOptionsFromConfig, RequestConfig } from './http-utils'; import { Observable, ReplaySubject } from 'rxjs'; import { HttpClient } from '@angular/common/http'; import { PageLink } from '@shared/models/page/page-link'; @@ -28,13 +28,15 @@ import { DeviceInfo, DeviceInfoQuery, DeviceSearchQuery, - PublishTelemetryCommand + PublishTelemetryCommand, + SaveDeviceParams } from '@shared/models/device.models'; import { EntitySubtype } from '@shared/models/entity-type.models'; import { AuthService } from '@core/auth/auth.service'; import { BulkImportRequest, BulkImportResult } from '@shared/import-export/import-export.models'; import { PersistentRpc, RpcStatus } from '@shared/models/rpc.models'; import { ResourcesService } from '@core/services/resources.service'; +import { SaveEntityParams } from '@shared/models/entity.models'; @Injectable({ providedIn: 'root' @@ -87,15 +89,19 @@ export class DeviceService { return this.http.get(`/api/device/info/${deviceId}`, defaultHttpOptionsFromConfig(config)); } - public saveDevice(device: Device, config?: RequestConfig): Observable { - return this.http.post('/api/device', device, defaultHttpOptionsFromConfig(config)); + public saveDevice(device: Device, config?: RequestConfig): Observable; + public saveDevice(device: Device, saveParams?: SaveDeviceParams, config?: RequestConfig): Observable; + public saveDevice(device: Device, saveParamsOrConfig?: SaveDeviceParams | RequestConfig, config?: RequestConfig): Observable { + return this.http.post('/api/device', device, createDefaultHttpOptions(saveParamsOrConfig, config)); } - public saveDeviceWithCredentials(device: Device, credentials: DeviceCredentials, config?: RequestConfig): Observable { + public saveDeviceWithCredentials(device: Device, credentials: DeviceCredentials, config?: RequestConfig): Observable; + public saveDeviceWithCredentials(device: Device, credentials: DeviceCredentials, saveParams: SaveEntityParams, config?: RequestConfig): Observable; + public saveDeviceWithCredentials(device: Device, credentials: DeviceCredentials, saveParamsOrConfig?: SaveEntityParams | RequestConfig, config?: RequestConfig): Observable { return this.http.post('/api/device-with-credentials', { device, credentials - }, defaultHttpOptionsFromConfig(config)); + }, createDefaultHttpOptions(saveParamsOrConfig, config)); } public deleteDevice(deviceId: string, config?: RequestConfig) { diff --git a/ui-ngx/src/app/core/http/entity-view.service.ts b/ui-ngx/src/app/core/http/entity-view.service.ts index ffd17d9a49..7285c5420f 100644 --- a/ui-ngx/src/app/core/http/entity-view.service.ts +++ b/ui-ngx/src/app/core/http/entity-view.service.ts @@ -15,13 +15,14 @@ /// import { Injectable } from '@angular/core'; -import { defaultHttpOptionsFromConfig, RequestConfig } from './http-utils'; +import { createDefaultHttpOptions, defaultHttpOptionsFromConfig, RequestConfig } from './http-utils'; import { Observable } from 'rxjs'; import { HttpClient } from '@angular/common/http'; import { PageLink } from '@shared/models/page/page-link'; import { PageData } from '@shared/models/page/page-data'; import { EntitySubtype } from '@app/shared/models/entity-type.models'; import { EntityView, EntityViewInfo, EntityViewSearchQuery } from '@app/shared/models/entity-view.models'; +import { SaveEntityParams } from '@shared/models/entity.models'; @Injectable({ providedIn: 'root' @@ -51,8 +52,10 @@ export class EntityViewService { return this.http.get(`/api/entityView/info/${entityViewId}`, defaultHttpOptionsFromConfig(config)); } - public saveEntityView(entityView: EntityView, config?: RequestConfig): Observable { - return this.http.post('/api/entityView', entityView, defaultHttpOptionsFromConfig(config)); + public saveEntityView(entityView: EntityView, config?: RequestConfig): Observable; + public saveEntityView(entityView: EntityView, saveParams: SaveEntityParams, config?: RequestConfig): Observable; + public saveEntityView(entityView: EntityView, saveParamsOrConfig?: SaveEntityParams | RequestConfig, config?: RequestConfig): Observable { + return this.http.post('/api/entityView', entityView, createDefaultHttpOptions(saveParamsOrConfig, config)); } public deleteEntityView(entityViewId: string, config?: RequestConfig) { diff --git a/ui-ngx/src/app/core/http/http-utils.ts b/ui-ngx/src/app/core/http/http-utils.ts index 787abcfbf3..ebd08f13fe 100644 --- a/ui-ngx/src/app/core/http/http-utils.ts +++ b/ui-ngx/src/app/core/http/http-utils.ts @@ -18,32 +18,56 @@ import { InterceptorHttpParams } from '../interceptors/interceptor-http-params'; import { HttpHeaders } from '@angular/common/http'; import { InterceptorConfig } from '../interceptors/interceptor-config'; +export type QueryParams = { [param:string]: any }; + export interface RequestConfig { ignoreLoading?: boolean; ignoreErrors?: boolean; resendRequest?: boolean; + queryParams?: QueryParams; +} + +export function hasRequestConfig(config?: any): boolean { + if (!config) { + return false; + } + return config.hasOwnProperty('ignoreLoading') || config.hasOwnProperty('ignoreErrors') || config.hasOwnProperty('resendRequest') || config.hasOwnProperty('queryParams'); +} + +export function createDefaultHttpOptions(queryParamsOrConfig?: QueryParams | RequestConfig, config?: RequestConfig) { + if (hasRequestConfig(queryParamsOrConfig)) { + return defaultHttpOptionsFromConfig(queryParamsOrConfig as RequestConfig); + } + const queryParams = queryParamsOrConfig as QueryParams; + const finalConfig = { + ...config, + ...(queryParams && { queryParams }), + }; + return defaultHttpOptionsFromConfig(finalConfig); } export function defaultHttpOptionsFromConfig(config?: RequestConfig) { if (!config) { config = {}; } - return defaultHttpOptions(config.ignoreLoading, config.ignoreErrors, config.resendRequest); + return defaultHttpOptions(config.ignoreLoading, config.ignoreErrors, config.resendRequest, config.queryParams); } export function defaultHttpOptions(ignoreLoading: boolean = false, ignoreErrors: boolean = false, - resendRequest: boolean = false) { + resendRequest: boolean = false, + queryParams?: QueryParams) { return { headers: new HttpHeaders({'Content-Type': 'application/json'}), - params: new InterceptorHttpParams(new InterceptorConfig(ignoreLoading, ignoreErrors, resendRequest)) + params: new InterceptorHttpParams(new InterceptorConfig(ignoreLoading, ignoreErrors, resendRequest), queryParams) }; } export function defaultHttpUploadOptions(ignoreLoading: boolean = false, ignoreErrors: boolean = false, - resendRequest: boolean = false) { + resendRequest: boolean = false, + queryParams?: QueryParams) { return { - params: new InterceptorHttpParams(new InterceptorConfig(ignoreLoading, ignoreErrors, resendRequest)) + params: new InterceptorHttpParams(new InterceptorConfig(ignoreLoading, ignoreErrors, resendRequest), queryParams) }; } diff --git a/ui-ngx/src/app/core/interceptors/interceptor-http-params.ts b/ui-ngx/src/app/core/interceptors/interceptor-http-params.ts index 1d9cbdddaa..ab75102464 100644 --- a/ui-ngx/src/app/core/interceptors/interceptor-http-params.ts +++ b/ui-ngx/src/app/core/interceptors/interceptor-http-params.ts @@ -20,7 +20,7 @@ import { InterceptorConfig } from './interceptor-config'; export class InterceptorHttpParams extends HttpParams { constructor( public interceptorConfig: InterceptorConfig, - params?: { [param: string]: string | string[] } + params?: { [param: string]: string | number | boolean | ReadonlyArray; } ) { super({ fromObject: params }); } diff --git a/ui-ngx/src/app/shared/models/device.models.ts b/ui-ngx/src/app/shared/models/device.models.ts index 8298d3a1fe..c2b95037a4 100644 --- a/ui-ngx/src/app/shared/models/device.models.ts +++ b/ui-ngx/src/app/shared/models/device.models.ts @@ -22,7 +22,7 @@ import { DeviceCredentialsId } from '@shared/models/id/device-credentials-id'; import { EntitySearchQuery } from '@shared/models/relation.models'; import { DeviceProfileId } from '@shared/models/id/device-profile-id'; import { RuleChainId } from '@shared/models/id/rule-chain-id'; -import { EntityInfoData, HasTenantId, HasVersion } from '@shared/models/entity.models'; +import { EntityInfoData, HasTenantId, HasVersion, SaveEntityParams } from '@shared/models/entity.models'; import { FilterPredicateValue, KeyFilter } from '@shared/models/query/query.models'; import { TimeUnit } from '@shared/models/time/time.models'; import _moment from 'moment'; @@ -739,6 +739,10 @@ export interface DeviceInfoFilter { active?: boolean; } +export interface SaveDeviceParams extends SaveEntityParams { + accessToken?: string; +} + export class DeviceInfoQuery { pageLink: PageLink; diff --git a/ui-ngx/src/app/shared/models/entity.models.ts b/ui-ngx/src/app/shared/models/entity.models.ts index 5aa526b583..54b5e98df3 100644 --- a/ui-ngx/src/app/shared/models/entity.models.ts +++ b/ui-ngx/src/app/shared/models/entity.models.ts @@ -209,3 +209,19 @@ export interface EntityTestScriptResult { } export type VersionedEntity = EntityInfoData & HasVersion | RuleChainMetaData; + +export enum NameConflictPolicy { + FAIL = 'FAIL', + UNIQUIFY = 'UNIQUIFY', +} + +export enum UniquifyStrategy { + RANDOM = 'RANDOM', + INCREMENTAL = 'INCREMENTAL' +} + +export interface SaveEntityParams { + nameConflictPolicy?: NameConflictPolicy; + uniquifyStrategy?: UniquifyStrategy; + uniquifySeparator?: string; +} From 03077c2bc0201710225b899d26057a81cd0389a7 Mon Sep 17 00:00:00 2001 From: dashevchenko Date: Mon, 13 Oct 2025 12:30:46 +0300 Subject: [PATCH 28/43] moved name update before validation --- .../server/dao/asset/BaseAssetService.java | 12 +++++------- .../server/dao/customer/CustomerServiceImpl.java | 10 +++++----- .../server/dao/device/DeviceServiceImpl.java | 12 +++++------- .../server/dao/entityview/EntityViewServiceImpl.java | 2 +- 4 files changed, 16 insertions(+), 20 deletions(-) diff --git a/dao/src/main/java/org/thingsboard/server/dao/asset/BaseAssetService.java b/dao/src/main/java/org/thingsboard/server/dao/asset/BaseAssetService.java index ad6adee1ee..5f8cbe7064 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/asset/BaseAssetService.java +++ b/dao/src/main/java/org/thingsboard/server/dao/asset/BaseAssetService.java @@ -160,11 +160,12 @@ public class BaseAssetService extends AbstractCachedEntityService Date: Mon, 13 Oct 2025 12:54:10 +0300 Subject: [PATCH 29/43] improved UNIQUIFY_STRATEGY_DESC description --- .../org/thingsboard/server/controller/ControllerConstants.java | 1 + 1 file changed, 1 insertion(+) 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 9e533f6d6e..276d2f615c 100644 --- a/application/src/main/java/org/thingsboard/server/controller/ControllerConstants.java +++ b/application/src/main/java/org/thingsboard/server/controller/ControllerConstants.java @@ -1755,6 +1755,7 @@ public class ControllerConstants { public static final String UNIQUIFY_STRATEGY_DESC = "Optional value of uniquify strategy used by UNIQUIFY policy. Possible values: RANDOM or INCREMENTAL. " + "By default, RANDOM strategy is used, which means random alphanumeric string will be added as a suffix to entity name. " + + "INCREMENTAL implies the first possible number starting from 1 will be added as a name suffix. " + "For example, strategy is UNIQUIFY, uniquify strategy is INCREMENTAL; if a name conflict occurs for entity name 'test-name', " + "created entity will have name like 'test-name-1."; } From 374b6cf22f427f76080cf50e112b925bd865589f Mon Sep 17 00:00:00 2001 From: dashevchenko Date: Mon, 13 Oct 2025 12:57:21 +0300 Subject: [PATCH 30/43] import cleanup --- application/src/main/data/upgrade/basic/schema_update.sql | 1 - .../server/service/device/DeviceBulkImportService.java | 2 -- 2 files changed, 3 deletions(-) diff --git a/application/src/main/data/upgrade/basic/schema_update.sql b/application/src/main/data/upgrade/basic/schema_update.sql index 0e7b0d9455..0add4c0545 100644 --- a/application/src/main/data/upgrade/basic/schema_update.sql +++ b/application/src/main/data/upgrade/basic/schema_update.sql @@ -46,4 +46,3 @@ WHERE NOT ( ); -- UPDATE TENANT PROFILE CONFIGURATION END - diff --git a/application/src/main/java/org/thingsboard/server/service/device/DeviceBulkImportService.java b/application/src/main/java/org/thingsboard/server/service/device/DeviceBulkImportService.java index 7bd0dc06ea..d042fb2657 100644 --- a/application/src/main/java/org/thingsboard/server/service/device/DeviceBulkImportService.java +++ b/application/src/main/java/org/thingsboard/server/service/device/DeviceBulkImportService.java @@ -29,8 +29,6 @@ import org.thingsboard.server.common.data.DeviceProfileProvisionType; import org.thingsboard.server.common.data.DeviceProfileType; import org.thingsboard.server.common.data.DeviceTransportType; import org.thingsboard.server.common.data.EntityType; -import org.thingsboard.server.common.data.NameConflictPolicy; -import org.thingsboard.server.common.data.NameConflictStrategy; import org.thingsboard.server.common.data.StringUtils; import org.thingsboard.server.common.data.device.credentials.BasicMqttCredentials; import org.thingsboard.server.common.data.device.credentials.lwm2m.LwM2MClientCredential; From 2f3ce3c323e8e4f9c3b5dea97b95ea0dcecea3e9 Mon Sep 17 00:00:00 2001 From: VIacheslavKlimov Date: Tue, 14 Oct 2025 11:31:02 +0300 Subject: [PATCH 31/43] Fix AiModelEdgeTest --- .../test/java/org/thingsboard/server/edge/AiModelEdgeTest.java | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/application/src/test/java/org/thingsboard/server/edge/AiModelEdgeTest.java b/application/src/test/java/org/thingsboard/server/edge/AiModelEdgeTest.java index 66a681e7b7..30c8448f5b 100644 --- a/application/src/test/java/org/thingsboard/server/edge/AiModelEdgeTest.java +++ b/application/src/test/java/org/thingsboard/server/edge/AiModelEdgeTest.java @@ -24,7 +24,6 @@ import org.thingsboard.common.util.JacksonUtil; import org.thingsboard.server.common.data.ai.AiModel; import org.thingsboard.server.common.data.ai.model.chat.OpenAiChatModelConfig; import org.thingsboard.server.common.data.ai.provider.OpenAiProviderConfig; -import org.thingsboard.server.common.data.id.TenantId; import org.thingsboard.server.dao.service.DaoSqlTest; import org.thingsboard.server.gen.edge.v1.AiModelUpdateMsg; import org.thingsboard.server.gen.edge.v1.UpdateMsgType; @@ -152,7 +151,7 @@ public class AiModelEdgeTest extends AbstractEdgeTest { aiModel.setTenantId(tenantId); aiModel.setName(name); aiModel.setConfiguration(OpenAiChatModelConfig.builder() - .providerConfig(new OpenAiProviderConfig("test-api-key")) + .providerConfig(new OpenAiProviderConfig(null, "test-api-key")) .modelId("gpt-4o") .temperature(0.5) .topP(0.3) From 3fa9b877ab71309d14217bc2efdd7cafde3cd457 Mon Sep 17 00:00:00 2001 From: dashevchenko Date: Tue, 14 Oct 2025 15:57:24 +0300 Subject: [PATCH 32/43] deleted redundant constraint check, refactoring --- .../dao/entity/AbstractEntityService.java | 29 ++++++++++++------- .../dao/entityview/EntityViewServiceImpl.java | 3 +- 2 files changed, 19 insertions(+), 13 deletions(-) diff --git a/dao/src/main/java/org/thingsboard/server/dao/entity/AbstractEntityService.java b/dao/src/main/java/org/thingsboard/server/dao/entity/AbstractEntityService.java index bf3f0f346d..366aa67b63 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/entity/AbstractEntityService.java +++ b/dao/src/main/java/org/thingsboard/server/dao/entity/AbstractEntityService.java @@ -178,18 +178,25 @@ public abstract class AbstractEntityService { .filter(e -> (oldEntity == null || !e.getId().equals(oldEntity.getId()))) .map(EntityInfo::getName) .collect(Collectors.toSet()); - if (!existingNames.isEmpty()) { - int idx = 1; - String suffix = (strategy.uniquifyStrategy() == RANDOM) ? StringUtils.randomAlphanumeric(6) : String.valueOf(idx); - while (true) { - String newName = entity.getName() + strategy.separator() + suffix; - if (!existingNames.contains(newName)) { - setName.accept(newName); - break; - } - suffix = (strategy.uniquifyStrategy() == RANDOM) ? StringUtils.randomAlphanumeric(6) : String.valueOf(idx++); - } + + if (existingNames.contains(entity.getName())) { + String uniqueName = generateUniqueName(entity.getName(), existingNames, strategy); + setName.accept(uniqueName); } } + private String generateUniqueName(String baseName, Set existingNames, NameConflictStrategy strategy) { + String newName; + int index = 1; + String separator = strategy.separator(); + boolean isRandom = strategy.uniquifyStrategy() == RANDOM; + + do { + String suffix = isRandom ? StringUtils.randomAlphanumeric(6) : String.valueOf(index++); + newName = baseName + separator + suffix; + } while (existingNames.contains(newName)); + + return newName; + } + } diff --git a/dao/src/main/java/org/thingsboard/server/dao/entityview/EntityViewServiceImpl.java b/dao/src/main/java/org/thingsboard/server/dao/entityview/EntityViewServiceImpl.java index 88d87f4227..5c836a11c2 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/entityview/EntityViewServiceImpl.java +++ b/dao/src/main/java/org/thingsboard/server/dao/entityview/EntityViewServiceImpl.java @@ -139,8 +139,7 @@ public class EntityViewServiceImpl extends CachedVersionedEntityService Date: Tue, 14 Oct 2025 16:36:34 +0300 Subject: [PATCH 33/43] UI: Refactoring calculate fields component to support new type --- .../calculated-field.module.ts | 64 +++++ .../calculated-fields-table-config.ts | 8 +- .../calculated-field-dialog.component.html | 195 ++------------- .../calculated-field-dialog.component.ts | 226 +----------------- ...eofencing-zone-groups-panel.component.html | 0 ...eofencing-zone-groups-panel.component.scss | 0 ...-geofencing-zone-groups-panel.component.ts | 0 ...eofencing-zone-groups-table.component.html | 0 ...eofencing-zone-groups-table.component.scss | 0 ...-geofencing-zone-groups-table.component.ts | 8 +- .../geofencing-configuration.component.html | 68 ++++++ .../geofencing-configuration.component.ts | 157 ++++++++++++ .../geofencing-configuration.module.ts | 52 ++++ .../output/caclculate-field-output.module.ts | 36 +++ .../calculated-field-output.component.html | 86 +++++++ .../calculated-field-output.component.ts | 148 ++++++++++++ .../components/public-api.ts | 2 - ...ulated-field-argument-panel.component.html | 0 ...ulated-field-argument-panel.component.scss | 0 ...lculated-field-argument-panel.component.ts | 0 ...lated-field-arguments-table.component.html | 0 ...lated-field-arguments-table.component.scss | 0 ...culated-field-arguments-table.component.ts | 4 +- .../simple-configuration.component.html | 98 ++++++++ .../simple-configuration.component.ts | 205 ++++++++++++++++ .../simple-configuration.module.ts | 48 ++++ .../home/components/home-components.module.ts | 41 ---- .../asset-profile/asset-profile.module.ts | 4 +- .../modules/home/pages/asset/asset.module.ts | 4 +- .../device-profile/device-profile.module.ts | 2 + .../home/pages/device/device.module.ts | 2 + .../components/time-unit-input.component.ts | 2 +- .../shared/models/calculated-field.models.ts | 55 ++++- .../assets/locale/locale.constant-en_US.json | 3 +- 34 files changed, 1063 insertions(+), 455 deletions(-) create mode 100644 ui-ngx/src/app/modules/home/components/calculated-fields/calculated-field.module.ts rename ui-ngx/src/app/modules/home/components/calculated-fields/components/{panel => geofencing-configuration}/calculated-field-geofencing-zone-groups-panel.component.html (100%) rename ui-ngx/src/app/modules/home/components/calculated-fields/components/{panel => geofencing-configuration}/calculated-field-geofencing-zone-groups-panel.component.scss (100%) rename ui-ngx/src/app/modules/home/components/calculated-fields/components/{panel => geofencing-configuration}/calculated-field-geofencing-zone-groups-panel.component.ts (100%) rename ui-ngx/src/app/modules/home/components/calculated-fields/components/{geofencing-zone-grups-table => geofencing-configuration}/calculated-field-geofencing-zone-groups-table.component.html (100%) rename ui-ngx/src/app/modules/home/components/calculated-fields/components/{geofencing-zone-grups-table => geofencing-configuration}/calculated-field-geofencing-zone-groups-table.component.scss (100%) rename ui-ngx/src/app/modules/home/components/calculated-fields/components/{geofencing-zone-grups-table => geofencing-configuration}/calculated-field-geofencing-zone-groups-table.component.ts (97%) create mode 100644 ui-ngx/src/app/modules/home/components/calculated-fields/components/geofencing-configuration/geofencing-configuration.component.html create mode 100644 ui-ngx/src/app/modules/home/components/calculated-fields/components/geofencing-configuration/geofencing-configuration.component.ts create mode 100644 ui-ngx/src/app/modules/home/components/calculated-fields/components/geofencing-configuration/geofencing-configuration.module.ts create mode 100644 ui-ngx/src/app/modules/home/components/calculated-fields/components/output/caclculate-field-output.module.ts create mode 100644 ui-ngx/src/app/modules/home/components/calculated-fields/components/output/calculated-field-output.component.html create mode 100644 ui-ngx/src/app/modules/home/components/calculated-fields/components/output/calculated-field-output.component.ts rename ui-ngx/src/app/modules/home/components/calculated-fields/components/{panel => simple-configuration}/calculated-field-argument-panel.component.html (100%) rename ui-ngx/src/app/modules/home/components/calculated-fields/components/{panel => simple-configuration}/calculated-field-argument-panel.component.scss (100%) rename ui-ngx/src/app/modules/home/components/calculated-fields/components/{panel => simple-configuration}/calculated-field-argument-panel.component.ts (100%) rename ui-ngx/src/app/modules/home/components/calculated-fields/components/{arguments-table => simple-configuration}/calculated-field-arguments-table.component.html (100%) rename ui-ngx/src/app/modules/home/components/calculated-fields/components/{arguments-table => simple-configuration}/calculated-field-arguments-table.component.scss (100%) rename ui-ngx/src/app/modules/home/components/calculated-fields/components/{arguments-table => simple-configuration}/calculated-field-arguments-table.component.ts (98%) create mode 100644 ui-ngx/src/app/modules/home/components/calculated-fields/components/simple-configuration/simple-configuration.component.html create mode 100644 ui-ngx/src/app/modules/home/components/calculated-fields/components/simple-configuration/simple-configuration.component.ts create mode 100644 ui-ngx/src/app/modules/home/components/calculated-fields/components/simple-configuration/simple-configuration.module.ts diff --git a/ui-ngx/src/app/modules/home/components/calculated-fields/calculated-field.module.ts b/ui-ngx/src/app/modules/home/components/calculated-fields/calculated-field.module.ts new file mode 100644 index 0000000000..9cccf28305 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/calculated-fields/calculated-field.module.ts @@ -0,0 +1,64 @@ +/// +/// 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 { NgModule } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { SharedModule } from '@shared/shared.module'; +import { CalculatedFieldsTableComponent } from '@home/components/calculated-fields/calculated-fields-table.component'; +import { + CalculatedFieldDialogComponent +} from '@home/components/calculated-fields/components/dialog/calculated-field-dialog.component'; +import { + CalculatedFieldDebugDialogComponent +} from '@home/components/calculated-fields/components/debug-dialog/calculated-field-debug-dialog.component'; +import { + CalculatedFieldScriptTestDialogComponent +} from '@home/components/calculated-fields/components/test-dialog/calculated-field-script-test-dialog.component'; +import { + CalculatedFieldTestArgumentsComponent +} from '@home/components/calculated-fields/components/test-arguments/calculated-field-test-arguments.component'; +import { + EntityDebugSettingsButtonComponent +} from '@home/components/entity/debug/entity-debug-settings-button.component'; +import { HomeComponentsModule } from '@home/components/home-components.module'; +import { + GeofencingConfigurationModule +} from '@home/components/calculated-fields/components/geofencing-configuration/geofencing-configuration.module'; +import { + SimpleConfigurationModule +} from '@home/components/calculated-fields/components/simple-configuration/simple-configuration.module'; + +@NgModule({ + declarations: [ + CalculatedFieldsTableComponent, + CalculatedFieldDialogComponent, + CalculatedFieldDebugDialogComponent, + CalculatedFieldScriptTestDialogComponent, + CalculatedFieldTestArgumentsComponent, + ], + imports: [ + CommonModule, + SharedModule, + GeofencingConfigurationModule, + EntityDebugSettingsButtonComponent, + HomeComponentsModule, + SimpleConfigurationModule + ], + exports: [ + CalculatedFieldsTableComponent, + ] +}) +export class CalculatedFieldsModule {} diff --git a/ui-ngx/src/app/modules/home/components/calculated-fields/calculated-fields-table-config.ts b/ui-ngx/src/app/modules/home/components/calculated-fields/calculated-fields-table-config.ts index 5f4b894448..1105365347 100644 --- a/ui-ngx/src/app/modules/home/components/calculated-fields/calculated-fields-table-config.ts +++ b/ui-ngx/src/app/modules/home/components/calculated-fields/calculated-fields-table-config.ts @@ -158,9 +158,10 @@ export class CalculatedFieldsTableConfig extends EntityTableConfig> { @@ -287,6 +288,9 @@ export class CalculatedFieldsTableConfig extends EntityTableConfig { + if (calculatedField.type === CalculatedFieldType.GEOFENCING || calculatedField.type === CalculatedFieldType.SIMPLE) { + return of(null); + } const resultArguments = Object.keys(calculatedField.configuration.arguments).reduce((acc, key) => { const type = calculatedField.configuration.arguments[key].refEntityKey.type; acc[key] = isObject(argumentsObj) && argumentsObj.hasOwnProperty(key) 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 e235b66fa7..222c3ffe91 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 @@ -62,187 +62,22 @@ - - @if (fieldFormGroup.get('type').value !== CalculatedFieldType.GEOFENCING) { -
-
{{ 'calculated-fields.arguments' | translate }}
- -
-
-
- {{ (fieldFormGroup.get('type').value === CalculatedFieldType.SIMPLE ? 'calculated-fields.expression' : 'calculated-fields.type.script' ) | translate }} -
- - -
-
- @if (configFormGroup.get('expressionSIMPLE').errors && configFormGroup.get('expressionSIMPLE').touched) { - - @if (configFormGroup.get('expressionSIMPLE').hasError('required')) { - {{ 'calculated-fields.hint.expression-required' | translate }} - } @else if (configFormGroup.get('expressionSIMPLE').hasError('pattern')) { - {{ 'calculated-fields.hint.expression-invalid' | translate }} - } @else if (configFormGroup.get('expressionSIMPLE').hasError('maxLength')) { - {{ 'calculated-fields.hint.expression-max-length' | translate }} - } - - } @else { - {{ 'calculated-fields.hint.expression' | translate }} - } -
-
- -
{{ 'api-usage.tbel' | translate }}
- -
-
- -
-
-
- } @else { -
-
- {{ 'calculated-fields.entity-coordinates' | translate }} -
-
- - -
-
- -
-
- {{ 'calculated-fields.geofencing-zone-groups' | translate }} -
- -
- -
- {{ 'calculated-fields.zone-group-refresh-interval' | translate }} -
-
-
- - -
-
-
+ @switch (fieldFormGroup.get('type').value) { + @case (CalculatedFieldType.GEOFENCING) { + + } -
-
{{ 'calculated-fields.output' | translate }}
-
- - {{ 'calculated-fields.output-type' | translate }} - - @for (type of outputTypes; track type) { - {{ OutputTypeTranslations.get(type) | translate}} - } - - - @if (outputFormGroup.get('type').value === OutputType.Attribute - && (data.entityId.entityType === EntityType.DEVICE || data.entityId.entityType === EntityType.DEVICE_PROFILE)) { - - {{ 'calculated-fields.attribute-scope' | translate }} - - - {{ 'calculated-fields.server-attributes' | translate }} - - - {{ 'calculated-fields.shared-attributes' | translate }} - - - - } -
- @if (fieldFormGroup.get('type').value === CalculatedFieldType.SIMPLE) { -
- - - {{ (outputFormGroup.get('type').value === OutputType.Timeseries - ? 'calculated-fields.timeseries-key' - : 'calculated-fields.attribute-key') - | translate }} - - - @if (outputFormGroup.get('name').errors && outputFormGroup.get('name').touched) { - - @if (outputFormGroup.get('name').hasError('required')) { - {{ 'common.hint.key-required' | translate }} - } @else if (outputFormGroup.get('name').hasError('pattern')) { - {{ 'common.hint.key-pattern' | translate }} - } @else if (outputFormGroup.get('name').hasError('maxlength')) { - {{ 'common.hint.key-max-length' | translate }} - } - - } - - - {{ 'calculated-fields.decimals-by-default' | translate }} - - @if (outputFormGroup.get('decimalsByDefault').errors && outputFormGroup.get('decimalsByDefault').touched) { - {{ 'calculated-fields.hint.decimals-range' | translate }} - } - -
-
- -
- calculated-fields.use-latest-timestamp -
-
-
- } -
-
+ @default { + + + } + }
diff --git a/ui-ngx/src/app/modules/home/components/calculated-fields/components/dialog/calculated-field-dialog.component.ts b/ui-ngx/src/app/modules/home/components/calculated-fields/components/dialog/calculated-field-dialog.component.ts index 8cca16d4ef..cf475111c6 100644 --- a/ui-ngx/src/app/modules/home/components/calculated-fields/components/dialog/calculated-field-dialog.component.ts +++ b/ui-ngx/src/app/modules/home/components/calculated-fields/components/dialog/calculated-field-dialog.component.ts @@ -18,36 +18,25 @@ import { Component, DestroyRef, Inject, ViewEncapsulation } from '@angular/core' import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog'; import { Store } from '@ngrx/store'; import { AppState } from '@core/core.state'; -import { FormBuilder, FormGroup, Validators } from '@angular/forms'; +import { FormBuilder, Validators } from '@angular/forms'; import { Router } from '@angular/router'; import { DialogComponent } from '@shared/components/dialog.component'; import { - ArgumentEntityType, CalculatedField, CalculatedFieldConfiguration, - calculatedFieldDefaultScript, - CalculatedFieldGeofencing, CalculatedFieldTestScriptFn, CalculatedFieldType, - CalculatedFieldTypeTranslations, - getCalculatedFieldArgumentsEditorCompleter, - getCalculatedFieldArgumentsHighlights, - getCalculatedFieldCurrentEntityFilter, - OutputType, - OutputTypeTranslations + CalculatedFieldTypeTranslations } from '@shared/models/calculated-field.models'; -import { digitsRegex, oneSpaceInsideRegex } from '@shared/models/regex.constants'; -import { AttributeScope, DataKeyType } from '@shared/models/telemetry/telemetry.models'; +import { oneSpaceInsideRegex } from '@shared/models/regex.constants'; import { EntityType } from '@shared/models/entity-type.models'; -import { map, startWith, switchMap } from 'rxjs/operators'; +import { switchMap } from 'rxjs/operators'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; -import { ScriptLanguage } from '@shared/models/rule-node.models'; import { CalculatedFieldsService } from '@core/http/calculated-fields.service'; import { Observable } from 'rxjs'; import { EntityId } from '@shared/models/id/entity-id'; import { AdditionalDebugActionConfig } from '@home/components/entity/debug/entity-debug-settings.model'; -import { EntityFilter } from '@shared/models/query/query.models'; -import { getCurrentAuthState } from '@core/auth/auth.selectors'; +import { deepTrim } from '@core/utils'; export interface CalculatedFieldDialogData { value?: CalculatedField; @@ -68,70 +57,22 @@ export interface CalculatedFieldDialogData { }) export class CalculatedFieldDialogComponent extends DialogComponent { - readonly minAllowedScheduledUpdateIntervalInSecForCF = getCurrentAuthState(this.store).minAllowedScheduledUpdateIntervalInSecForCF; - fieldFormGroup = this.fb.group({ name: ['', [Validators.required, Validators.pattern(oneSpaceInsideRegex), Validators.maxLength(255)]], type: [CalculatedFieldType.SIMPLE], debugSettings: [], - configuration: this.fb.group({ - entityCoordinates: this.fb.group({ - latitudeKeyName: [null, [Validators.required]], - longitudeKeyName: [null, [Validators.required]], - }), - arguments: this.fb.control({}), - zoneGroups: this.fb.control({}), - scheduledUpdateEnabled: [true], - scheduledUpdateInterval: [this.minAllowedScheduledUpdateIntervalInSecForCF], - expressionSIMPLE: ['', [Validators.required, Validators.pattern(oneSpaceInsideRegex), Validators.maxLength(255)]], - expressionSCRIPT: [calculatedFieldDefaultScript], - output: this.fb.group({ - name: ['', [Validators.required, Validators.pattern(oneSpaceInsideRegex), Validators.maxLength(255)]], - scope: [{ value: AttributeScope.SERVER_SCOPE, disabled: true }], - type: [OutputType.Timeseries], - decimalsByDefault: [null as number, [Validators.min(0), Validators.max(15), Validators.pattern(digitsRegex)]], - }), - useLatestTs: [false] - }), + configuration: this.fb.control({} as CalculatedFieldConfiguration), }); - functionArgs$ = this.configFormGroup.get('arguments').valueChanges - .pipe( - startWith(this.data.value?.configuration?.arguments ?? {}), - map(argumentsObj => ['ctx', ...Object.keys(argumentsObj)]) - ); - - argumentsEditorCompleter$ = this.configFormGroup.get('arguments').valueChanges - .pipe( - startWith(this.data.value?.configuration?.arguments ?? {}), - map(argumentsObj => getCalculatedFieldArgumentsEditorCompleter(argumentsObj)) - ); - - argumentsHighlightRules$ = this.configFormGroup.get('arguments').valueChanges - .pipe( - startWith(this.data.value?.configuration?.arguments ?? {}), - map(argumentsObj => getCalculatedFieldArgumentsHighlights(argumentsObj)) - ); - additionalDebugActionConfig = this.data.value?.id ? { ...this.data.additionalDebugActionConfig, action: () => this.data.additionalDebugActionConfig.action({ id: this.data.value.id, ...this.fromGroupValue }), } : null; - currentEntityFilter: EntityFilter; - - isRelatedEntity: boolean; - - readonly OutputTypeTranslations = OutputTypeTranslations; - readonly OutputType = OutputType; - readonly AttributeScope = AttributeScope; readonly EntityType = EntityType; readonly CalculatedFieldType = CalculatedFieldType; - readonly ScriptLanguage = ScriptLanguage; readonly fieldTypes = Object.values(CalculatedFieldType) as CalculatedFieldType[]; - readonly outputTypes = Object.values(OutputType) as OutputType[]; readonly CalculatedFieldTypeTranslations = CalculatedFieldTypeTranslations; - readonly DataKeyType = DataKeyType; constructor(protected store: Store, protected router: Router, @@ -143,48 +84,10 @@ export class CalculatedFieldDialogComponent extends DialogComponent { const calculatedFieldId = this.data.value?.id?.id; - let testScriptDialogResult$: Observable; - if (calculatedFieldId) { - testScriptDialogResult$ = this.calculatedFieldsService.getLatestCalculatedFieldDebugEvent(calculatedFieldId) + return this.calculatedFieldsService.getLatestCalculatedFieldDebugEvent(calculatedFieldId) .pipe( switchMap(event => { const args = event?.arguments ? JSON.parse(event.arguments) : null; @@ -212,114 +113,13 @@ export class CalculatedFieldDialogComponent extends DialogComponent { - this.configFormGroup.get('expressionSCRIPT').setValue(expression); - this.configFormGroup.get('expressionSCRIPT').markAsDirty(); - }); + return this.data.getTestScriptDialogFn(this.fromGroupValue, null, false); } private applyDialogData(): void { - const { configuration = {}, type = CalculatedFieldType.SIMPLE, debugSettings = { failuresEnabled: true, allEnabled: true }, ...value } = this.data.value ?? {}; - const { expression, ...restConfig } = configuration as CalculatedFieldConfiguration; - const updatedConfig = { ...restConfig , ['expression'+type]: expression }; - this.fieldFormGroup.patchValue({ configuration: updatedConfig, type, debugSettings, ...value }, {emitEvent: false}); - } - - private observeTypeChanges(): void { - this.toggleKeyByCalculatedFieldType(this.fieldFormGroup.get('type').value); - this.toggleScopeByOutputType(this.outputFormGroup.get('type').value); - - this.outputFormGroup.get('type').valueChanges - .pipe(takeUntilDestroyed()) - .subscribe(type => this.toggleScopeByOutputType(type)); - this.fieldFormGroup.get('type').valueChanges - .pipe(takeUntilDestroyed()) - .subscribe(type => this.toggleKeyByCalculatedFieldType(type)); - } - - private observeZoneChanges(): void { - this.configFormGroup.get('zoneGroups').valueChanges - .pipe(takeUntilDestroyed()) - .subscribe((zoneGroups: CalculatedFieldGeofencing) => - this.checkRelatedEntity(zoneGroups) - ); - this.checkRelatedEntity(this.configFormGroup.get('zoneGroups').value); - } - - private observeScheduledUpdateEnabled(): void { - this.configFormGroup.get('scheduledUpdateEnabled').valueChanges - .pipe(takeUntilDestroyed()) - .subscribe((value: boolean) => - this.checkScheduledUpdateEnabled(value) - ); - this.checkScheduledUpdateEnabled(this.configFormGroup.get('scheduledUpdateEnabled').value); - } - - private checkScheduledUpdateEnabled(value: boolean) { - if (value) { - this.configFormGroup.get('scheduledUpdateInterval').enable({emitEvent: false}); - } else { - this.configFormGroup.get('scheduledUpdateInterval').disable({emitEvent: false}); - } - } - - private checkRelatedEntity(zoneGroups: CalculatedFieldGeofencing) { - this.isRelatedEntity = Object.values(zoneGroups).some(zone => zone.refDynamicSourceConfiguration?.type === ArgumentEntityType.RelationQuery); - } - - private toggleScopeByOutputType(type: OutputType): void { - if (type === OutputType.Attribute) { - this.outputFormGroup.get('scope').enable({emitEvent: false}); - } else { - this.outputFormGroup.get('scope').disable({emitEvent: false}); - } - if (this.fieldFormGroup.get('type').value === CalculatedFieldType.SIMPLE) { - if (type === OutputType.Attribute) { - this.configFormGroup.get('useLatestTs').disable({emitEvent: false}); - } else { - this.configFormGroup.get('useLatestTs').enable({emitEvent: false}); - } - } else { - this.configFormGroup.get('useLatestTs').disable({emitEvent: false}); - } - } - - private toggleKeyByCalculatedFieldType(type: CalculatedFieldType): void { - if (type === CalculatedFieldType.GEOFENCING) { - this.configFormGroup.get('entityCoordinates').enable({emitEvent: false}); - this.configFormGroup.get('zoneGroups').enable({emitEvent: false}); - this.configFormGroup.get('scheduledUpdateInterval').enable({emitEvent: false}); - - this.outputFormGroup.get('name').disable({emitEvent: false}); - this.configFormGroup.get('useLatestTs').disable({emitEvent: false}); - this.configFormGroup.get('expressionSIMPLE').disable({emitEvent: false}); - this.configFormGroup.get('expressionSCRIPT').disable({emitEvent: false}); - this.configFormGroup.get('arguments').disable({emitEvent: false}); - } else { - this.configFormGroup.get('entityCoordinates').disable({emitEvent: false}); - this.configFormGroup.get('zoneGroups').disable({emitEvent: false}); - this.configFormGroup.get('scheduledUpdateInterval').disable({emitEvent: false}); - - if (type === CalculatedFieldType.SIMPLE) { - this.outputFormGroup.get('name').enable({emitEvent: false}); - this.configFormGroup.get('expressionSIMPLE').enable({emitEvent: false}); - this.configFormGroup.get('expressionSCRIPT').disable({emitEvent: false}); - if (this.outputFormGroup.get('type').value === OutputType.Attribute) { - this.configFormGroup.get('useLatestTs').disable({emitEvent: false}); - } else { - this.configFormGroup.get('useLatestTs').enable({emitEvent: false}); - } - } else { - this.outputFormGroup.get('name').disable({emitEvent: false}); - this.configFormGroup.get('useLatestTs').disable({emitEvent: false}); - this.configFormGroup.get('expressionSIMPLE').disable({emitEvent: false}); - this.configFormGroup.get('expressionSCRIPT').enable({emitEvent: false}); - } - } + const { configuration = {} as CalculatedFieldConfiguration, type = CalculatedFieldType.SIMPLE, debugSettings = { failuresEnabled: true, allEnabled: true }, ...value } = this.data.value ?? {}; + this.fieldFormGroup.patchValue({ configuration, type, debugSettings, ...value }, {emitEvent: false}); } private observeIsLoading(): void { @@ -328,8 +128,6 @@ export class CalculatedFieldDialogComponent extends DialogComponent +
+
+
+ {{ 'calculated-fields.entity-coordinates' | translate }} +
+
+ + +
+
+ +
+
+ {{ 'calculated-fields.geofencing-zone-groups' | translate }} +
+ +
+ +
+ {{ 'calculated-fields.zone-group-refresh-interval' | translate }} +
+
+
+ + +
+
+
+ + +
diff --git a/ui-ngx/src/app/modules/home/components/calculated-fields/components/geofencing-configuration/geofencing-configuration.component.ts b/ui-ngx/src/app/modules/home/components/calculated-fields/components/geofencing-configuration/geofencing-configuration.component.ts new file mode 100644 index 0000000000..67c0fe8749 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/calculated-fields/components/geofencing-configuration/geofencing-configuration.component.ts @@ -0,0 +1,157 @@ +/// +/// 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, OnInit } from '@angular/core'; +import { + ControlValueAccessor, + FormBuilder, + NG_VALIDATORS, + NG_VALUE_ACCESSOR, + ValidationErrors, + Validator, + Validators +} from '@angular/forms'; +import { + ArgumentEntityType, + CalculatedFieldGeofencing, + CalculatedFieldGeofencingConfiguration, + CalculatedFieldOutput, + CalculatedFieldType, + getCalculatedFieldCurrentEntityFilter, + OutputType +} from '@shared/models/calculated-field.models'; +import { AttributeScope, DataKeyType } from '@shared/models/telemetry/telemetry.models'; +import { getCurrentAuthState } from '@core/auth/auth.selectors'; +import { Store } from '@ngrx/store'; +import { AppState } from '@core/core.state'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; +import { EntityFilter } from '@shared/models/query/query.models'; +import { EntityId } from '@shared/models/id/entity-id'; + +@Component({ + selector: 'tb-geofencing-configuration', + templateUrl: './geofencing-configuration.component.html', + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => GeofencingConfigurationComponent), + multi: true + }, + { + provide: NG_VALIDATORS, + useExisting: forwardRef(() => GeofencingConfigurationComponent), + multi: true + } + ], +}) +export class GeofencingConfigurationComponent implements ControlValueAccessor, Validator, OnInit { + + @Input({required: true}) + entityId: EntityId; + + @Input({required: true}) + tenantId: string; + + @Input({required: true}) + entityName: string; + + readonly minAllowedScheduledUpdateIntervalInSecForCF = getCurrentAuthState(this.store).minAllowedScheduledUpdateIntervalInSecForCF; + readonly DataKeyType = DataKeyType; + + geofencingConfiguration = this.fb.group({ + entityCoordinates: this.fb.group({ + latitudeKeyName: [null, [Validators.required]], + longitudeKeyName: [null, [Validators.required]], + }), + zoneGroups: this.fb.control>({}), + scheduledUpdateEnabled: [true], + scheduledUpdateInterval: [this.minAllowedScheduledUpdateIntervalInSecForCF], + output: this.fb.control({scope: AttributeScope.SERVER_SCOPE, type: OutputType.Timeseries}) + }); + + currentEntityFilter: EntityFilter; + isRelatedEntity: boolean; + + private propagateChange: (config: CalculatedFieldGeofencingConfiguration) => void = () => { }; + + constructor(private fb: FormBuilder, + private store: Store) { + + this.geofencingConfiguration.get('zoneGroups').valueChanges + .pipe(takeUntilDestroyed()) + .subscribe((zoneGroups: Record) => + this.checkRelatedEntity(zoneGroups) + ); + + this.geofencingConfiguration.get('scheduledUpdateEnabled').valueChanges + .pipe(takeUntilDestroyed()) + .subscribe((value: boolean) => + this.checkScheduledUpdateEnabled(value) + ); + + this.geofencingConfiguration.valueChanges.pipe( + takeUntilDestroyed() + ).subscribe(() => { + this.updatedModel(this.geofencingConfiguration.getRawValue() as any); + }) + } + + ngOnInit() { + this.currentEntityFilter = getCalculatedFieldCurrentEntityFilter(this.entityName, this.entityId); + } + + validate(): ValidationErrors | null { + return this.geofencingConfiguration.valid ? null : { geofencingConfigError: false }; + } + + writeValue(config: CalculatedFieldGeofencingConfiguration): void { + this.geofencingConfiguration.patchValue(config, {emitEvent: false}); + this.checkRelatedEntity(this.geofencingConfiguration.get('zoneGroups').value); + this.checkScheduledUpdateEnabled(this.geofencingConfiguration.get('scheduledUpdateEnabled').value); + } + + registerOnChange(fn: (config: CalculatedFieldGeofencingConfiguration) => void): void { + this.propagateChange = fn; + } + + registerOnTouched(_: any): void { } + + setDisabledState(isDisabled: boolean): void { + if (isDisabled) { + this.geofencingConfiguration.disable({emitEvent: false}); + } else { + this.geofencingConfiguration.enable({emitEvent: false}); + this.checkScheduledUpdateEnabled(this.geofencingConfiguration.get('scheduledUpdateEnabled').value); + } + } + + private updatedModel(value: CalculatedFieldGeofencingConfiguration) { + value.type = CalculatedFieldType.GEOFENCING; + this.propagateChange(value) + } + + private checkScheduledUpdateEnabled(value: boolean) { + if (value) { + this.geofencingConfiguration.get('scheduledUpdateInterval').enable({emitEvent: false}); + } else { + this.geofencingConfiguration.get('scheduledUpdateInterval').disable({emitEvent: false}); + } + } + + private checkRelatedEntity(zoneGroups: Record) { + this.isRelatedEntity = Object.values(zoneGroups).some(zone => zone.refDynamicSourceConfiguration?.type === ArgumentEntityType.RelationQuery); + } +} diff --git a/ui-ngx/src/app/modules/home/components/calculated-fields/components/geofencing-configuration/geofencing-configuration.module.ts b/ui-ngx/src/app/modules/home/components/calculated-fields/components/geofencing-configuration/geofencing-configuration.module.ts new file mode 100644 index 0000000000..8fc52d2940 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/calculated-fields/components/geofencing-configuration/geofencing-configuration.module.ts @@ -0,0 +1,52 @@ +/// +/// 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 { NgModule } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { + CalculatedFieldGeofencingZoneGroupsTableComponent +} from '@home/components/calculated-fields/components/geofencing-configuration/calculated-field-geofencing-zone-groups-table.component'; +import { + CalculatedFieldGeofencingZoneGroupsPanelComponent +} from '@home/components/calculated-fields/components/geofencing-configuration/calculated-field-geofencing-zone-groups-panel.component'; +import { SharedModule } from '@shared/shared.module'; +import { + GeofencingConfigurationComponent +} from '@home/components/calculated-fields/components/geofencing-configuration/geofencing-configuration.component'; +import { + CalculatedFieldOutputModule +} from '@home/components/calculated-fields/components/output/caclculate-field-output.module'; + +@NgModule({ + imports: [ + CommonModule, + SharedModule, + CalculatedFieldOutputModule + ], + declarations: [ + CalculatedFieldGeofencingZoneGroupsTableComponent, + CalculatedFieldGeofencingZoneGroupsPanelComponent, + GeofencingConfigurationComponent + ], + exports: [ + CalculatedFieldGeofencingZoneGroupsTableComponent, + CalculatedFieldGeofencingZoneGroupsPanelComponent, + GeofencingConfigurationComponent + ] +}) +export class GeofencingConfigurationModule { + +} diff --git a/ui-ngx/src/app/modules/home/components/calculated-fields/components/output/caclculate-field-output.module.ts b/ui-ngx/src/app/modules/home/components/calculated-fields/components/output/caclculate-field-output.module.ts new file mode 100644 index 0000000000..83b3970d9b --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/calculated-fields/components/output/caclculate-field-output.module.ts @@ -0,0 +1,36 @@ +/// +/// 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 { NgModule } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { SharedModule } from '@shared/shared.module'; +import { + CalculatedFieldOutputComponent +} from '@home/components/calculated-fields/components/output/calculated-field-output.component'; + +@NgModule({ + imports: [ + CommonModule, + SharedModule, + ], + declarations: [ + CalculatedFieldOutputComponent + ], + exports: [ + CalculatedFieldOutputComponent + ] +}) +export class CalculatedFieldOutputModule { } diff --git a/ui-ngx/src/app/modules/home/components/calculated-fields/components/output/calculated-field-output.component.html b/ui-ngx/src/app/modules/home/components/calculated-fields/components/output/calculated-field-output.component.html new file mode 100644 index 0000000000..ededb1d78b --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/calculated-fields/components/output/calculated-field-output.component.html @@ -0,0 +1,86 @@ + +
+
{{ 'calculated-fields.output' | translate }}
+
+ + {{ 'calculated-fields.output-type' | translate }} + + @for (type of outputTypes; track type) { + {{ OutputTypeTranslations.get(type) | translate }} + } + + + @if (outputForm.get('type').value === OutputType.Attribute + && (entityId.entityType === EntityType.DEVICE || entityId.entityType === EntityType.DEVICE_PROFILE)) { + + {{ 'calculated-fields.attribute-scope' | translate }} + + + {{ 'calculated-fields.server-attributes' | translate }} + + + {{ 'calculated-fields.shared-attributes' | translate }} + + + + } +
+ @if (simpleMode) { +
+ + + {{ + (outputForm.get('type').value === OutputType.Timeseries + ? 'calculated-fields.timeseries-key' + : 'calculated-fields.attribute-key') + | translate + }} + + + @if (outputForm.get('name').errors && outputForm.get('name').touched) { + + @if (outputForm.get('name').hasError('required')) { + {{ 'common.hint.key-required' | translate }} + } @else if (outputForm.get('name').hasError('pattern')) { + {{ 'common.hint.key-pattern' | translate }} + } @else if (outputForm.get('name').hasError('maxlength')) { + {{ 'common.hint.key-max-length' | translate }} + } + + } + + + {{ 'calculated-fields.decimals-by-default' | translate }} + + @if (outputForm.get('decimalsByDefault').errors && outputForm.get('decimalsByDefault').touched) { + {{ 'calculated-fields.hint.decimals-range' | translate }} + } + +
+ + + + + + + + + + } +
diff --git a/ui-ngx/src/app/modules/home/components/calculated-fields/components/output/calculated-field-output.component.ts b/ui-ngx/src/app/modules/home/components/calculated-fields/components/output/calculated-field-output.component.ts new file mode 100644 index 0000000000..9a95c921fa --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/calculated-fields/components/output/calculated-field-output.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 { Component, DestroyRef, forwardRef, inject, Input, OnChanges, OnInit, SimpleChanges } from '@angular/core'; +import { + ControlValueAccessor, + FormBuilder, + NG_VALIDATORS, + NG_VALUE_ACCESSOR, + ValidationErrors, + Validator, + Validators +} from '@angular/forms'; +import { AttributeScope } from '@shared/models/telemetry/telemetry.models'; +import { + CalculatedFieldOutput, + CalculatedFieldSimpleOutput, + OutputType, + OutputTypeTranslations +} from '@shared/models/calculated-field.models'; +import { digitsRegex, oneSpaceInsideRegex } from '@shared/models/regex.constants'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; +import { EntityId } from '@shared/models/id/entity-id'; +import { EntityType } from '@shared/models/entity-type.models'; + +@Component({ + selector: 'tb-calculate-field-output', + templateUrl: './calculated-field-output.component.html', + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => CalculatedFieldOutputComponent), + multi: true + }, + { + provide: NG_VALIDATORS, + useExisting: forwardRef(() => CalculatedFieldOutputComponent), + multi: true + } + ], +}) +export class CalculatedFieldOutputComponent implements ControlValueAccessor, Validator, OnInit, OnChanges { + + @Input() + simpleMode = false; + + @Input({required: true}) + entityId: EntityId; + + readonly outputTypes = Object.values(OutputType) as OutputType[]; + readonly OutputType = OutputType; + readonly AttributeScope = AttributeScope; + readonly OutputTypeTranslations = OutputTypeTranslations; + readonly EntityType = EntityType; + + private fb = inject(FormBuilder); + private destroyRef = inject(DestroyRef); + + outputForm = this.fb.group({ + name: ['', [Validators.required, Validators.pattern(oneSpaceInsideRegex), Validators.maxLength(255)]], + scope: [{value: AttributeScope.SERVER_SCOPE, disabled: true}], + type: [OutputType.Timeseries], + decimalsByDefault: [null as number, [Validators.min(0), Validators.max(15), Validators.pattern(digitsRegex)]], + }); + + private propagateChange: (config: CalculatedFieldOutput | CalculatedFieldSimpleOutput) => void = () => { }; + + ngOnInit() { + this.outputForm.get('type').valueChanges + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe(type => this.toggleScopeByOutputType(type)); + + this.updatedFormWithMode(); + + this.outputForm.valueChanges.pipe( + takeUntilDestroyed(this.destroyRef) + ).subscribe((value: CalculatedFieldOutput | CalculatedFieldSimpleOutput) => { + this.updatedModel(value) + }) + } + + ngOnChanges(changes: SimpleChanges): void { + for (const propName of Object.keys(changes)) { + const change = changes[propName]; + if (change.currentValue !== change.previousValue) { + if (propName === 'simpleMode') { + this.updatedFormWithMode(); + if (!change.firstChange) { + this.outputForm.updateValueAndValidity(); + } + } + } + } + } + + validate(): ValidationErrors | null { + return this.outputForm.valid ? null : {outputConfig: false}; + } + + writeValue(value: CalculatedFieldOutput | CalculatedFieldSimpleOutput): void { + this.outputForm.patchValue(value, {emitEvent: false}); + this.outputForm.get('type').updateValueAndValidity({onlySelf: true}); + } + + registerOnChange(fn: (config: CalculatedFieldOutput | CalculatedFieldSimpleOutput) => void): void { + this.propagateChange = fn; + } + + registerOnTouched(_: any): void { } + + private updatedModel(value: CalculatedFieldOutput | CalculatedFieldSimpleOutput) { + if (this.simpleMode && 'name' in value) { + value.name = value.name?.trim() ?? ''; + } + this.propagateChange(value); + } + + private toggleScopeByOutputType(type: OutputType): void { + if (type === OutputType.Attribute) { + this.outputForm.get('scope').enable({emitEvent: false}); + } else { + this.outputForm.get('scope').disable({emitEvent: false}); + } + } + + private updatedFormWithMode(): void { + if (this.simpleMode) { + this.outputForm.get('name').enable({emitEvent: false}); + this.outputForm.get('decimalsByDefault').enable({emitEvent: false}); + } else { + this.outputForm.get('name').disable({emitEvent: false}); + this.outputForm.get('decimalsByDefault').disable({emitEvent: false}); + } + } +} diff --git a/ui-ngx/src/app/modules/home/components/calculated-fields/components/public-api.ts b/ui-ngx/src/app/modules/home/components/calculated-fields/components/public-api.ts index 9e3c52bc4f..d4d4f9d1da 100644 --- a/ui-ngx/src/app/modules/home/components/calculated-fields/components/public-api.ts +++ b/ui-ngx/src/app/modules/home/components/calculated-fields/components/public-api.ts @@ -15,7 +15,5 @@ /// export * from './dialog/calculated-field-dialog.component'; -export * from './arguments-table/calculated-field-arguments-table.component'; -export * from './panel/calculated-field-argument-panel.component'; export * from './debug-dialog/calculated-field-debug-dialog.component'; export * from './test-dialog/calculated-field-script-test-dialog.component'; diff --git a/ui-ngx/src/app/modules/home/components/calculated-fields/components/panel/calculated-field-argument-panel.component.html b/ui-ngx/src/app/modules/home/components/calculated-fields/components/simple-configuration/calculated-field-argument-panel.component.html similarity index 100% rename from ui-ngx/src/app/modules/home/components/calculated-fields/components/panel/calculated-field-argument-panel.component.html rename to ui-ngx/src/app/modules/home/components/calculated-fields/components/simple-configuration/calculated-field-argument-panel.component.html diff --git a/ui-ngx/src/app/modules/home/components/calculated-fields/components/panel/calculated-field-argument-panel.component.scss b/ui-ngx/src/app/modules/home/components/calculated-fields/components/simple-configuration/calculated-field-argument-panel.component.scss similarity index 100% rename from ui-ngx/src/app/modules/home/components/calculated-fields/components/panel/calculated-field-argument-panel.component.scss rename to ui-ngx/src/app/modules/home/components/calculated-fields/components/simple-configuration/calculated-field-argument-panel.component.scss diff --git a/ui-ngx/src/app/modules/home/components/calculated-fields/components/panel/calculated-field-argument-panel.component.ts b/ui-ngx/src/app/modules/home/components/calculated-fields/components/simple-configuration/calculated-field-argument-panel.component.ts similarity index 100% rename from ui-ngx/src/app/modules/home/components/calculated-fields/components/panel/calculated-field-argument-panel.component.ts rename to ui-ngx/src/app/modules/home/components/calculated-fields/components/simple-configuration/calculated-field-argument-panel.component.ts diff --git a/ui-ngx/src/app/modules/home/components/calculated-fields/components/arguments-table/calculated-field-arguments-table.component.html b/ui-ngx/src/app/modules/home/components/calculated-fields/components/simple-configuration/calculated-field-arguments-table.component.html similarity index 100% rename from ui-ngx/src/app/modules/home/components/calculated-fields/components/arguments-table/calculated-field-arguments-table.component.html rename to ui-ngx/src/app/modules/home/components/calculated-fields/components/simple-configuration/calculated-field-arguments-table.component.html diff --git a/ui-ngx/src/app/modules/home/components/calculated-fields/components/arguments-table/calculated-field-arguments-table.component.scss b/ui-ngx/src/app/modules/home/components/calculated-fields/components/simple-configuration/calculated-field-arguments-table.component.scss similarity index 100% rename from ui-ngx/src/app/modules/home/components/calculated-fields/components/arguments-table/calculated-field-arguments-table.component.scss rename to ui-ngx/src/app/modules/home/components/calculated-fields/components/simple-configuration/calculated-field-arguments-table.component.scss diff --git a/ui-ngx/src/app/modules/home/components/calculated-fields/components/arguments-table/calculated-field-arguments-table.component.ts b/ui-ngx/src/app/modules/home/components/calculated-fields/components/simple-configuration/calculated-field-arguments-table.component.ts similarity index 98% rename from ui-ngx/src/app/modules/home/components/calculated-fields/components/arguments-table/calculated-field-arguments-table.component.ts rename to ui-ngx/src/app/modules/home/components/calculated-fields/components/simple-configuration/calculated-field-arguments-table.component.ts index 28a3f09126..03730f3c69 100644 --- a/ui-ngx/src/app/modules/home/components/calculated-fields/components/arguments-table/calculated-field-arguments-table.component.ts +++ b/ui-ngx/src/app/modules/home/components/calculated-fields/components/simple-configuration/calculated-field-arguments-table.component.ts @@ -43,7 +43,9 @@ import { CalculatedFieldArgumentValue, CalculatedFieldType, } from '@shared/models/calculated-field.models'; -import { CalculatedFieldArgumentPanelComponent } from '@home/components/calculated-fields/components/public-api'; +import { + CalculatedFieldArgumentPanelComponent +} from '@home/components/calculated-fields/components/simple-configuration/calculated-field-argument-panel.component'; import { MatButton } from '@angular/material/button'; import { TbPopoverService } from '@shared/components/popover.service'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; diff --git a/ui-ngx/src/app/modules/home/components/calculated-fields/components/simple-configuration/simple-configuration.component.html b/ui-ngx/src/app/modules/home/components/calculated-fields/components/simple-configuration/simple-configuration.component.html new file mode 100644 index 0000000000..a4c37bcdee --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/calculated-fields/components/simple-configuration/simple-configuration.component.html @@ -0,0 +1,98 @@ + +
+
+
{{ 'calculated-fields.arguments' | translate }}
+ +
+
+
+ {{ (isScript ? 'calculated-fields.type.script' : 'calculated-fields.expression') | translate }} +
+ + +
+
+ @if (simpleConfiguration.get('expressionSIMPLE').errors && simpleConfiguration.get('expressionSIMPLE').touched) { + + @if (simpleConfiguration.get('expressionSIMPLE').hasError('required')) { + {{ 'calculated-fields.hint.expression-required' | translate }} + } @else if (simpleConfiguration.get('expressionSIMPLE').hasError('pattern')) { + {{ 'calculated-fields.hint.expression-invalid' | translate }} + } @else if (simpleConfiguration.get('expressionSIMPLE').hasError('maxLength')) { + {{ 'calculated-fields.hint.expression-max-length' | translate }} + } + + } @else { + {{ 'calculated-fields.hint.expression' | translate }} + } +
+
+ +
{{ 'api-usage.tbel' | translate }} +
+ +
+
+ +
+
+
+ +
+ +
+ calculated-fields.use-latest-timestamp +
+
+
+
+
diff --git a/ui-ngx/src/app/modules/home/components/calculated-fields/components/simple-configuration/simple-configuration.component.ts b/ui-ngx/src/app/modules/home/components/calculated-fields/components/simple-configuration/simple-configuration.component.ts new file mode 100644 index 0000000000..42137cac1c --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/calculated-fields/components/simple-configuration/simple-configuration.component.ts @@ -0,0 +1,205 @@ +/// +/// 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, + NG_VALIDATORS, + NG_VALUE_ACCESSOR, + ValidationErrors, + Validator, + Validators +} from '@angular/forms'; +import { oneSpaceInsideRegex } from '@shared/models/regex.constants'; +import { + calculatedFieldDefaultScript, + CalculatedFieldScriptConfiguration, + CalculatedFieldSimpleConfiguration, + CalculatedFieldSimpleOutput, + CalculatedFieldType, + getCalculatedFieldArgumentsEditorCompleter, + getCalculatedFieldArgumentsHighlights, + OutputType +} from '@shared/models/calculated-field.models'; +import { AttributeScope } from '@shared/models/telemetry/telemetry.models'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; +import { deepClone } from '@core/utils'; +import { EntityId } from '@shared/models/id/entity-id'; +import { Observable } from 'rxjs'; +import { ScriptLanguage } from '@shared/models/rule-node.models'; +import { map } from 'rxjs/operators'; + +type SimpeConfiguration = CalculatedFieldSimpleConfiguration | CalculatedFieldScriptConfiguration; + +@Component({ + selector: 'tb-simple-configuration', + templateUrl: './simple-configuration.component.html', + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => SimpleConfigurationComponent), + multi: true + }, + { + provide: NG_VALIDATORS, + useExisting: forwardRef(() => SimpleConfigurationComponent), + multi: true + } + ], +}) +export class SimpleConfigurationComponent implements ControlValueAccessor, Validator, OnChanges { + + @Input() + isScript: boolean; + + @Input() + entityId: EntityId; + + @Input() + tenantId: string; + + @Input() + entityName: string; + + @Input() + testScript$: Observable; + + simpleConfiguration = this.fb.group({ + arguments: this.fb.control({}), + expressionSIMPLE: ['', [Validators.required, Validators.pattern(oneSpaceInsideRegex), Validators.maxLength(255)]], + expressionSCRIPT: [calculatedFieldDefaultScript], + output: this.fb.control({ + name: '', + scope: AttributeScope.SERVER_SCOPE, + type: OutputType.Timeseries, + decimalsByDefault: null + }), + useLatestTs: [false] + }); + + readonly ScriptLanguage = ScriptLanguage; + readonly CalculatedFieldType = CalculatedFieldType; + readonly OutputType = OutputType; + + functionArgs$ = this.simpleConfiguration.get('arguments').valueChanges.pipe( + map(argumentsObj => ['ctx', ...Object.keys(argumentsObj)]) + ); + + argumentsEditorCompleter$ = this.simpleConfiguration.get('arguments').valueChanges.pipe( + map(argumentsObj => getCalculatedFieldArgumentsEditorCompleter(argumentsObj ?? {})) + ); + + argumentsHighlightRules$ = this.simpleConfiguration.get('arguments').valueChanges.pipe( + map(argumentsObj => getCalculatedFieldArgumentsHighlights(argumentsObj)) + ); + + private propagateChange: (config: SimpeConfiguration) => void = () => { }; + + constructor(private fb: FormBuilder) { + this.simpleConfiguration.get('output').valueChanges.pipe( + takeUntilDestroyed(), + ).subscribe(() => { + this.toggleScopeByOutputType(); + }); + + this.simpleConfiguration.valueChanges.pipe( + takeUntilDestroyed() + ).subscribe((value) => { + const { expressionSIMPLE, expressionSCRIPT, ...config } = value; + const cfConfig = config as SimpeConfiguration; + cfConfig.expression = this.isScript ? expressionSCRIPT : expressionSIMPLE; + this.updatedModel(cfConfig); + }) + } + + ngOnChanges(changes: SimpleChanges): void { + for (const propName of Object.keys(changes)) { + const change = changes[propName]; + if (change.currentValue !== change.previousValue) { + if (propName === 'isScript') { + this.updatedFormWithScript(); + if (!change.firstChange) { + this.simpleConfiguration.updateValueAndValidity(); + } + } + } + } + } + + validate(): ValidationErrors | null { + return this.simpleConfiguration.valid ? null : {invalidSimpleConfig: false}; + } + + writeValue(value: SimpeConfiguration): void { + const formValue: any = deepClone(value); + if (this.isScript) { + formValue.expressionSCRIPT = formValue.expression; + } else { + formValue.expressionSIMPLE = formValue.expression; + } + this.simpleConfiguration.patchValue(formValue, {emitEvent: false}); + this.simpleConfiguration.get('arguments').updateValueAndValidity({onlySelf: true}); + this.updatedFormWithScript(); + } + + registerOnChange(fn: (config: SimpeConfiguration) => void): void { + this.propagateChange = fn; + } + + registerOnTouched(_: any): void { + } + + setDisabledState(isDisabled: boolean): void { + if (isDisabled) { + this.simpleConfiguration.disable({emitEvent: false}); + } else { + this.simpleConfiguration.enable({emitEvent: false}); + this.updatedFormWithScript(); + } + } + + onTestScript() { + this.testScript$?.subscribe((expression) => { + this.simpleConfiguration.get('expressionSCRIPT').setValue(expression); + this.simpleConfiguration.get('expressionSCRIPT').markAsDirty(); + }) + } + + private updatedModel(value: SimpeConfiguration): void { + value.type = this.isScript ? CalculatedFieldType.SCRIPT : CalculatedFieldType.SIMPLE; + this.propagateChange(value); + } + + private updatedFormWithScript() { + if (this.isScript) { + this.simpleConfiguration.get('expressionSIMPLE').disable({emitEvent: false}); + this.simpleConfiguration.get('expressionSCRIPT').enable({emitEvent: false}); + } else { + this.simpleConfiguration.get('expressionSIMPLE').enable({emitEvent: false}); + this.simpleConfiguration.get('expressionSCRIPT').disable({emitEvent: false}); + } + this.toggleScopeByOutputType(); + } + + private toggleScopeByOutputType(): void { + if (this.isScript || this.simpleConfiguration.get('output').value.type === OutputType.Attribute) { + this.simpleConfiguration.get('useLatestTs').disable({emitEvent: false}); + } else { + this.simpleConfiguration.get('useLatestTs').enable({emitEvent: false}); + } + } +} diff --git a/ui-ngx/src/app/modules/home/components/calculated-fields/components/simple-configuration/simple-configuration.module.ts b/ui-ngx/src/app/modules/home/components/calculated-fields/components/simple-configuration/simple-configuration.module.ts new file mode 100644 index 0000000000..aee32a0916 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/calculated-fields/components/simple-configuration/simple-configuration.module.ts @@ -0,0 +1,48 @@ +/// +/// 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 { NgModule } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { SharedModule } from '@shared/shared.module'; +import { + SimpleConfigurationComponent +} from '@home/components/calculated-fields/components/simple-configuration/simple-configuration.component'; +import { + CalculatedFieldArgumentPanelComponent +} from '@home/components/calculated-fields/components/simple-configuration/calculated-field-argument-panel.component'; +import { + CalculatedFieldOutputModule +} from '@home/components/calculated-fields/components/output/caclculate-field-output.module'; +import { + CalculatedFieldArgumentsTableComponent +} from '@home/components/calculated-fields/components/simple-configuration/calculated-field-arguments-table.component'; + +@NgModule({ + imports: [ + CommonModule, + SharedModule, + CalculatedFieldOutputModule, + ], + declarations: [ + SimpleConfigurationComponent, + CalculatedFieldArgumentPanelComponent, + CalculatedFieldArgumentsTableComponent + ], + exports: [ + SimpleConfigurationComponent + ] +}) +export class SimpleConfigurationModule {} diff --git a/ui-ngx/src/app/modules/home/components/home-components.module.ts b/ui-ngx/src/app/modules/home/components/home-components.module.ts index d3816ddaca..2c9c08aeb3 100644 --- a/ui-ngx/src/app/modules/home/components/home-components.module.ts +++ b/ui-ngx/src/app/modules/home/components/home-components.module.ts @@ -183,36 +183,13 @@ import { } from '@home/components/dashboard-page/layout/select-dashboard-breakpoint.component'; import { EntityChipsComponent } from '@home/components/entity/entity-chips.component'; import { DashboardViewComponent } from '@home/components/dashboard-view/dashboard-view.component'; -import { CalculatedFieldsTableComponent } from '@home/components/calculated-fields/calculated-fields-table.component'; -import { CalculatedFieldDialogComponent } from '@home/components/calculated-fields/components/dialog/calculated-field-dialog.component'; import { EntityDebugSettingsButtonComponent } from '@home/components/entity/debug/entity-debug-settings-button.component'; -import { - CalculatedFieldArgumentsTableComponent -} from '@home/components/calculated-fields/components/arguments-table/calculated-field-arguments-table.component'; -import { - CalculatedFieldArgumentPanelComponent -} from '@home/components/calculated-fields/components/panel/calculated-field-argument-panel.component'; -import { - CalculatedFieldDebugDialogComponent -} from '@home/components/calculated-fields/components/debug-dialog/calculated-field-debug-dialog.component'; -import { - CalculatedFieldScriptTestDialogComponent -} from '@home/components/calculated-fields/components/test-dialog/calculated-field-script-test-dialog.component'; -import { - CalculatedFieldTestArgumentsComponent -} from '@home/components/calculated-fields/components/test-arguments/calculated-field-test-arguments.component'; import { CheckConnectivityDialogComponent } from '@home/components/ai-model/check-connectivity-dialog.component'; import { AIModelDialogComponent } from '@home/components/ai-model/ai-model-dialog.component'; import { ResourcesDialogComponent } from "@home/components/resources/resources-dialog.component"; import { ResourcesLibraryComponent } from "@home/components/resources/resources-library.component"; -import { - CalculatedFieldGeofencingZoneGroupsTableComponent -} from '@home/components/calculated-fields/components/geofencing-zone-grups-table/calculated-field-geofencing-zone-groups-table.component'; -import { - CalculatedFieldGeofencingZoneGroupsPanelComponent -} from '@home/components/calculated-fields/components/panel/calculated-field-geofencing-zone-groups-panel.component'; @NgModule({ declarations: @@ -357,15 +334,6 @@ import { SendNotificationButtonComponent, EntityChipsComponent, DashboardViewComponent, - CalculatedFieldsTableComponent, - CalculatedFieldDialogComponent, - CalculatedFieldArgumentsTableComponent, - CalculatedFieldArgumentPanelComponent, - CalculatedFieldDebugDialogComponent, - CalculatedFieldScriptTestDialogComponent, - CalculatedFieldTestArgumentsComponent, - CalculatedFieldGeofencingZoneGroupsTableComponent, - CalculatedFieldGeofencingZoneGroupsPanelComponent, CheckConnectivityDialogComponent, AIModelDialogComponent, ResourcesDialogComponent, @@ -508,15 +476,6 @@ import { SendNotificationButtonComponent, EntityChipsComponent, DashboardViewComponent, - CalculatedFieldsTableComponent, - CalculatedFieldDialogComponent, - CalculatedFieldArgumentsTableComponent, - CalculatedFieldArgumentPanelComponent, - CalculatedFieldDebugDialogComponent, - CalculatedFieldScriptTestDialogComponent, - CalculatedFieldTestArgumentsComponent, - CalculatedFieldGeofencingZoneGroupsTableComponent, - CalculatedFieldGeofencingZoneGroupsPanelComponent, CheckConnectivityDialogComponent, AIModelDialogComponent, ResourcesDialogComponent, diff --git a/ui-ngx/src/app/modules/home/pages/asset-profile/asset-profile.module.ts b/ui-ngx/src/app/modules/home/pages/asset-profile/asset-profile.module.ts index b85c83f281..c174a2b97b 100644 --- a/ui-ngx/src/app/modules/home/pages/asset-profile/asset-profile.module.ts +++ b/ui-ngx/src/app/modules/home/pages/asset-profile/asset-profile.module.ts @@ -20,6 +20,7 @@ import { SharedModule } from '@shared/shared.module'; import { HomeComponentsModule } from '@modules/home/components/home-components.module'; import { AssetProfileTabsComponent } from './asset-profile-tabs.component'; import { AssetProfileRoutingModule } from './asset-profile-routing.module'; +import { CalculatedFieldsModule } from '@home/components/calculated-fields/calculated-field.module'; @NgModule({ declarations: [ @@ -29,7 +30,8 @@ import { AssetProfileRoutingModule } from './asset-profile-routing.module'; CommonModule, SharedModule, HomeComponentsModule, - AssetProfileRoutingModule + CalculatedFieldsModule, + AssetProfileRoutingModule, ] }) export class AssetProfileModule { } diff --git a/ui-ngx/src/app/modules/home/pages/asset/asset.module.ts b/ui-ngx/src/app/modules/home/pages/asset/asset.module.ts index 475af2fb9a..44fa22520f 100644 --- a/ui-ngx/src/app/modules/home/pages/asset/asset.module.ts +++ b/ui-ngx/src/app/modules/home/pages/asset/asset.module.ts @@ -23,6 +23,7 @@ import { AssetTableHeaderComponent } from './asset-table-header.component'; import { AssetRoutingModule } from './asset-routing.module'; import { HomeComponentsModule } from '@modules/home/components/home-components.module'; import { AssetTabsComponent } from '@home/pages/asset/asset-tabs.component'; +import { CalculatedFieldsModule } from '@home/components/calculated-fields/calculated-field.module'; @NgModule({ declarations: [ @@ -35,7 +36,8 @@ import { AssetTabsComponent } from '@home/pages/asset/asset-tabs.component'; SharedModule, HomeComponentsModule, HomeDialogsModule, - AssetRoutingModule + CalculatedFieldsModule, + AssetRoutingModule, ] }) export class AssetModule { } diff --git a/ui-ngx/src/app/modules/home/pages/device-profile/device-profile.module.ts b/ui-ngx/src/app/modules/home/pages/device-profile/device-profile.module.ts index 76d15d00f1..12b68f4ab4 100644 --- a/ui-ngx/src/app/modules/home/pages/device-profile/device-profile.module.ts +++ b/ui-ngx/src/app/modules/home/pages/device-profile/device-profile.module.ts @@ -20,6 +20,7 @@ import { SharedModule } from '@shared/shared.module'; import { HomeComponentsModule } from '@modules/home/components/home-components.module'; import { DeviceProfileTabsComponent } from './device-profile-tabs.component'; import { DeviceProfileRoutingModule } from './device-profile-routing.module'; +import { CalculatedFieldsModule } from '@home/components/calculated-fields/calculated-field.module'; @NgModule({ declarations: [ @@ -29,6 +30,7 @@ import { DeviceProfileRoutingModule } from './device-profile-routing.module'; CommonModule, SharedModule, HomeComponentsModule, + CalculatedFieldsModule, DeviceProfileRoutingModule ] }) diff --git a/ui-ngx/src/app/modules/home/pages/device/device.module.ts b/ui-ngx/src/app/modules/home/pages/device/device.module.ts index 4c74da0f89..8681ff7fe7 100644 --- a/ui-ngx/src/app/modules/home/pages/device/device.module.ts +++ b/ui-ngx/src/app/modules/home/pages/device/device.module.ts @@ -36,6 +36,7 @@ import { SnmpDeviceTransportConfigurationComponent } from './data/snmp-device-tr import { DeviceCredentialsModule } from '@home/components/device/device-credentials.module'; import { DeviceProfileCommonModule } from '@home/components/profile/device/common/device-profile-common.module'; import { DeviceCheckConnectivityDialogComponent } from './device-check-connectivity-dialog.component'; +import { CalculatedFieldsModule } from '@home/components/calculated-fields/calculated-field.module'; @NgModule({ declarations: [ @@ -61,6 +62,7 @@ import { DeviceCheckConnectivityDialogComponent } from './device-check-connectiv HomeDialogsModule, DeviceCredentialsModule, DeviceProfileCommonModule, + CalculatedFieldsModule, DeviceRoutingModule ] }) diff --git a/ui-ngx/src/app/shared/components/time-unit-input.component.ts b/ui-ngx/src/app/shared/components/time-unit-input.component.ts index 44f1be514a..35b64514c7 100644 --- a/ui-ngx/src/app/shared/components/time-unit-input.component.ts +++ b/ui-ngx/src/app/shared/components/time-unit-input.component.ts @@ -178,7 +178,7 @@ export class TimeUnitInputComponent implements ControlValueAccessor, Validator, this.timeInputForm.disable({emitEvent: false}); } else { this.timeInputForm.enable({emitEvent: false}); - if(this.timeInputForm.invalid) { + if(!this.timeInputForm.valid) { setTimeout(() => this.updatedModel(this.timeInputForm.value, true)) } } diff --git a/ui-ngx/src/app/shared/models/calculated-field.models.ts b/ui-ngx/src/app/shared/models/calculated-field.models.ts index 841baea168..8a2c34d036 100644 --- a/ui-ngx/src/app/shared/models/calculated-field.models.ts +++ b/ui-ngx/src/app/shared/models/calculated-field.models.ts @@ -31,16 +31,35 @@ import { } from '@shared/models/ace/ace.models'; import { EntitySearchDirection } from '@shared/models/relation.models'; -export interface CalculatedField extends Omit, 'label'>, HasVersion, HasEntityDebugSettings, HasTenantId, ExportableEntity { - configuration: CalculatedFieldConfiguration; - type: CalculatedFieldType; +interface BaseCalculatedField extends Omit, 'label'>, HasVersion, HasEntityDebugSettings, HasTenantId, ExportableEntity { entityId: EntityId; } +export interface CalculatedFieldSimple extends BaseCalculatedField { + type: CalculatedFieldType.SIMPLE; + configuration: CalculatedFieldSimpleConfiguration; +} + +export interface CalculatedFieldScript extends BaseCalculatedField { + type: CalculatedFieldType.SCRIPT; + configuration: CalculatedFieldScriptConfiguration; +} + +export interface CalculatedFieldGeofencing extends BaseCalculatedField { + type: CalculatedFieldType.GEOFENCING; + configuration: CalculatedFieldGeofencingConfiguration; +} + +export type CalculatedField = + | CalculatedFieldSimple + | CalculatedFieldScript + | CalculatedFieldGeofencing; + export enum CalculatedFieldType { SIMPLE = 'SIMPLE', SCRIPT = 'SCRIPT', - GEOFENCING = 'GEOFENCING' + GEOFENCING = 'GEOFENCING', + PROPAGATION = 'PROPAGATION' } export const CalculatedFieldTypeTranslations = new Map( @@ -48,22 +67,44 @@ export const CalculatedFieldTypeTranslations = new Map; + output: CalculatedFieldSimpleOutput; +} + +export interface CalculatedFieldScriptConfiguration { + type: CalculatedFieldType.SCRIPT; + expression?: string; + arguments?: Record; + output: CalculatedFieldOutput; +} + +export interface CalculatedFieldGeofencingConfiguration { + type: CalculatedFieldType.GEOFENCING; zoneGroups?: Record; + scheduledUpdateEnabled?: boolean; scheduledUpdateInterval?: number; output: CalculatedFieldOutput; } export interface CalculatedFieldOutput { type: OutputType; - name: string; scope?: AttributeScope; +} + +export interface CalculatedFieldSimpleOutput extends CalculatedFieldOutput { + name: string; decimalsByDefault?: number; } diff --git a/ui-ngx/src/assets/locale/locale.constant-en_US.json b/ui-ngx/src/assets/locale/locale.constant-en_US.json index 8bba7205e3..24266ad192 100644 --- a/ui-ngx/src/assets/locale/locale.constant-en_US.json +++ b/ui-ngx/src/assets/locale/locale.constant-en_US.json @@ -1053,7 +1053,8 @@ "type": { "simple": "Simple", "script": "Script", - "geofencing" : "Geofencing" + "geofencing" : "Geofencing", + "propagation": "Propagation" }, "arguments": "Arguments", "decimals-by-default": "Decimals by default", From fca21661707023180d28bec8f6b213540f7e1e92 Mon Sep 17 00:00:00 2001 From: Vladyslav_Prykhodko Date: Thu, 16 Oct 2025 11:53:59 +0300 Subject: [PATCH 34/43] UI: Add configuration propagate calculate field --- .../calculated-field.module.ts | 6 +- .../calculated-fields-table-config.ts | 70 ++++--- ...ulated-field-argument-panel.component.html | 134 +++++++++----- ...ulated-field-argument-panel.component.scss | 0 ...lculated-field-argument-panel.component.ts | 113 ++++++++---- ...lated-field-arguments-table.component.html | 15 +- ...lated-field-arguments-table.component.scss | 2 +- ...culated-field-arguments-table.component.ts | 58 +++--- ...calculated-field-arguments-table.module.ts | 45 +++++ .../propagate-arguments-table.component.ts | 116 ++++++++++++ .../calculated-field-dialog.component.html | 10 +- .../calculated-field-dialog.component.ts | 11 ++ .../geofencing-configuration.component.ts | 2 +- .../geofencing-configuration.module.ts | 2 +- ...e.ts => calculated-field-output.module.ts} | 0 .../propagation-configuration.component.html | 99 ++++++++++ .../propagation-configuration.component.ts | 174 ++++++++++++++++++ .../propagation-configuration.module.ts | 44 +++++ .../simple-configuration.component.html | 2 +- .../simple-configuration.component.ts | 21 ++- .../simple-configuration.module.ts | 12 +- .../shared/models/calculated-field.models.ts | 51 ++++- .../assets/locale/locale.constant-en_US.json | 23 ++- 23 files changed, 828 insertions(+), 182 deletions(-) rename ui-ngx/src/app/modules/home/components/calculated-fields/components/{simple-configuration => calculated-field-arguments}/calculated-field-argument-panel.component.html (65%) rename ui-ngx/src/app/modules/home/components/calculated-fields/components/{simple-configuration => calculated-field-arguments}/calculated-field-argument-panel.component.scss (100%) rename ui-ngx/src/app/modules/home/components/calculated-fields/components/{simple-configuration => calculated-field-arguments}/calculated-field-argument-panel.component.ts (76%) rename ui-ngx/src/app/modules/home/components/calculated-fields/components/{simple-configuration => calculated-field-arguments}/calculated-field-arguments-table.component.html (89%) rename ui-ngx/src/app/modules/home/components/calculated-fields/components/{simple-configuration => calculated-field-arguments}/calculated-field-arguments-table.component.scss (95%) rename ui-ngx/src/app/modules/home/components/calculated-fields/components/{simple-configuration => calculated-field-arguments}/calculated-field-arguments-table.component.ts (85%) create mode 100644 ui-ngx/src/app/modules/home/components/calculated-fields/components/calculated-field-arguments/calculated-field-arguments-table.module.ts create mode 100644 ui-ngx/src/app/modules/home/components/calculated-fields/components/calculated-field-arguments/propagate-arguments-table.component.ts rename ui-ngx/src/app/modules/home/components/calculated-fields/components/output/{caclculate-field-output.module.ts => calculated-field-output.module.ts} (100%) create mode 100644 ui-ngx/src/app/modules/home/components/calculated-fields/components/propagation-configuration/propagation-configuration.component.html create mode 100644 ui-ngx/src/app/modules/home/components/calculated-fields/components/propagation-configuration/propagation-configuration.component.ts create mode 100644 ui-ngx/src/app/modules/home/components/calculated-fields/components/propagation-configuration/propagation-configuration.module.ts diff --git a/ui-ngx/src/app/modules/home/components/calculated-fields/calculated-field.module.ts b/ui-ngx/src/app/modules/home/components/calculated-fields/calculated-field.module.ts index 9cccf28305..6cb6a907b7 100644 --- a/ui-ngx/src/app/modules/home/components/calculated-fields/calculated-field.module.ts +++ b/ui-ngx/src/app/modules/home/components/calculated-fields/calculated-field.module.ts @@ -40,6 +40,9 @@ import { import { SimpleConfigurationModule } from '@home/components/calculated-fields/components/simple-configuration/simple-configuration.module'; +import { + PropagationConfigurationModule +} from '@home/components/calculated-fields/components/propagation-configuration/propagation-configuration.module'; @NgModule({ declarations: [ @@ -55,7 +58,8 @@ import { GeofencingConfigurationModule, EntityDebugSettingsButtonComponent, HomeComponentsModule, - SimpleConfigurationModule + SimpleConfigurationModule, + PropagationConfigurationModule, ], exports: [ CalculatedFieldsTableComponent, diff --git a/ui-ngx/src/app/modules/home/components/calculated-fields/calculated-fields-table-config.ts b/ui-ngx/src/app/modules/home/components/calculated-fields/calculated-fields-table-config.ts index 1105365347..4104b97221 100644 --- a/ui-ngx/src/app/modules/home/components/calculated-fields/calculated-fields-table-config.ts +++ b/ui-ngx/src/app/modules/home/components/calculated-fields/calculated-fields-table-config.ts @@ -40,10 +40,12 @@ import { ArgumentType, CalculatedField, CalculatedFieldEventArguments, + CalculatedFieldScriptConfiguration, CalculatedFieldType, CalculatedFieldTypeTranslations, getCalculatedFieldArgumentsEditorCompleter, getCalculatedFieldArgumentsHighlights, + PropagationWithExpression, } from '@shared/models/calculated-field.models'; import { CalculatedFieldDebugDialogComponent, @@ -122,7 +124,7 @@ export class CalculatedFieldsTableConfig extends EntityTableConfig('createdTime', 'common.created-time', this.datePipe, '150px')); this.columns.push(new EntityTableColumn('name', 'common.name', '33%')); - this.columns.push(new EntityTableColumn('type', 'common.type', '70px', entity => this.translate.instant(CalculatedFieldTypeTranslations.get(entity.type)))); + this.columns.push(new EntityTableColumn('type', 'common.type', '80px', entity => this.translate.instant(CalculatedFieldTypeTranslations.get(entity.type)))); this.columns.push(expressionColumn); this.cellActionDescriptors.push( @@ -156,7 +158,8 @@ export class CalculatedFieldsTableConfig extends EntityTableConfig { - if (calculatedField.type === CalculatedFieldType.GEOFENCING || calculatedField.type === CalculatedFieldType.SIMPLE) { + if ( + calculatedField.type === CalculatedFieldType.SCRIPT || + (calculatedField.type === CalculatedFieldType.PROPAGATION && calculatedField.configuration.applyExpressionToResolvedArguments === true) + ) { + const resultArguments = Object.keys(calculatedField.configuration.arguments).reduce((acc, key) => { + const type = calculatedField.configuration.arguments[key].refEntityKey.type; + acc[key] = isObject(argumentsObj) && argumentsObj.hasOwnProperty(key) + ? {...argumentsObj[key], type} + : type === ArgumentType.Rolling ? {values: [], type} : {value: '', type, ts: new Date().getTime()}; + return acc; + }, {}); + return this.dialog.open(CalculatedFieldScriptTestDialogComponent, + { + disableClose: true, + panelClass: ['tb-dialog', 'tb-fullscreen-dialog', 'tb-fullscreen-dialog-gt-xs'], + data: { + arguments: resultArguments, + expression: (calculatedField.configuration as CalculatedFieldScriptConfiguration | PropagationWithExpression).expression, + argumentsEditorCompleter: getCalculatedFieldArgumentsEditorCompleter(calculatedField.configuration.arguments), + argumentsHighlightRules: getCalculatedFieldArgumentsHighlights(calculatedField.configuration.arguments), + openCalculatedFieldEdit + } + }).afterClosed() + .pipe( + filter(Boolean), + tap(expression => { + if (openCalculatedFieldEdit) { + this.editCalculatedField({ + entityId: this.entityId, ...calculatedField, + configuration: {...calculatedField.configuration, expression} as any + }, true) + } + }), + ); + } else { return of(null); } - const resultArguments = Object.keys(calculatedField.configuration.arguments).reduce((acc, key) => { - const type = calculatedField.configuration.arguments[key].refEntityKey.type; - acc[key] = isObject(argumentsObj) && argumentsObj.hasOwnProperty(key) - ? { ...argumentsObj[key], type } - : type === ArgumentType.Rolling ? { values: [], type } : { value: '', type, ts: new Date().getTime() }; - return acc; - }, {}); - return this.dialog.open(CalculatedFieldScriptTestDialogComponent, - { - disableClose: true, - panelClass: ['tb-dialog', 'tb-fullscreen-dialog', 'tb-fullscreen-dialog-gt-xs'], - data: { - arguments: resultArguments, - expression: calculatedField.configuration.expression, - argumentsEditorCompleter: getCalculatedFieldArgumentsEditorCompleter(calculatedField.configuration.arguments), - argumentsHighlightRules: getCalculatedFieldArgumentsHighlights(calculatedField.configuration.arguments), - openCalculatedFieldEdit - } - }).afterClosed() - .pipe( - filter(Boolean), - tap(expression => { - if (openCalculatedFieldEdit) { - this.editCalculatedField({ entityId: this.entityId, ...calculatedField, configuration: {...calculatedField.configuration, expression } }, true) - } - }), - ); } } diff --git a/ui-ngx/src/app/modules/home/components/calculated-fields/components/simple-configuration/calculated-field-argument-panel.component.html b/ui-ngx/src/app/modules/home/components/calculated-fields/components/calculated-field-arguments/calculated-field-argument-panel.component.html similarity index 65% rename from ui-ngx/src/app/modules/home/components/calculated-fields/components/simple-configuration/calculated-field-argument-panel.component.html rename to ui-ngx/src/app/modules/home/components/calculated-fields/components/calculated-field-arguments/calculated-field-argument-panel.component.html index 92855d882f..77bdedb068 100644 --- a/ui-ngx/src/app/modules/home/components/calculated-fields/components/simple-configuration/calculated-field-argument-panel.component.html +++ b/ui-ngx/src/app/modules/home/components/calculated-fields/components/calculated-field-arguments/calculated-field-argument-panel.component.html @@ -19,62 +19,34 @@
{{ 'calculated-fields.argument-settings' | translate }}
-
-
{{ 'calculated-fields.argument-name' | translate }}
- - - @if (argumentFormGroup.get('argumentName').touched && argumentFormGroup.get('argumentName').hasError('required')) { - - warning - - } @else if (argumentFormGroup.get('argumentName').touched && argumentFormGroup.get('argumentName').hasError('duplicateName')) { - - warning - - } @else if (argumentFormGroup.get('argumentName').touched && argumentFormGroup.get('argumentName').hasError('pattern')) { - - warning - - } @else if (argumentFormGroup.get('argumentName').touched && argumentFormGroup.get('argumentName').hasError('maxlength')) { - - warning - - } @else if (argumentFormGroup.get('argumentName').touched && argumentFormGroup.get('argumentName').hasError('forbiddenName')) { - - warning - - } - -
- + @if (!isOutputKey) { + + } +
{{ 'entity.entity-type' | translate }}
- + @for (type of argumentEntityTypes; track type) { {{ ArgumentEntityTypeTranslations.get(type) | translate }} } + @if (argumentType.touched && argumentType.hasError('required')) { + + warning + + }
@if (ArgumentEntityTypeParamsMap.has(entityType)) { @@ -83,7 +55,8 @@ + @if (isOutputKey) { + + } @if (refEntityKeyFormGroup.get('type').value !== ArgumentType.Rolling) {
{{ 'calculated-fields.default-value' | translate }}
@@ -207,3 +190,54 @@
+ + +
+
{{ label | translate }}
+ + + @if (argumentFormGroup.get('argumentName').touched && argumentFormGroup.get('argumentName').hasError('required')) { + + warning + + } @else if (argumentFormGroup.get('argumentName').touched && argumentFormGroup.get('argumentName').hasError('duplicateName')) { + + warning + + } @else if (argumentFormGroup.get('argumentName').touched && argumentFormGroup.get('argumentName').hasError('pattern')) { + + warning + + } @else if (argumentFormGroup.get('argumentName').touched && argumentFormGroup.get('argumentName').hasError('maxlength')) { + + warning + + } @else if (argumentFormGroup.get('argumentName').touched && argumentFormGroup.get('argumentName').hasError('forbiddenName')) { + + warning + + } + +
+
diff --git a/ui-ngx/src/app/modules/home/components/calculated-fields/components/simple-configuration/calculated-field-argument-panel.component.scss b/ui-ngx/src/app/modules/home/components/calculated-fields/components/calculated-field-arguments/calculated-field-argument-panel.component.scss similarity index 100% rename from ui-ngx/src/app/modules/home/components/calculated-fields/components/simple-configuration/calculated-field-argument-panel.component.scss rename to ui-ngx/src/app/modules/home/components/calculated-fields/components/calculated-field-arguments/calculated-field-argument-panel.component.scss diff --git a/ui-ngx/src/app/modules/home/components/calculated-fields/components/simple-configuration/calculated-field-argument-panel.component.ts b/ui-ngx/src/app/modules/home/components/calculated-fields/components/calculated-field-arguments/calculated-field-argument-panel.component.ts similarity index 76% rename from ui-ngx/src/app/modules/home/components/calculated-fields/components/simple-configuration/calculated-field-argument-panel.component.ts rename to ui-ngx/src/app/modules/home/components/calculated-fields/components/calculated-field-arguments/calculated-field-argument-panel.component.ts index 8ccaa4fe49..070ffb5c06 100644 --- a/ui-ngx/src/app/modules/home/components/calculated-fields/components/simple-configuration/calculated-field-argument-panel.component.ts +++ b/ui-ngx/src/app/modules/home/components/calculated-fields/components/calculated-field-arguments/calculated-field-argument-panel.component.ts @@ -14,7 +14,16 @@ /// limitations under the License. /// -import { AfterViewInit, ChangeDetectorRef, Component, Input, OnInit, output, ViewChild } from '@angular/core'; +import { + AfterViewInit, + ChangeDetectorRef, + Component, + DestroyRef, + Input, + OnInit, + output, + ViewChild +} from '@angular/core'; import { TbPopoverComponent } from '@shared/components/popover.component'; import { FormBuilder, FormControl, FormGroup, ValidatorFn, Validators } from '@angular/forms'; import { charsWithNumRegex, oneSpaceInsideRegex } from '@shared/models/regex.constants'; @@ -25,7 +34,6 @@ import { ArgumentType, ArgumentTypeTranslations, CalculatedFieldArgumentValue, - CalculatedFieldType, getCalculatedFieldCurrentEntityFilter } from '@shared/models/calculated-field.models'; import { debounceTime, delay, distinctUntilChanged, filter } from 'rxjs/operators'; @@ -43,6 +51,7 @@ import { AppState } from '@core/core.state'; import { Store } from '@ngrx/store'; import { EntityAutocompleteComponent } from '@shared/components/entity/entity-autocomplete.component'; import { NULL_UUID } from '@shared/models/id/has-uuid'; +import { TenantId } from '@shared/models/id/tenant-id'; @Component({ selector: 'tb-calculated-field-argument-panel', @@ -56,22 +65,23 @@ export class CalculatedFieldArgumentPanelComponent implements OnInit, AfterViewI @Input() entityId: EntityId; @Input() tenantId: string; @Input() entityName: string; - @Input() calculatedFieldType: CalculatedFieldType; + @Input() isScript: boolean; @Input() usedArgumentNames: string[]; + @Input() isOutputKey = false; + @Input() argumentEntityTypes = Object.values(ArgumentEntityType).filter(value => value !== ArgumentEntityType.RelationQuery) as ArgumentEntityType[]; @ViewChild('entityAutocomplete') entityAutocomplete: EntityAutocompleteComponent; argumentsDataApplied = output(); + argumentType = this.fb.control(ArgumentEntityType.Current, Validators.required); + readonly maxDataPointsPerRollingArg = getCurrentAuthState(this.store).maxDataPointsPerRollingArg; readonly defaultLimit = Math.floor(this.maxDataPointsPerRollingArg / 10); argumentFormGroup = this.fb.group({ argumentName: ['', [Validators.required, this.uniqNameRequired(), this.forbiddenArgumentNameValidator(), Validators.pattern(charsWithNumRegex), Validators.maxLength(255)]], - refEntityId: this.fb.group({ - entityType: [ArgumentEntityType.Current], - id: [''] - }), + refEntityId: [null], refEntityKey: this.fb.group({ type: [ArgumentType.LatestTelemetry, [Validators.required]], key: ['', [Validators.pattern(oneSpaceInsideRegex)]], @@ -86,7 +96,6 @@ export class CalculatedFieldArgumentPanelComponent implements OnInit, AfterViewI entityFilter: EntityFilter; entityNameSubject = new BehaviorSubject(null); - readonly argumentEntityTypes = Object.values(ArgumentEntityType).filter(value => value !== ArgumentEntityType.RelationQuery) as ArgumentEntityType[]; readonly ArgumentEntityTypeTranslations = ArgumentEntityTypeTranslations; readonly ArgumentType = ArgumentType; readonly DataKeyType = DataKeyType; @@ -103,20 +112,17 @@ export class CalculatedFieldArgumentPanelComponent implements OnInit, AfterViewI private fb: FormBuilder, private cd: ChangeDetectorRef, private popover: TbPopoverComponent, - private store: Store + private store: Store, + private destroyRef: DestroyRef ) { this.observeEntityFilterChanges(); - this.observeEntityTypeChanges(); + this.observeArgumentTypeChanges(); this.observeEntityKeyChanges(); this.observeUpdatePosition(); } get entityType(): ArgumentEntityType { - return this.argumentFormGroup.get('refEntityId').get('entityType').value; - } - - get refEntityIdFormGroup(): FormGroup { - return this.argumentFormGroup.get('refEntityId') as FormGroup; + return this.argumentType.value; } get refEntityKeyFormGroup(): FormGroup { @@ -130,14 +136,18 @@ export class CalculatedFieldArgumentPanelComponent implements OnInit, AfterViewI } ngOnInit(): void { + this.updatedArgumentType(); this.argumentFormGroup.patchValue(this.argument, {emitEvent: false}); this.currentEntityFilter = getCalculatedFieldCurrentEntityFilter(this.entityName, this.entityId); - this.updateEntityFilter(this.argument.refEntityId?.entityType, true); + this.updateEntityFilter(this.entityType, true); + this.updatedRefEntityIdState(this.entityType); this.toggleByEntityKeyType(this.argument.refEntityKey?.type); this.setInitialEntityKeyType(); + this.setInitialEntityType(); + this.setWatchKeyChange(); this.argumentTypes = Object.values(ArgumentType) - .filter(type => type !== ArgumentType.Rolling || this.calculatedFieldType === CalculatedFieldType.SCRIPT); + .filter(type => type !== ArgumentType.Rolling || this.isScript); } ngAfterViewInit(): void { @@ -147,12 +157,11 @@ export class CalculatedFieldArgumentPanelComponent implements OnInit, AfterViewI } saveArgument(): void { - const { refEntityId, ...restConfig } = this.argumentFormGroup.value; - const value = (refEntityId.entityType === ArgumentEntityType.Current ? restConfig : { refEntityId, ...restConfig }) as CalculatedFieldArgumentValue; - if (refEntityId.entityType === ArgumentEntityType.Tenant) { - refEntityId.id = this.tenantId; + const value = this.argumentFormGroup.value as CalculatedFieldArgumentValue; + if (this.entityType === ArgumentEntityType.Tenant) { + value.refEntityId = new TenantId(this.tenantId) as any; } - if (refEntityId.entityType !== ArgumentEntityType.Current && refEntityId.entityType !== ArgumentEntityType.Tenant) { + if (this.entityType !== ArgumentEntityType.Current && this.entityType !== ArgumentEntityType.Tenant) { value.entityName = this.entityNameSubject.value; } if (value.defaultValue) { @@ -166,6 +175,14 @@ export class CalculatedFieldArgumentPanelComponent implements OnInit, AfterViewI this.popover.hide(); } + private updatedArgumentType(): void { + let argumentType = ArgumentEntityType.Current; + if (this.argument.refEntityId?.entityType) { + argumentType = this.argument.refEntityId.entityType; + } + this.argumentType.setValue(argumentType, {emitEvent: false}); + } + private toggleByEntityKeyType(type: ArgumentType): void { const isAttribute = type === ArgumentType.Attribute; const isRolling = type === ArgumentType.Rolling; @@ -205,26 +222,21 @@ export class CalculatedFieldArgumentPanelComponent implements OnInit, AfterViewI private observeEntityFilterChanges(): void { merge( - this.refEntityIdFormGroup.get('entityType').valueChanges, + this.argumentType.valueChanges, this.refEntityKeyFormGroup.get('type').valueChanges, - this.refEntityIdFormGroup.get('id').valueChanges.pipe(filter(Boolean)), + this.argumentFormGroup.get('refEntityId').valueChanges.pipe(filter(Boolean)), this.refEntityKeyFormGroup.get('scope').valueChanges, ) .pipe(debounceTime(50), takeUntilDestroyed()) .subscribe(() => this.updateEntityFilter(this.entityType)); } - private observeEntityTypeChanges(): void { - this.refEntityIdFormGroup.get('entityType').valueChanges + private observeArgumentTypeChanges(): void { + this.argumentType.valueChanges .pipe(distinctUntilChanged(), takeUntilDestroyed()) .subscribe(type => { - this.argumentFormGroup.get('refEntityId').get('id').setValue(''); - const isEntityWithId = type !== ArgumentEntityType.Tenant && type !== ArgumentEntityType.Current; - this.argumentFormGroup.get('refEntityId') - .get('id')[isEntityWithId ? 'enable' : 'disable'](); - if (!isEntityWithId) { - this.entityNameSubject.next(null); - } + this.argumentFormGroup.get('refEntityId').setValue(null); + this.updatedRefEntityIdState(type); if (!this.enableAttributeScopeSelection) { this.refEntityKeyFormGroup.get('scope').setValue(AttributeScope.SERVER_SCOPE); } @@ -247,29 +259,56 @@ export class CalculatedFieldArgumentPanelComponent implements OnInit, AfterViewI } private setInitialEntityKeyType(): void { - if (this.calculatedFieldType === CalculatedFieldType.SIMPLE && this.argument.refEntityKey?.type === ArgumentType.Rolling) { + if (!this.isScript && this.argument.refEntityKey?.type === ArgumentType.Rolling) { const typeControl = this.argumentFormGroup.get('refEntityKey').get('type'); typeControl.setValue(null); typeControl.markAsTouched(); } } + private setInitialEntityType() { + if (!this.argumentEntityTypes.includes(this.entityType)) { + this.argumentType.setValue(null); + this.argumentType.markAsTouched(); + } + } + + private setWatchKeyChange(): void { + if (this.isOutputKey) { + this.refEntityKeyFormGroup.get('key').valueChanges.pipe( + takeUntilDestroyed(this.destroyRef) + ).subscribe((key) => { + if (this.argumentFormGroup.get('argumentName').pristine) { + this.argumentFormGroup.get('argumentName').setValue(key); + } + }); + } + } + private forbiddenArgumentNameValidator(): ValidatorFn { return (control: FormControl) => { const trimmedValue = control.value.trim().toLowerCase(); - const forbiddenArgumentNames = ['ctx', 'e', 'pi']; + const forbiddenArgumentNames = ['ctx', 'e', 'pi', 'propagationCtx']; return forbiddenArgumentNames.includes(trimmedValue) ? { forbiddenName: true } : null; }; } private observeUpdatePosition(): void { merge( - this.refEntityIdFormGroup.get('entityType').valueChanges, + this.argumentType.valueChanges, this.refEntityKeyFormGroup.get('type').valueChanges, this.argumentFormGroup.get('timeWindow').valueChanges, - this.refEntityIdFormGroup.get('id').valueChanges.pipe(filter(Boolean)), + this.argumentFormGroup.get('refEntityId').valueChanges.pipe(filter(Boolean)), ) .pipe(delay(50), takeUntilDestroyed()) .subscribe(() => this.popover.updatePosition()); } + + private updatedRefEntityIdState(type: ArgumentEntityType): void { + const isEntityWithId = !!type && type !== ArgumentEntityType.Tenant && type !== ArgumentEntityType.Current; + this.argumentFormGroup.get('refEntityId')[isEntityWithId ? 'enable' : 'disable'](); + if (!isEntityWithId) { + this.entityNameSubject.next(null); + } + } } diff --git a/ui-ngx/src/app/modules/home/components/calculated-fields/components/simple-configuration/calculated-field-arguments-table.component.html b/ui-ngx/src/app/modules/home/components/calculated-fields/components/calculated-field-arguments/calculated-field-arguments-table.component.html similarity index 89% rename from ui-ngx/src/app/modules/home/components/calculated-fields/components/simple-configuration/calculated-field-arguments-table.component.html rename to ui-ngx/src/app/modules/home/components/calculated-fields/components/calculated-field-arguments/calculated-field-arguments-table.component.html index 8cf040538c..94e2967d23 100644 --- a/ui-ngx/src/app/modules/home/components/calculated-fields/components/simple-configuration/calculated-field-arguments-table.component.html +++ b/ui-ngx/src/app/modules/home/components/calculated-fields/components/calculated-field-arguments/calculated-field-arguments-table.component.html @@ -21,7 +21,7 @@ [matSortActive]="sortOrder.property" [matSortDirection]="sortOrder.direction" matSortDisableClear> -
{{ 'common.name' | translate }}
+
{{ argumentNameColumn | translate }}
@@ -29,7 +29,7 @@ @@ -37,7 +37,7 @@ - + {{ 'entity.entity-type' | translate }} @@ -96,8 +96,7 @@ [matTooltip]="'action.edit' | translate" matTooltipPosition="above"> - - + +
diff --git a/ui-ngx/src/app/modules/home/components/calculated-fields/components/simple-configuration/calculated-field-arguments-table.component.scss b/ui-ngx/src/app/modules/home/components/calculated-fields/components/calculated-field-arguments/calculated-field-arguments-table.component.scss similarity index 95% rename from ui-ngx/src/app/modules/home/components/calculated-fields/components/simple-configuration/calculated-field-arguments-table.component.scss rename to ui-ngx/src/app/modules/home/components/calculated-fields/components/calculated-field-arguments/calculated-field-arguments-table.component.scss index 430958d0f4..6ddb58c51c 100644 --- a/ui-ngx/src/app/modules/home/components/calculated-fields/components/simple-configuration/calculated-field-arguments-table.component.scss +++ b/ui-ngx/src/app/modules/home/components/calculated-fields/components/calculated-field-arguments/calculated-field-arguments-table.component.scss @@ -62,7 +62,7 @@ } .arguments-table { - .mat-mdc-header-row.mat-row-select .mat-mdc-header-cell.entity-type-header { + .mat-mdc-header-row.mat-row-select .mat-mdc-header-cell:nth-child(2) { padding: 0 28px 0 0; } } diff --git a/ui-ngx/src/app/modules/home/components/calculated-fields/components/simple-configuration/calculated-field-arguments-table.component.ts b/ui-ngx/src/app/modules/home/components/calculated-fields/components/calculated-field-arguments/calculated-field-arguments-table.component.ts similarity index 85% rename from ui-ngx/src/app/modules/home/components/calculated-fields/components/simple-configuration/calculated-field-arguments-table.component.ts rename to ui-ngx/src/app/modules/home/components/calculated-fields/components/calculated-field-arguments/calculated-field-arguments-table.component.ts index 03730f3c69..8187c360c1 100644 --- a/ui-ngx/src/app/modules/home/components/calculated-fields/components/simple-configuration/calculated-field-arguments-table.component.ts +++ b/ui-ngx/src/app/modules/home/components/calculated-fields/components/calculated-field-arguments/calculated-field-arguments-table.component.ts @@ -45,17 +45,17 @@ import { } from '@shared/models/calculated-field.models'; import { CalculatedFieldArgumentPanelComponent -} from '@home/components/calculated-fields/components/simple-configuration/calculated-field-argument-panel.component'; +} from '@home/components/calculated-fields/components/calculated-field-arguments/calculated-field-argument-panel.component'; import { MatButton } from '@angular/material/button'; import { TbPopoverService } from '@shared/components/popover.service'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { EntityId } from '@shared/models/id/entity-id'; import { EntityType, entityTypeTranslations } from '@shared/models/entity-type.models'; -import { getEntityDetailsPageURL, isEqual } from '@core/utils'; +import { getEntityDetailsPageURL, isDefined, isEqual } from '@core/utils'; import { TbPopoverComponent } from '@shared/components/popover.component'; import { TbTableDatasource } from '@shared/components/table/table-datasource.abstract'; import { EntityService } from '@core/http/entity.service'; -import { MatSort } from '@angular/material/sort'; +import { MatSort, SortDirection } from '@angular/material/sort'; import { getCurrentAuthState } from '@core/auth/auth.selectors'; import { Store } from '@ngrx/store'; import { AppState } from '@core/core.state'; @@ -85,16 +85,22 @@ export class CalculatedFieldArgumentsTableComponent implements ControlValueAcces @Input() entityId: EntityId; @Input() tenantId: string; @Input() entityName: string; - @Input() calculatedFieldType: CalculatedFieldType; + @Input() isScript: boolean; @ViewChild(MatSort, { static: true }) sort: MatSort; errorText = ''; argumentsFormArray = this.fb.array([]); entityNameMap = new Map(); - sortOrder = { direction: 'asc', property: '' }; + sortOrder: { direction: SortDirection; property: string } = {direction: 'asc', property: ''}; dataSource = new CalculatedFieldArgumentDatasource(); + argumentNameColumn = 'common.name'; + argumentNameColumnCopy = 'calculated-fields.copy-argument-name'; + displayColumns = ['name', 'entityType', 'target', 'type', 'key', 'actions']; + + protected panelAdditionalCtx: Record + readonly entityTypeTranslations = entityTypeTranslations; readonly ArgumentTypeTranslations = ArgumentTypeTranslations; readonly ArgumentEntityType = ArgumentEntityType; @@ -107,14 +113,14 @@ export class CalculatedFieldArgumentsTableComponent implements ControlValueAcces private propagateChange: (argumentsObj: Record) => void = () => {}; constructor( - private fb: FormBuilder, - private popoverService: TbPopoverService, - private viewContainerRef: ViewContainerRef, - private cd: ChangeDetectorRef, - private renderer: Renderer2, - private entityService: EntityService, - private destroyRef: DestroyRef, - private store: Store + protected fb: FormBuilder, + protected popoverService: TbPopoverService, + protected viewContainerRef: ViewContainerRef, + protected cd: ChangeDetectorRef, + protected renderer: Renderer2, + protected entityService: EntityService, + protected destroyRef: DestroyRef, + protected store: Store ) { this.argumentsFormArray.valueChanges.pipe(takeUntilDestroyed()).subscribe(value => { this.updateDataSource(value); @@ -123,9 +129,8 @@ export class CalculatedFieldArgumentsTableComponent implements ControlValueAcces } ngOnChanges(changes: SimpleChanges): void { - if (changes.calculatedFieldType?.previousValue - && changes.calculatedFieldType.currentValue !== changes.calculatedFieldType.previousValue) { - this.argumentsFormArray.updateValueAndValidity(); + if (isDefined(changes.isScript?.previousValue) && changes.isScript.currentValue !== changes.isScript.previousValue) { + this.changeIsScriptMode(); } } @@ -141,7 +146,7 @@ export class CalculatedFieldArgumentsTableComponent implements ControlValueAcces this.propagateChange = fn; } - registerOnTouched(_): void {} + registerOnTouched(_: any): void {} validate(): ValidationErrors | null { this.updateErrorText(); @@ -170,7 +175,7 @@ export class CalculatedFieldArgumentsTableComponent implements ControlValueAcces index, argument, entityId: this.entityId, - calculatedFieldType: this.calculatedFieldType, + isScript: this.isScript, buttonTitle: isExists ? 'action.apply' : 'action.add', tenantId: this.tenantId, entityName: this.entityName, @@ -181,8 +186,8 @@ export class CalculatedFieldArgumentsTableComponent implements ControlValueAcces renderer: this.renderer, componentType: CalculatedFieldArgumentPanelComponent, hostView: this.viewContainerRef, - preferredPlacement: isExists ? ['left', 'leftTop', 'leftBottom'] : ['topRight', 'right', 'rightTop'], - context: ctx, + preferredPlacement: isExists ? ['leftOnly', 'leftTopOnly', 'leftBottomOnly'] : ['rightOnly', 'rightTopOnly', 'rightBottomOnly'], + context: Object.assign(ctx, this.panelAdditionalCtx), isModal: true }); this.popoverComponent.tbComponentRef.instance.argumentsDataApplied.subscribe(({ entityName, ...value }) => { @@ -205,9 +210,8 @@ export class CalculatedFieldArgumentsTableComponent implements ControlValueAcces this.dataSource.loadData(sortedValue); } - private updateErrorText(): void { - if (this.calculatedFieldType === CalculatedFieldType.SIMPLE - && this.argumentsFormArray.controls.some(control => control.value.refEntityKey.type === ArgumentType.Rolling)) { + protected updateErrorText(): void { + if (!this.isScript && this.argumentsFormArray.controls.some(control => control.value.refEntityKey.type === ArgumentType.Rolling)) { this.errorText = 'calculated-fields.hint.arguments-simple-with-rolling'; } else if (this.argumentsFormArray.controls.some(control => control.value.refEntityId?.id === NULL_UUID)) { this.errorText = 'calculated-fields.hint.arguments-entity-not-found'; @@ -236,6 +240,14 @@ export class CalculatedFieldArgumentsTableComponent implements ControlValueAcces return getEntityDetailsPageURL(id, type); } + protected changeIsScriptMode(): void { + this.argumentsFormArray.updateValueAndValidity(); + } + + protected isEditButtonShowBadge(argument: CalculatedFieldArgumentValue): boolean { + return !(argument.refEntityKey.type === ArgumentType.Rolling && !this.isScript) && argument.refEntityId?.id !== NULL_UUID + } + private populateArgumentsFormArray(argumentsObj: Record): void { Object.keys(argumentsObj).forEach(key => { const value: CalculatedFieldArgumentValue = { diff --git a/ui-ngx/src/app/modules/home/components/calculated-fields/components/calculated-field-arguments/calculated-field-arguments-table.module.ts b/ui-ngx/src/app/modules/home/components/calculated-fields/components/calculated-field-arguments/calculated-field-arguments-table.module.ts new file mode 100644 index 0000000000..082001f052 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/calculated-fields/components/calculated-field-arguments/calculated-field-arguments-table.module.ts @@ -0,0 +1,45 @@ +/// +/// 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 { NgModule } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { SharedModule } from '@shared/shared.module'; +import { + CalculatedFieldArgumentPanelComponent +} from '@home/components/calculated-fields/components/calculated-field-arguments/calculated-field-argument-panel.component'; +import { + CalculatedFieldArgumentsTableComponent +} from '@home/components/calculated-fields/components/calculated-field-arguments/calculated-field-arguments-table.component'; +import { + PropagateArgumentsTableComponent +} from '@home/components/calculated-fields/components/calculated-field-arguments/propagate-arguments-table.component'; + +@NgModule({ + imports: [ + CommonModule, + SharedModule, + ], + declarations: [ + CalculatedFieldArgumentPanelComponent, + CalculatedFieldArgumentsTableComponent, + PropagateArgumentsTableComponent + ], + exports: [ + CalculatedFieldArgumentsTableComponent, + PropagateArgumentsTableComponent + ] +}) +export class CalculatedFieldArgumentsTableModule {} diff --git a/ui-ngx/src/app/modules/home/components/calculated-fields/components/calculated-field-arguments/propagate-arguments-table.component.ts b/ui-ngx/src/app/modules/home/components/calculated-fields/components/calculated-field-arguments/propagate-arguments-table.component.ts new file mode 100644 index 0000000000..04d1dbf91b --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/calculated-fields/components/calculated-field-arguments/propagate-arguments-table.component.ts @@ -0,0 +1,116 @@ +/// +/// 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, + DestroyRef, + forwardRef, + OnInit, + Renderer2, + ViewContainerRef, +} from '@angular/core'; +import { FormBuilder, NG_VALIDATORS, NG_VALUE_ACCESSOR, } from '@angular/forms'; +import { TbPopoverService } from '@shared/components/popover.service'; +import { EntityService } from '@core/http/entity.service'; +import { Store } from '@ngrx/store'; +import { AppState } from '@core/core.state'; +import { + CalculatedFieldArgumentsTableComponent +} from '@home/components/calculated-fields/components/calculated-field-arguments/calculated-field-arguments-table.component'; +import { ArgumentEntityType, ArgumentType, CalculatedFieldArgumentValue } from '@shared/models/calculated-field.models'; +import { isDefined } from '@core/utils'; +import { NULL_UUID } from '@shared/models/id/has-uuid'; + +@Component({ + selector: 'tb-propagate-arguments-table', + templateUrl: './calculated-field-arguments-table.component.html', + styleUrls: [`calculated-field-arguments-table.component.scss`], + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => PropagateArgumentsTableComponent), + multi: true + }, + { + provide: NG_VALIDATORS, + useExisting: forwardRef(() => PropagateArgumentsTableComponent), + multi: true + } + ], +}) +export class PropagateArgumentsTableComponent extends CalculatedFieldArgumentsTableComponent implements OnInit { + + constructor( + protected fb: FormBuilder, + protected popoverService: TbPopoverService, + protected viewContainerRef: ViewContainerRef, + protected cd: ChangeDetectorRef, + protected renderer: Renderer2, + protected entityService: EntityService, + protected destroyRef: DestroyRef, + protected store: Store + ) { + super(fb, popoverService, viewContainerRef, cd, renderer, entityService, destroyRef, store) + } + + ngOnInit() { + this.updatedValue(); + } + + protected changeIsScriptMode(): void { + this.updatedValue(); + super.changeIsScriptMode(); + } + + private updatedValue() { + if (this.isScript) { + this.argumentNameColumn = 'common.name'; + this.argumentNameColumnCopy = 'calculated-fields.copy-argument-name'; + this.displayColumns = ['name', 'entityType', 'target', 'type', 'key', 'actions']; + this.panelAdditionalCtx = null; + } else { + this.argumentNameColumn = 'calculated-fields.output-key'; + this.argumentNameColumnCopy = 'calculated-fields.copy-output-key'; + this.displayColumns = ['name', 'type', 'key', 'actions']; + this.panelAdditionalCtx = { + argumentEntityTypes: [ArgumentEntityType.Current], + isOutputKey: true + }; + } + } + + protected isEditButtonShowBadge(argument: CalculatedFieldArgumentValue): boolean { + if (!this.isScript && isDefined(argument?.refEntityId)) { + return false; + } + return super.isEditButtonShowBadge(argument); + } + + protected updateErrorText(): void { + if (!this.isScript && this.argumentsFormArray.controls.some(control => isDefined(control.value?.refEntityId))) { + this.errorText = 'calculated-fields.hint.arguments-propagate-argument-entity-type'; + } else if (!this.isScript && this.argumentsFormArray.controls.some(control => control.value.refEntityKey.type === ArgumentType.Rolling)) { + this.errorText = 'calculated-fields.hint.arguments-propagate-arguments-with-rolling'; + } else if (this.argumentsFormArray.controls.some(control => control.value.refEntityId?.id === NULL_UUID)) { + this.errorText = 'calculated-fields.hint.arguments-entity-not-found'; + } else if (!this.argumentsFormArray.controls.length) { + this.errorText = 'calculated-fields.hint.arguments-empty'; + } else { + this.errorText = ''; + } + } +} 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 222c3ffe91..1d9dcc98f1 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 @@ -67,13 +67,21 @@ } + @case (CalculatedFieldType.PROPAGATION) { + + + } @default { } diff --git a/ui-ngx/src/app/modules/home/components/calculated-fields/components/dialog/calculated-field-dialog.component.ts b/ui-ngx/src/app/modules/home/components/calculated-fields/components/dialog/calculated-field-dialog.component.ts index cf475111c6..ed0d9dd1c3 100644 --- a/ui-ngx/src/app/modules/home/components/calculated-fields/components/dialog/calculated-field-dialog.component.ts +++ b/ui-ngx/src/app/modules/home/components/calculated-fields/components/dialog/calculated-field-dialog.component.ts @@ -83,6 +83,7 @@ export class CalculatedFieldDialogComponent extends DialogComponent { + if (type !== CalculatedFieldType.SIMPLE && type !== CalculatedFieldType.SCRIPT) { + this.fieldFormGroup.get('configuration').setValue(({} as CalculatedFieldConfiguration), {emitEvent: false}); + } + }); + } } diff --git a/ui-ngx/src/app/modules/home/components/calculated-fields/components/geofencing-configuration/geofencing-configuration.component.ts b/ui-ngx/src/app/modules/home/components/calculated-fields/components/geofencing-configuration/geofencing-configuration.component.ts index 67c0fe8749..835a3628ff 100644 --- a/ui-ngx/src/app/modules/home/components/calculated-fields/components/geofencing-configuration/geofencing-configuration.component.ts +++ b/ui-ngx/src/app/modules/home/components/calculated-fields/components/geofencing-configuration/geofencing-configuration.component.ts @@ -114,7 +114,7 @@ export class GeofencingConfigurationComponent implements ControlValueAccessor, V } validate(): ValidationErrors | null { - return this.geofencingConfiguration.valid ? null : { geofencingConfigError: false }; + return this.geofencingConfiguration.valid || this.geofencingConfiguration.status === "DISABLED" ? null : { geofencingConfigError: false }; } writeValue(config: CalculatedFieldGeofencingConfiguration): void { diff --git a/ui-ngx/src/app/modules/home/components/calculated-fields/components/geofencing-configuration/geofencing-configuration.module.ts b/ui-ngx/src/app/modules/home/components/calculated-fields/components/geofencing-configuration/geofencing-configuration.module.ts index 8fc52d2940..e270dd29cb 100644 --- a/ui-ngx/src/app/modules/home/components/calculated-fields/components/geofencing-configuration/geofencing-configuration.module.ts +++ b/ui-ngx/src/app/modules/home/components/calculated-fields/components/geofencing-configuration/geofencing-configuration.module.ts @@ -28,7 +28,7 @@ import { } from '@home/components/calculated-fields/components/geofencing-configuration/geofencing-configuration.component'; import { CalculatedFieldOutputModule -} from '@home/components/calculated-fields/components/output/caclculate-field-output.module'; +} from '@home/components/calculated-fields/components/output/calculated-field-output.module'; @NgModule({ imports: [ diff --git a/ui-ngx/src/app/modules/home/components/calculated-fields/components/output/caclculate-field-output.module.ts b/ui-ngx/src/app/modules/home/components/calculated-fields/components/output/calculated-field-output.module.ts similarity index 100% rename from ui-ngx/src/app/modules/home/components/calculated-fields/components/output/caclculate-field-output.module.ts rename to ui-ngx/src/app/modules/home/components/calculated-fields/components/output/calculated-field-output.module.ts diff --git a/ui-ngx/src/app/modules/home/components/calculated-fields/components/propagation-configuration/propagation-configuration.component.html b/ui-ngx/src/app/modules/home/components/calculated-fields/components/propagation-configuration/propagation-configuration.component.html new file mode 100644 index 0000000000..01cc5a542b --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/calculated-fields/components/propagation-configuration/propagation-configuration.component.html @@ -0,0 +1,99 @@ + +
+
+
+ {{ 'calculated-fields.propagation-path-related-entities' | translate }} +
+
+ + {{ 'calculated-fields.direction' | translate }} + + @for (direction of Directions; track direction) { + {{ PropagationDirectionTranslations.get(direction) | translate }} + } + + + + +
+
+
+
+
+ {{ 'calculated-fields.data-propagate' | translate }} +
+ + {{ 'calculated-fields.propagate-type.arguments-only' | translate }} + {{ 'calculated-fields.propagate-type.expression-result' | translate }} + +
+ +
+
+
+ {{ 'calculated-fields.expression' | translate }} +
+
+ +
{{ 'api-usage.tbel' | translate }} +
+ +
+
+ +
+
+
+ + +
diff --git a/ui-ngx/src/app/modules/home/components/calculated-fields/components/propagation-configuration/propagation-configuration.component.ts b/ui-ngx/src/app/modules/home/components/calculated-fields/components/propagation-configuration/propagation-configuration.component.ts new file mode 100644 index 0000000000..0dfe799bc8 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/calculated-fields/components/propagation-configuration/propagation-configuration.component.ts @@ -0,0 +1,174 @@ +/// +/// 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 } from '@angular/core'; +import { + ControlValueAccessor, + FormBuilder, + NG_VALIDATORS, + NG_VALUE_ACCESSOR, + ValidationErrors, + Validator, + Validators +} from '@angular/forms'; +import { EntityId } from '@shared/models/id/entity-id'; +import { Observable, of } from 'rxjs'; +import { + calculatedFieldDefaultScript, + CalculatedFieldOutput, + CalculatedFieldPropagationConfiguration, + CalculatedFieldType, + getCalculatedFieldArgumentsEditorCompleter, + getCalculatedFieldArgumentsHighlights, + OutputType, + PropagationDirectionTranslations, + PropagationWithExpression +} from '@shared/models/calculated-field.models'; +import { AttributeScope } from '@shared/models/telemetry/telemetry.models'; +import { map } from 'rxjs/operators'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; +import { ScriptLanguage } from '@app/shared/models/rule-node.models'; +import { EntitySearchDirection } from '@shared/models/relation.models'; + +@Component({ + selector: 'tb-propagation-configuration', + templateUrl: './propagation-configuration.component.html', + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => PropagationConfigurationComponent), + multi: true + }, + { + provide: NG_VALIDATORS, + useExisting: forwardRef(() => PropagationConfigurationComponent), + multi: true + } + ], +}) +export class PropagationConfigurationComponent implements ControlValueAccessor, Validator { + + @Input({required: true}) + entityId: EntityId; + + @Input({required: true}) + tenantId: string; + + @Input({required: true}) + entityName: string; + + @Input({required: true}) + testScript: () => Observable; + + propagateConfiguration = this.fb.group({ + arguments: this.fb.control({}), + applyExpressionToResolvedArguments: [false], + direction: [EntitySearchDirection.TO, Validators.required], + relationType: ['Contains', Validators.required], + expression: [calculatedFieldDefaultScript], + output: this.fb.control({ + scope: AttributeScope.SERVER_SCOPE, + type: OutputType.Timeseries, + }), + }); + + readonly ScriptLanguage = ScriptLanguage; + readonly CalculatedFieldType = CalculatedFieldType; + readonly OutputType = OutputType; + readonly Directions = Object.values(EntitySearchDirection) as Array; + readonly PropagationDirectionTranslations = PropagationDirectionTranslations; + + functionArgs$ = this.propagateConfiguration.get('arguments').valueChanges.pipe( + map(argumentsObj => ['ctx', ...Object.keys(argumentsObj)]) + ); + + argumentsEditorCompleter$ = this.propagateConfiguration.get('arguments').valueChanges.pipe( + map(argumentsObj => getCalculatedFieldArgumentsEditorCompleter(argumentsObj ?? {})) + ); + + argumentsHighlightRules$ = this.propagateConfiguration.get('arguments').valueChanges.pipe( + map(argumentsObj => getCalculatedFieldArgumentsHighlights(argumentsObj)) + ); + + private propagateChange: (config: CalculatedFieldPropagationConfiguration) => void = () => { }; + + constructor(private fb: FormBuilder) { + this.propagateConfiguration.get('applyExpressionToResolvedArguments').valueChanges.pipe( + takeUntilDestroyed() + ).subscribe(() => { + this.updatedFormWithScript(); + }) + + this.propagateConfiguration.valueChanges.pipe( + takeUntilDestroyed() + ).subscribe((value: CalculatedFieldPropagationConfiguration) => { + this.updatedModel(value); + }) + } + + validate(): ValidationErrors | null { + return this.propagateConfiguration.valid || this.propagateConfiguration.status === "DISABLED" ? null : {invalidPropagateConfig: false}; + } + + writeValue(value: PropagationWithExpression): void { + value.expression = value.expression ?? calculatedFieldDefaultScript; + this.propagateConfiguration.patchValue(value, {emitEvent: false}); + this.updatedFormWithScript(); + setTimeout(() => { + this.propagateConfiguration.get('arguments').updateValueAndValidity({onlySelf: true}); + }); + } + + registerOnChange(fn: (config: CalculatedFieldPropagationConfiguration) => void): void { + this.propagateChange = fn; + } + + registerOnTouched(_: any): void { } + + setDisabledState(isDisabled: boolean): void { + if (isDisabled) { + this.propagateConfiguration.disable({emitEvent: false}); + } else { + this.propagateConfiguration.enable({emitEvent: false}); + this.updatedFormWithScript(); + } + } + + onTestScript() { + this.testScript().subscribe((expression) => { + this.propagateConfiguration.get('expression').setValue(expression); + this.propagateConfiguration.get('expression').markAsDirty(); + }) + } + + fetchOptions(searchText: string): Observable> { + const search = searchText ? searchText?.toLowerCase() : ''; + return of(['Contains', 'Manages']).pipe(map(name => name?.filter(option => option.toLowerCase().includes(search)))); + } + + private updatedModel(value: CalculatedFieldPropagationConfiguration): void { + value.type = CalculatedFieldType.PROPAGATION; + this.propagateChange(value); + } + + private updatedFormWithScript() { + if (this.propagateConfiguration.get('applyExpressionToResolvedArguments').value) { + this.propagateConfiguration.get('expression').enable({emitEvent: false}); + } else { + this.propagateConfiguration.get('expression').disable({emitEvent: false}); + } + } +} diff --git a/ui-ngx/src/app/modules/home/components/calculated-fields/components/propagation-configuration/propagation-configuration.module.ts b/ui-ngx/src/app/modules/home/components/calculated-fields/components/propagation-configuration/propagation-configuration.module.ts new file mode 100644 index 0000000000..83dc4badf9 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/calculated-fields/components/propagation-configuration/propagation-configuration.module.ts @@ -0,0 +1,44 @@ +/// +/// 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 { NgModule } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { SharedModule } from '@shared/shared.module'; +import { + CalculatedFieldOutputModule +} from '@home/components/calculated-fields/components/output/calculated-field-output.module'; +import { + CalculatedFieldArgumentsTableModule +} from '@home/components/calculated-fields/components/calculated-field-arguments/calculated-field-arguments-table.module'; +import { + PropagationConfigurationComponent +} from '@home/components/calculated-fields/components/propagation-configuration/propagation-configuration.component'; + +@NgModule({ + imports: [ + CommonModule, + SharedModule, + CalculatedFieldOutputModule, + CalculatedFieldArgumentsTableModule, + ], + declarations: [ + PropagationConfigurationComponent, + ], + exports: [ + PropagationConfigurationComponent, + ] +}) +export class PropagationConfigurationModule { } diff --git a/ui-ngx/src/app/modules/home/components/calculated-fields/components/simple-configuration/simple-configuration.component.html b/ui-ngx/src/app/modules/home/components/calculated-fields/components/simple-configuration/simple-configuration.component.html index a4c37bcdee..a44e4362af 100644 --- a/ui-ngx/src/app/modules/home/components/calculated-fields/components/simple-configuration/simple-configuration.component.html +++ b/ui-ngx/src/app/modules/home/components/calculated-fields/components/simple-configuration/simple-configuration.component.html @@ -22,7 +22,7 @@ [entityId]="entityId" [tenantId]="tenantId" [entityName]="entityName" - [calculatedFieldType]="(isScript ? CalculatedFieldType.SCRIPT : CalculatedFieldType.SIMPLE)" /> + [isScript]="isScript" />
diff --git a/ui-ngx/src/app/modules/home/components/calculated-fields/components/simple-configuration/simple-configuration.component.ts b/ui-ngx/src/app/modules/home/components/calculated-fields/components/simple-configuration/simple-configuration.component.ts index 42137cac1c..89b720bd7e 100644 --- a/ui-ngx/src/app/modules/home/components/calculated-fields/components/simple-configuration/simple-configuration.component.ts +++ b/ui-ngx/src/app/modules/home/components/calculated-fields/components/simple-configuration/simple-configuration.component.ts @@ -66,17 +66,17 @@ export class SimpleConfigurationComponent implements ControlValueAccessor, Valid @Input() isScript: boolean; - @Input() + @Input({required: true}) entityId: EntityId; - @Input() + @Input({required: true}) tenantId: string; - @Input() + @Input({required: true}) entityName: string; - @Input() - testScript$: Observable; + @Input({required: true}) + testScript: () => Observable; simpleConfiguration = this.fb.group({ arguments: this.fb.control({}), @@ -92,7 +92,6 @@ export class SimpleConfigurationComponent implements ControlValueAccessor, Valid }); readonly ScriptLanguage = ScriptLanguage; - readonly CalculatedFieldType = CalculatedFieldType; readonly OutputType = OutputType; functionArgs$ = this.simpleConfiguration.get('arguments').valueChanges.pipe( @@ -141,19 +140,21 @@ export class SimpleConfigurationComponent implements ControlValueAccessor, Valid } validate(): ValidationErrors | null { - return this.simpleConfiguration.valid ? null : {invalidSimpleConfig: false}; + return this.simpleConfiguration.valid || this.simpleConfiguration.status === "DISABLED" ? null : {invalidSimpleConfig: false}; } writeValue(value: SimpeConfiguration): void { const formValue: any = deepClone(value); if (this.isScript) { - formValue.expressionSCRIPT = formValue.expression; + formValue.expressionSCRIPT = formValue.expression ?? calculatedFieldDefaultScript; } else { formValue.expressionSIMPLE = formValue.expression; } this.simpleConfiguration.patchValue(formValue, {emitEvent: false}); - this.simpleConfiguration.get('arguments').updateValueAndValidity({onlySelf: true}); this.updatedFormWithScript(); + setTimeout(() => { + this.simpleConfiguration.get('arguments').updateValueAndValidity({onlySelf: true}); + }); } registerOnChange(fn: (config: SimpeConfiguration) => void): void { @@ -173,7 +174,7 @@ export class SimpleConfigurationComponent implements ControlValueAccessor, Valid } onTestScript() { - this.testScript$?.subscribe((expression) => { + this.testScript().subscribe((expression) => { this.simpleConfiguration.get('expressionSCRIPT').setValue(expression); this.simpleConfiguration.get('expressionSCRIPT').markAsDirty(); }) diff --git a/ui-ngx/src/app/modules/home/components/calculated-fields/components/simple-configuration/simple-configuration.module.ts b/ui-ngx/src/app/modules/home/components/calculated-fields/components/simple-configuration/simple-configuration.module.ts index aee32a0916..2e5e14426e 100644 --- a/ui-ngx/src/app/modules/home/components/calculated-fields/components/simple-configuration/simple-configuration.module.ts +++ b/ui-ngx/src/app/modules/home/components/calculated-fields/components/simple-configuration/simple-configuration.module.ts @@ -20,26 +20,22 @@ import { SharedModule } from '@shared/shared.module'; import { SimpleConfigurationComponent } from '@home/components/calculated-fields/components/simple-configuration/simple-configuration.component'; -import { - CalculatedFieldArgumentPanelComponent -} from '@home/components/calculated-fields/components/simple-configuration/calculated-field-argument-panel.component'; import { CalculatedFieldOutputModule -} from '@home/components/calculated-fields/components/output/caclculate-field-output.module'; +} from '@home/components/calculated-fields/components/output/calculated-field-output.module'; import { - CalculatedFieldArgumentsTableComponent -} from '@home/components/calculated-fields/components/simple-configuration/calculated-field-arguments-table.component'; + CalculatedFieldArgumentsTableModule +} from '@home/components/calculated-fields/components/calculated-field-arguments/calculated-field-arguments-table.module'; @NgModule({ imports: [ CommonModule, SharedModule, CalculatedFieldOutputModule, + CalculatedFieldArgumentsTableModule, ], declarations: [ SimpleConfigurationComponent, - CalculatedFieldArgumentPanelComponent, - CalculatedFieldArgumentsTableComponent ], exports: [ SimpleConfigurationComponent diff --git a/ui-ngx/src/app/shared/models/calculated-field.models.ts b/ui-ngx/src/app/shared/models/calculated-field.models.ts index 8a2c34d036..8294a776df 100644 --- a/ui-ngx/src/app/shared/models/calculated-field.models.ts +++ b/ui-ngx/src/app/shared/models/calculated-field.models.ts @@ -50,10 +50,16 @@ export interface CalculatedFieldGeofencing extends BaseCalculatedField { configuration: CalculatedFieldGeofencingConfiguration; } +export interface CalculatedFieldPropagation extends BaseCalculatedField { + type: CalculatedFieldType.PROPAGATION; + configuration: CalculatedFieldPropagationConfiguration; +} + export type CalculatedField = | CalculatedFieldSimple | CalculatedFieldScript - | CalculatedFieldGeofencing; + | CalculatedFieldGeofencing + | CalculatedFieldPropagation; export enum CalculatedFieldType { SIMPLE = 'SIMPLE', @@ -74,30 +80,52 @@ export const CalculatedFieldTypeTranslations = new Map; + expression: string; + arguments: Record; output: CalculatedFieldSimpleOutput; } export interface CalculatedFieldScriptConfiguration { type: CalculatedFieldType.SCRIPT; - expression?: string; - arguments?: Record; + expression: string; + arguments: Record; output: CalculatedFieldOutput; } export interface CalculatedFieldGeofencingConfiguration { type: CalculatedFieldType.GEOFENCING; - zoneGroups?: Record; - scheduledUpdateEnabled?: boolean; + zoneGroups: Record; + scheduledUpdateEnabled: boolean; scheduledUpdateInterval?: number; output: CalculatedFieldOutput; } +interface BasePropagationConfiguration { + type: CalculatedFieldType.PROPAGATION; + direction: EntitySearchDirection; + relationType: string; + arguments: Record; + output: CalculatedFieldOutput; +} + +export interface PropagationWithNoExpression extends BasePropagationConfiguration { + applyExpressionToResolvedArguments: false; +} + +export interface PropagationWithExpression extends BasePropagationConfiguration { + applyExpressionToResolvedArguments: true; + expression: string; +} + +export type CalculatedFieldPropagationConfiguration = + | PropagationWithNoExpression + | PropagationWithExpression; + export interface CalculatedFieldOutput { type: OutputType; scope?: AttributeScope; @@ -156,6 +184,13 @@ export const GeofencingDirectionLevelTranslations = new Map( + [ + [EntitySearchDirection.FROM, 'calculated-fields.direction-down-child'], + [EntitySearchDirection.TO, 'calculated-fields.direction-up-parent'], + ] +) + export enum ArgumentType { Attribute = 'ATTRIBUTE', LatestTelemetry = 'TS_LATEST', diff --git a/ui-ngx/src/assets/locale/locale.constant-en_US.json b/ui-ngx/src/assets/locale/locale.constant-en_US.json index 24266ad192..7f728c0b63 100644 --- a/ui-ngx/src/assets/locale/locale.constant-en_US.json +++ b/ui-ngx/src/assets/locale/locale.constant-en_US.json @@ -1064,6 +1064,7 @@ "datasource": "Datasource", "add-argument": "Add argument", "test-script-function": "Test script function", + "test-expression-function": "Test expression function", "no-arguments": "No arguments configured", "argument-settings": "Argument settings", "argument-current": "Current entity", @@ -1139,14 +1140,26 @@ "level": "Level", "direction-level": "Direction", "direction-up": "Up", + "direction-up-parent": "Up to parent", "direction-down": "Down", + "direction-down-child": "Down to child", "add-level": "Add level", "delete-level": "Delete level", "no-level": "No level configured", "levels-required": "At least one level must be configured.", "max-allowed-levels-error": "Relation level exceeds the maximum allowed.", + "propagation-path-related-entities": "Propagation path to related entities", + "propagate-type": { + "arguments-only": "Arguments only", + "expression-result": "Expression result" + }, + "data-propagate": "Data to propagate", + "output-key": "Output key", + "copy-output-key": "Copy output key", "hint": { "arguments-simple-with-rolling": "Simple type calculated field should not contain keys with time series rolling type.", + "arguments-propagate-arguments-with-rolling": "'Time series rolling' type is incompatible with 'Arguments only' propagation.", + "arguments-propagate-argument-entity-type": "Entity type is incompatible with 'Arguments only' propagation.", "arguments-empty": "Arguments should not be empty.", "expression-required": "Expression is required.", "expression-invalid": "Expression is invalid", @@ -1156,6 +1169,12 @@ "argument-name-duplicate": "Argument with such name already exists.", "argument-name-max-length": "Argument name should be less than 256 characters.", "argument-name-forbidden": "Argument name is reserved and cannot be used.", + "output-key-required": "Output key is required.", + "output-key-pattern": "Output key is invalid.", + "output-key-duplicate": "Key with such name already exists.", + "output-key-max-length": "Output key should be less than 256 characters.", + "output-key-forbidden": "Output key is reserved and cannot be used.", + "entity-type-required": "Entity type is required", "name-required": "Mame is required.", "name-pattern": "Name is invalid.", "name-duplicate": "Name with such name already exists.", @@ -1181,7 +1200,9 @@ "max-geofencing-zone": "Maximum number of geofencing zones reached.", "zone-group-refresh-interval": "Defines how often zone groups configured via related entities are refreshed.", "zone-group-refresh-interval-required": "Zone groups refresh interval is required.", - "zone-group-refresh-interval-min": "Zone group refresh interval should be at least {{ min }} second." + "zone-group-refresh-interval-min": "Zone group refresh interval should be at least {{ min }} second.", + "propagation-path-related-entities": "Defines a direct, single-level path to a related entity based on the selected direction and relation type.", + "data-propagate": "Defines the data to be propagated from the arguments configured below. 'Arguments only' uses the retrieved data directly, while 'Expression result' calculates a new value from that data." } }, "ai-models": { From 6da6f846521dfd2e4daa7fe57aa5ebfaee9a9f8d Mon Sep 17 00:00:00 2001 From: Vladyslav_Prykhodko Date: Thu, 16 Oct 2025 13:58:19 +0300 Subject: [PATCH 35/43] UI: refactor cf module --- .../calculated-fields/calculated-field.module.ts | 11 ++--------- .../modules/home/components/home-components.module.ts | 9 +++++++++ .../home/pages/asset-profile/asset-profile.module.ts | 2 -- .../src/app/modules/home/pages/asset/asset.module.ts | 2 -- .../pages/device-profile/device-profile.module.ts | 2 -- .../app/modules/home/pages/device/device.module.ts | 2 -- 6 files changed, 11 insertions(+), 17 deletions(-) diff --git a/ui-ngx/src/app/modules/home/components/calculated-fields/calculated-field.module.ts b/ui-ngx/src/app/modules/home/components/calculated-fields/calculated-field.module.ts index 6cb6a907b7..e0db7a6eef 100644 --- a/ui-ngx/src/app/modules/home/components/calculated-fields/calculated-field.module.ts +++ b/ui-ngx/src/app/modules/home/components/calculated-fields/calculated-field.module.ts @@ -17,13 +17,9 @@ import { NgModule } from '@angular/core'; import { CommonModule } from '@angular/common'; import { SharedModule } from '@shared/shared.module'; -import { CalculatedFieldsTableComponent } from '@home/components/calculated-fields/calculated-fields-table.component'; import { CalculatedFieldDialogComponent } from '@home/components/calculated-fields/components/dialog/calculated-field-dialog.component'; -import { - CalculatedFieldDebugDialogComponent -} from '@home/components/calculated-fields/components/debug-dialog/calculated-field-debug-dialog.component'; import { CalculatedFieldScriptTestDialogComponent } from '@home/components/calculated-fields/components/test-dialog/calculated-field-script-test-dialog.component'; @@ -33,7 +29,6 @@ import { import { EntityDebugSettingsButtonComponent } from '@home/components/entity/debug/entity-debug-settings-button.component'; -import { HomeComponentsModule } from '@home/components/home-components.module'; import { GeofencingConfigurationModule } from '@home/components/calculated-fields/components/geofencing-configuration/geofencing-configuration.module'; @@ -46,9 +41,7 @@ import { @NgModule({ declarations: [ - CalculatedFieldsTableComponent, CalculatedFieldDialogComponent, - CalculatedFieldDebugDialogComponent, CalculatedFieldScriptTestDialogComponent, CalculatedFieldTestArgumentsComponent, ], @@ -57,12 +50,12 @@ import { SharedModule, GeofencingConfigurationModule, EntityDebugSettingsButtonComponent, - HomeComponentsModule, SimpleConfigurationModule, PropagationConfigurationModule, ], exports: [ - CalculatedFieldsTableComponent, + CalculatedFieldDialogComponent, + CalculatedFieldScriptTestDialogComponent, ] }) export class CalculatedFieldsModule {} diff --git a/ui-ngx/src/app/modules/home/components/home-components.module.ts b/ui-ngx/src/app/modules/home/components/home-components.module.ts index 2c9c08aeb3..e15d466b7a 100644 --- a/ui-ngx/src/app/modules/home/components/home-components.module.ts +++ b/ui-ngx/src/app/modules/home/components/home-components.module.ts @@ -190,6 +190,11 @@ import { CheckConnectivityDialogComponent } from '@home/components/ai-model/chec import { AIModelDialogComponent } from '@home/components/ai-model/ai-model-dialog.component'; import { ResourcesDialogComponent } from "@home/components/resources/resources-dialog.component"; import { ResourcesLibraryComponent } from "@home/components/resources/resources-library.component"; +import { CalculatedFieldsTableComponent } from '@home/components/calculated-fields/calculated-fields-table.component'; +import { + CalculatedFieldDebugDialogComponent +} from '@home/components/calculated-fields/components/debug-dialog/calculated-field-debug-dialog.component'; +import { CalculatedFieldsModule } from '@home/components/calculated-fields/calculated-field.module'; @NgModule({ declarations: @@ -202,6 +207,8 @@ import { ResourcesLibraryComponent } from "@home/components/resources/resources- EntityDetailsPageComponent, AuditLogTableComponent, AuditLogDetailsDialogComponent, + CalculatedFieldsTableComponent, + CalculatedFieldDebugDialogComponent, EventContentDialogComponent, EventTableHeaderComponent, EventTableComponent, @@ -343,6 +350,7 @@ import { ResourcesLibraryComponent } from "@home/components/resources/resources- CommonModule, SharedModule, SharedHomeComponentsModule, + CalculatedFieldsModule, WidgetConfigComponentsModule, BasicWidgetConfigModule, Lwm2mProfileComponentsModule, @@ -360,6 +368,7 @@ import { ResourcesLibraryComponent } from "@home/components/resources/resources- EntityDetailsPanelComponent, EntityDetailsPageComponent, AuditLogTableComponent, + CalculatedFieldsTableComponent, EventTableComponent, EdgeDownlinkTableHeaderComponent, EdgeDownlinkTableComponent, diff --git a/ui-ngx/src/app/modules/home/pages/asset-profile/asset-profile.module.ts b/ui-ngx/src/app/modules/home/pages/asset-profile/asset-profile.module.ts index c174a2b97b..fb10712d57 100644 --- a/ui-ngx/src/app/modules/home/pages/asset-profile/asset-profile.module.ts +++ b/ui-ngx/src/app/modules/home/pages/asset-profile/asset-profile.module.ts @@ -20,7 +20,6 @@ import { SharedModule } from '@shared/shared.module'; import { HomeComponentsModule } from '@modules/home/components/home-components.module'; import { AssetProfileTabsComponent } from './asset-profile-tabs.component'; import { AssetProfileRoutingModule } from './asset-profile-routing.module'; -import { CalculatedFieldsModule } from '@home/components/calculated-fields/calculated-field.module'; @NgModule({ declarations: [ @@ -30,7 +29,6 @@ import { CalculatedFieldsModule } from '@home/components/calculated-fields/calcu CommonModule, SharedModule, HomeComponentsModule, - CalculatedFieldsModule, AssetProfileRoutingModule, ] }) diff --git a/ui-ngx/src/app/modules/home/pages/asset/asset.module.ts b/ui-ngx/src/app/modules/home/pages/asset/asset.module.ts index 44fa22520f..17c8317fe2 100644 --- a/ui-ngx/src/app/modules/home/pages/asset/asset.module.ts +++ b/ui-ngx/src/app/modules/home/pages/asset/asset.module.ts @@ -23,7 +23,6 @@ import { AssetTableHeaderComponent } from './asset-table-header.component'; import { AssetRoutingModule } from './asset-routing.module'; import { HomeComponentsModule } from '@modules/home/components/home-components.module'; import { AssetTabsComponent } from '@home/pages/asset/asset-tabs.component'; -import { CalculatedFieldsModule } from '@home/components/calculated-fields/calculated-field.module'; @NgModule({ declarations: [ @@ -36,7 +35,6 @@ import { CalculatedFieldsModule } from '@home/components/calculated-fields/calcu SharedModule, HomeComponentsModule, HomeDialogsModule, - CalculatedFieldsModule, AssetRoutingModule, ] }) diff --git a/ui-ngx/src/app/modules/home/pages/device-profile/device-profile.module.ts b/ui-ngx/src/app/modules/home/pages/device-profile/device-profile.module.ts index 12b68f4ab4..76d15d00f1 100644 --- a/ui-ngx/src/app/modules/home/pages/device-profile/device-profile.module.ts +++ b/ui-ngx/src/app/modules/home/pages/device-profile/device-profile.module.ts @@ -20,7 +20,6 @@ import { SharedModule } from '@shared/shared.module'; import { HomeComponentsModule } from '@modules/home/components/home-components.module'; import { DeviceProfileTabsComponent } from './device-profile-tabs.component'; import { DeviceProfileRoutingModule } from './device-profile-routing.module'; -import { CalculatedFieldsModule } from '@home/components/calculated-fields/calculated-field.module'; @NgModule({ declarations: [ @@ -30,7 +29,6 @@ import { CalculatedFieldsModule } from '@home/components/calculated-fields/calcu CommonModule, SharedModule, HomeComponentsModule, - CalculatedFieldsModule, DeviceProfileRoutingModule ] }) diff --git a/ui-ngx/src/app/modules/home/pages/device/device.module.ts b/ui-ngx/src/app/modules/home/pages/device/device.module.ts index 8681ff7fe7..4c74da0f89 100644 --- a/ui-ngx/src/app/modules/home/pages/device/device.module.ts +++ b/ui-ngx/src/app/modules/home/pages/device/device.module.ts @@ -36,7 +36,6 @@ import { SnmpDeviceTransportConfigurationComponent } from './data/snmp-device-tr import { DeviceCredentialsModule } from '@home/components/device/device-credentials.module'; import { DeviceProfileCommonModule } from '@home/components/profile/device/common/device-profile-common.module'; import { DeviceCheckConnectivityDialogComponent } from './device-check-connectivity-dialog.component'; -import { CalculatedFieldsModule } from '@home/components/calculated-fields/calculated-field.module'; @NgModule({ declarations: [ @@ -62,7 +61,6 @@ import { CalculatedFieldsModule } from '@home/components/calculated-fields/calcu HomeDialogsModule, DeviceCredentialsModule, DeviceProfileCommonModule, - CalculatedFieldsModule, DeviceRoutingModule ] }) From 57372035cc255e957c2e3748e6f27df20ef78f36 Mon Sep 17 00:00:00 2001 From: dshvaika Date: Thu, 16 Oct 2025 16:34:21 +0300 Subject: [PATCH 36/43] Fixed typo --- .../thingsboard/server/dao/relation/BaseRelationService.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dao/src/main/java/org/thingsboard/server/dao/relation/BaseRelationService.java b/dao/src/main/java/org/thingsboard/server/dao/relation/BaseRelationService.java index eecb31713e..4e4e86a6d6 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/relation/BaseRelationService.java +++ b/dao/src/main/java/org/thingsboard/server/dao/relation/BaseRelationService.java @@ -511,7 +511,7 @@ public class BaseRelationService implements RelationService { validateId(tenantId, id -> "Invalid tenant id: " + id); validate(relationPathQuery); int limit = (int) apiLimitService.getLimit(tenantId, DefaultTenantProfileConfiguration::getMaxRelatedEntitiesToReturnPerCfArgument); - validatePositiveNumber(limit, "Invalid entities limit: " + limit); + validatePositiveNumber(limit, "Max related entities limit for relation path query must be positive!"); if (relationPathQuery.levels().size() == 1) { RelationPathLevel relationPathLevel = relationPathQuery.levels().get(0); var relationsFuture = switch (relationPathLevel.direction()) { From 58a4600342d65281eaac29adc3adf6f003e82314 Mon Sep 17 00:00:00 2001 From: dshvaika Date: Thu, 16 Oct 2025 16:36:26 +0300 Subject: [PATCH 37/43] fix typo in upgrade script --- application/src/main/data/upgrade/basic/schema_update.sql | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/application/src/main/data/upgrade/basic/schema_update.sql b/application/src/main/data/upgrade/basic/schema_update.sql index 2be18eaf35..0862a33926 100644 --- a/application/src/main/data/upgrade/basic/schema_update.sql +++ b/application/src/main/data/upgrade/basic/schema_update.sql @@ -40,7 +40,7 @@ SET profile_data = jsonb_set( WHEN (profile_data -> 'configuration') ? 'maxRelatedEntitiesToReturnPerCfArgument' THEN NULL ELSE to_jsonb(100) - END, + END ) ), false From 6b7faf94a9257e11e4068c61b173046aa2c50bcc Mon Sep 17 00:00:00 2001 From: Vladyslav_Prykhodko Date: Thu, 16 Oct 2025 19:20:38 +0300 Subject: [PATCH 38/43] UI: Add new property in tenant profile --- ...eofencing-zone-groups-panel.component.html | 2 +- ...enant-profile-configuration.component.html | 216 ++++++++++-------- ...-tenant-profile-configuration.component.ts | 216 ++++++++---------- ui-ngx/src/app/shared/models/tenant.model.ts | 11 + .../assets/locale/locale.constant-en_US.json | 4 + 5 files changed, 232 insertions(+), 217 deletions(-) diff --git a/ui-ngx/src/app/modules/home/components/calculated-fields/components/geofencing-configuration/calculated-field-geofencing-zone-groups-panel.component.html b/ui-ngx/src/app/modules/home/components/calculated-fields/components/geofencing-configuration/calculated-field-geofencing-zone-groups-panel.component.html index a460deaf4e..1e8483f1cc 100644 --- a/ui-ngx/src/app/modules/home/components/calculated-fields/components/geofencing-configuration/calculated-field-geofencing-zone-groups-panel.component.html +++ b/ui-ngx/src/app/modules/home/components/calculated-fields/components/geofencing-configuration/calculated-field-geofencing-zone-groups-panel.component.html @@ -176,7 +176,7 @@
- @if (entityFilter.singleEntity.id) { + @if (entityFilter.singleEntity?.id) {
{{ 'calculated-fields.perimeter-attribute-key' | translate }} diff --git a/ui-ngx/src/app/modules/home/components/profile/tenant/default-tenant-profile-configuration.component.html b/ui-ngx/src/app/modules/home/components/profile/tenant/default-tenant-profile-configuration.component.html index 1540a3c87f..d53cacbd83 100644 --- a/ui-ngx/src/app/modules/home/components/profile/tenant/default-tenant-profile-configuration.component.html +++ b/ui-ngx/src/app/modules/home/components/profile/tenant/default-tenant-profile-configuration.component.html @@ -15,7 +15,7 @@ limitations under the License. --> -
+
{{ 'tenant-profile.entities' | translate }} tenant-profile.unlimited @@ -26,10 +26,10 @@ - + {{ 'tenant-profile.maximum-devices-required' | translate}} - + {{ 'tenant-profile.maximum-devices-range' | translate}} @@ -39,10 +39,10 @@ - + {{ 'tenant-profile.maximum-dashboards-required' | translate}} - + {{ 'tenant-profile.maximum-dashboards-range' | translate}} @@ -54,10 +54,10 @@ - + {{ 'tenant-profile.maximum-assets-required' | translate}} - + {{ 'tenant-profile.maximum-assets-range' | translate}} @@ -67,10 +67,10 @@ - + {{ 'tenant-profile.maximum-users-required' | translate}} - + {{ 'tenant-profile.maximum-users-range' | translate}} @@ -89,10 +89,10 @@ - + {{ 'tenant-profile.maximum-customers-required' | translate}} - + {{ 'tenant-profile.maximum-customers-range' | translate}} @@ -102,10 +102,10 @@ - + {{ 'tenant-profile.maximum-rule-chains-required' | translate}} - + {{ 'tenant-profile.maximum-rule-chains-range' | translate}} @@ -117,10 +117,10 @@ - + {{ 'tenant-profile.maximum-edges-required' | translate }} - + {{ 'tenant-profile.maximum-edges-range' | translate }} @@ -141,10 +141,10 @@ - + {{ 'tenant-profile.max-r-e-executions-required' | translate}} - + {{ 'tenant-profile.max-r-e-executions-range' | translate}} @@ -154,10 +154,10 @@ - + {{ 'tenant-profile.max-transport-messages-required' | translate}} - + {{ 'tenant-profile.max-transport-messages-range' | translate}} @@ -176,10 +176,10 @@ - + {{ 'tenant-profile.max-j-s-executions-required' | translate}} - + {{ 'tenant-profile.max-j-s-executions-range' | translate}} @@ -189,10 +189,10 @@ - + {{ 'tenant-profile.max-tbel-executions-required' | translate}} - + {{ 'tenant-profile.max-tbel-executions-range' | translate}} @@ -204,10 +204,10 @@ - + {{ 'tenant-profile.max-rule-node-executions-per-message-required' | translate}} - + {{ 'tenant-profile.max-rule-node-executions-per-message-range' | translate}} @@ -217,10 +217,10 @@ - + {{ 'tenant-profile.max-transport-data-points-required' | translate}} - + {{ 'tenant-profile.max-transport-data-points-range' | translate}} @@ -239,10 +239,10 @@ - + {{ 'tenant-profile.max-calculated-fields-required' | translate}} - + {{ 'tenant-profile.max-calculated-fields-range' | translate}} @@ -252,10 +252,10 @@ - + {{ 'tenant-profile.max-data-points-per-rolling-arg-required' | translate}} - + {{ 'tenant-profile.max-data-points-per-rolling-arg-range' | translate}} @@ -267,42 +267,14 @@ - + {{ 'tenant-profile.max-arguments-per-cf-required' | translate}} - + {{ 'tenant-profile.max-arguments-per-cf-range' | translate}} - - tenant-profile.max-related-level-per-argument - - - {{ 'tenant-profile.max-related-level-per-argument-required' | translate}} - - - {{ 'tenant-profile.max-related-level-per-argument-range' | translate}} - - - -
-
- - tenant-profile.min-allowed-scheduled-update-interval - - - {{ 'tenant-profile.min-allowed-scheduled-update-interval-required' | translate}} - - - {{ 'tenant-profile.min-allowed-scheduled-update-interval-range' | translate}} - - -
@@ -318,10 +290,10 @@ - + {{ 'tenant-profile.max-state-size-required' | translate}} - + {{ 'tenant-profile.max-state-size-range' | translate}} @@ -331,15 +303,59 @@ - + {{ 'tenant-profile.max-value-argument-size-required' | translate}} - + {{ 'tenant-profile.max-value-argument-size-range' | translate}}
+
+ + tenant-profile.max-related-level-per-argument + + + {{ 'tenant-profile.max-related-level-per-argument-required' | translate}} + + + {{ 'tenant-profile.max-related-level-per-argument-range' | translate}} + + + + + tenant-profile.min-allowed-scheduled-update-interval + + + {{ 'tenant-profile.min-allowed-scheduled-update-interval-required' | translate}} + + + {{ 'tenant-profile.min-allowed-scheduled-update-interval-range' | translate}} + + + +
+
+ + tenant-profile.relation-search-entity-limit + + + {{ 'tenant-profile.relation-search-entity-limit-required' | translate}} + + + {{ 'tenant-profile.relation-search-entity-limit-range' | translate}} + + tenant-profile.relation-search-entity-limit-hint + +
+
@@ -354,10 +370,10 @@ - + {{ 'tenant-profile.max-d-p-storage-days-required' | translate}} - + {{ 'tenant-profile.max-d-p-storage-days-range' | translate}} @@ -367,10 +383,10 @@ - + {{ 'tenant-profile.alarms-ttl-days-required' | translate}} - + {{ 'tenant-profile.alarms-ttl-days-days-range' | translate}} @@ -382,10 +398,10 @@ - + {{ 'tenant-profile.default-storage-ttl-days-required' | translate}} - + {{ 'tenant-profile.default-storage-ttl-days-range' | translate}} @@ -395,10 +411,10 @@ - + {{ 'tenant-profile.rpc-ttl-days-required' | translate}} - + {{ 'tenant-profile.rpc-ttl-days-days-range' | translate}} @@ -410,10 +426,10 @@ - + {{ 'tenant-profile.queue-stats-ttl-days-required' | translate}} - + {{ 'tenant-profile.queue-stats-ttl-days-range' | translate}} @@ -423,10 +439,10 @@ - + {{ 'tenant-profile.rule-engine-exceptions-ttl-days-required' | translate}} - + {{ 'tenant-profile.rule-engine-exceptions-ttl-days-range' | translate}} @@ -441,16 +457,16 @@ {{ 'tenant-profile.sms-enabled' | translate }} - tenant-profile.max-sms - + {{ 'tenant-profile.max-sms-required' | translate}} - + {{ 'tenant-profile.max-sms-range' | translate}} @@ -460,10 +476,10 @@ - + {{ 'tenant-profile.max-emails-required' | translate}} - + {{ 'tenant-profile.max-emails-range' | translate}} @@ -473,10 +489,10 @@ - + {{ 'tenant-profile.max-created-alarms-required' | translate}} - + {{ 'tenant-profile.max-created-alarms-range' | translate}} @@ -494,7 +510,7 @@ - + {{ 'tenant-profile.maximum-debug-duration-min-range' | translate }} @@ -513,10 +529,10 @@ - + {{ 'tenant-profile.maximum-resources-sum-data-size-required' | translate}} - + {{ 'tenant-profile.maximum-resources-sum-data-size-range' | translate}} @@ -526,10 +542,10 @@ - + {{ 'tenant-profile.maximum-ota-package-sum-data-size-required' | translate}} - + {{ 'tenant-profile.maximum-ota-package-sum-data-size-range' | translate}} @@ -541,10 +557,10 @@ - + {{ 'tenant-profile.maximum-resource-size-required' | translate}} - + {{ 'tenant-profile.maximum-resource-size-range' | translate}} @@ -561,14 +577,14 @@ tenant-profile.ws-limit-max-sessions-per-tenant - + {{ 'tenant-profile.too-small-value-zero' | translate}} tenant-profile.ws-limit-max-subscriptions-per-tenant - + {{ 'tenant-profile.too-small-value-zero' | translate}} @@ -577,14 +593,14 @@ tenant-profile.ws-limit-max-sessions-per-customer - + {{ 'tenant-profile.too-small-value-zero' | translate}} tenant-profile.ws-limit-max-subscriptions-per-customer - + {{ 'tenant-profile.too-small-value-zero' | translate}} @@ -600,14 +616,14 @@ tenant-profile.ws-limit-max-sessions-per-public-user - + {{ 'tenant-profile.too-small-value-zero' | translate}} tenant-profile.ws-limit-max-subscriptions-per-public-user - + {{ 'tenant-profile.too-small-value-zero' | translate}} @@ -616,14 +632,14 @@ tenant-profile.ws-limit-max-sessions-per-regular-user - + {{ 'tenant-profile.too-small-value-zero' | translate}} tenant-profile.ws-limit-max-subscriptions-per-regular-user - + {{ 'tenant-profile.too-small-value-zero' | translate}} @@ -632,7 +648,7 @@ tenant-profile.ws-limit-queue-per-session - + {{ 'tenant-profile.too-small-value-one' | translate}} diff --git a/ui-ngx/src/app/modules/home/components/profile/tenant/default-tenant-profile-configuration.component.ts b/ui-ngx/src/app/modules/home/components/profile/tenant/default-tenant-profile-configuration.component.ts index 0dd1453648..9595def95e 100644 --- a/ui-ngx/src/app/modules/home/components/profile/tenant/default-tenant-profile-configuration.component.ts +++ b/ui-ngx/src/app/modules/home/components/profile/tenant/default-tenant-profile-configuration.component.ts @@ -14,16 +14,13 @@ /// limitations under the License. /// -import { Component, forwardRef, Input, OnDestroy, OnInit } from '@angular/core'; -import { ControlValueAccessor, UntypedFormBuilder, UntypedFormGroup, NG_VALUE_ACCESSOR, Validators } from '@angular/forms'; -import { Store } from '@ngrx/store'; -import { AppState } from '@app/core/core.state'; -import { coerceBooleanProperty } from '@angular/cdk/coercion'; -import { DefaultTenantProfileConfiguration, TenantProfileConfiguration } from '@shared/models/tenant.model'; +import { Component, forwardRef, Input } from '@angular/core'; +import { ControlValueAccessor, FormBuilder, FormGroup, NG_VALUE_ACCESSOR, Validators } from '@angular/forms'; +import { DefaultTenantProfileConfiguration, FormControlsFrom } from '@shared/models/tenant.model'; import { isDefinedAndNotNull } from '@core/utils'; import { RateLimitsType } from './rate-limits/rate-limits.models'; -import { takeUntil } from 'rxjs/operators'; -import { Subject } from 'rxjs'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; +import { coerceBoolean } from '@shared/decorators/coercion'; @Component({ selector: 'tb-default-tenant-profile-configuration', @@ -35,112 +32,107 @@ import { Subject } from 'rxjs'; multi: true }] }) -export class DefaultTenantProfileConfigurationComponent implements ControlValueAccessor, OnInit, OnDestroy { +export class DefaultTenantProfileConfigurationComponent implements ControlValueAccessor { - defaultTenantProfileConfigurationFormGroup: UntypedFormGroup; + tenantProfileConfigurationForm: FormGroup>; - private requiredValue: boolean; - private destroy$ = new Subject(); - get required(): boolean { - return this.requiredValue; - } @Input() - set required(value: boolean) { - this.requiredValue = coerceBooleanProperty(value); - } + @coerceBoolean() + required: boolean; @Input() + @coerceBoolean() disabled: boolean; rateLimitsType = RateLimitsType; - private propagateChange = (v: any) => { }; - - constructor(private store: Store, - private fb: UntypedFormBuilder) { - this.defaultTenantProfileConfigurationFormGroup = this.fb.group({ - maxDevices: [null, [Validators.required, Validators.min(0)]], - maxAssets: [null, [Validators.required, Validators.min(0)]], - maxCustomers: [null, [Validators.required, Validators.min(0)]], - maxUsers: [null, [Validators.required, Validators.min(0)]], - maxDashboards: [null, [Validators.required, Validators.min(0)]], - maxRuleChains: [null, [Validators.required, Validators.min(0)]], - maxEdges: [null, [Validators.required, Validators.min(0)]], - maxResourcesInBytes: [null, [Validators.required, Validators.min(0)]], - maxOtaPackagesInBytes: [null, [Validators.required, Validators.min(0)]], - maxResourceSize: [null, [Validators.required, Validators.min(0)]], - transportTenantMsgRateLimit: [null, []], - transportTenantTelemetryMsgRateLimit: [null, []], - transportTenantTelemetryDataPointsRateLimit: [null, []], - transportDeviceMsgRateLimit: [null, []], - transportDeviceTelemetryMsgRateLimit: [null, []], - transportDeviceTelemetryDataPointsRateLimit: [null, []], - transportGatewayMsgRateLimit: [null, []], - transportGatewayTelemetryMsgRateLimit: [null, []], - transportGatewayTelemetryDataPointsRateLimit: [null, []], - transportGatewayDeviceMsgRateLimit: [null, []], - transportGatewayDeviceTelemetryMsgRateLimit: [null, []], - transportGatewayDeviceTelemetryDataPointsRateLimit: [null, []], - tenantEntityExportRateLimit: [null, []], - tenantEntityImportRateLimit: [null, []], - tenantNotificationRequestsRateLimit: [null, []], - tenantNotificationRequestsPerRuleRateLimit: [null, []], - maxTransportMessages: [null, [Validators.required, Validators.min(0)]], - maxTransportDataPoints: [null, [Validators.required, Validators.min(0)]], - maxREExecutions: [null, [Validators.required, Validators.min(0)]], - maxJSExecutions: [null, [Validators.required, Validators.min(0)]], - maxTbelExecutions: [null, [Validators.required, Validators.min(0)]], - maxDPStorageDays: [null, [Validators.required, Validators.min(0)]], - maxRuleNodeExecutionsPerMessage: [null, [Validators.required, Validators.min(0)]], - maxEmails: [null, [Validators.required, Validators.min(0)]], - maxSms: [null, []], - smsEnabled: [null, []], - maxCreatedAlarms: [null, [Validators.required, Validators.min(0)]], - maxDebugModeDurationMinutes: [null, [Validators.min(0)]], - defaultStorageTtlDays: [null, [Validators.required, Validators.min(0)]], - alarmsTtlDays: [null, [Validators.required, Validators.min(0)]], - rpcTtlDays: [null, [Validators.required, Validators.min(0)]], - queueStatsTtlDays: [null, [Validators.required, Validators.min(0)]], - ruleEngineExceptionsTtlDays: [null, [Validators.required, Validators.min(0)]], - tenantServerRestLimitsConfiguration: [null, []], - customerServerRestLimitsConfiguration: [null, []], - maxWsSessionsPerTenant: [null, [Validators.min(0)]], - maxWsSessionsPerCustomer: [null, [Validators.min(0)]], - maxWsSessionsPerRegularUser: [null, [Validators.min(0)]], - maxWsSessionsPerPublicUser: [null, [Validators.min(0)]], - wsMsgQueueLimitPerSession: [null, [Validators.min(0)]], - maxWsSubscriptionsPerTenant: [null, [Validators.min(0)]], - maxWsSubscriptionsPerCustomer: [null, [Validators.min(0)]], - maxWsSubscriptionsPerRegularUser: [null, [Validators.min(0)]], - maxWsSubscriptionsPerPublicUser: [null, [Validators.min(0)]], - wsUpdatesPerSessionRateLimit: [null, []], - cassandraWriteQueryTenantCoreRateLimits: [null, []], - cassandraReadQueryTenantCoreRateLimits: [null, []], - cassandraWriteQueryTenantRuleEngineRateLimits: [null, []], - cassandraReadQueryTenantRuleEngineRateLimits: [null, []], - edgeEventRateLimits: [null, []], - edgeEventRateLimitsPerEdge: [null, []], - edgeUplinkMessagesRateLimits: [null, []], - edgeUplinkMessagesRateLimitsPerEdge: [null, []], - maxCalculatedFieldsPerEntity: [null, [Validators.required, Validators.min(0)]], - maxArgumentsPerCF: [null, [Validators.required, Validators.min(0)]], - maxRelationLevelPerCfArgument: [null, [Validators.required, Validators.min(1)]], - minAllowedScheduledUpdateIntervalInSecForCF: [null, [Validators.required, Validators.min(0)]], - maxDataPointsPerRollingArg: [null, [Validators.required, Validators.min(0)]], - maxStateSizeInKBytes: [null, [Validators.required, Validators.min(0)]], - calculatedFieldDebugEventsRateLimit: [null, []], - maxSingleValueArgumentSizeInKBytes: [null, [Validators.required, Validators.min(0)]], + private propagateChange = (_v: any) => { }; + + constructor(private fb: FormBuilder) { + this.tenantProfileConfigurationForm = this.fb.group({ + maxDevices: [0, [Validators.required, Validators.min(0)]], + maxAssets: [0, [Validators.required, Validators.min(0)]], + maxCustomers: [0, [Validators.required, Validators.min(0)]], + maxUsers: [0, [Validators.required, Validators.min(0)]], + maxDashboards: [0, [Validators.required, Validators.min(0)]], + maxRuleChains: [0, [Validators.required, Validators.min(0)]], + maxEdges: [0, [Validators.required, Validators.min(0)]], + maxResourcesInBytes: [0, [Validators.required, Validators.min(0)]], + maxOtaPackagesInBytes: [0, [Validators.required, Validators.min(0)]], + maxResourceSize: [0, [Validators.required, Validators.min(0)]], + transportTenantMsgRateLimit: [''], + transportTenantTelemetryMsgRateLimit: [''], + transportTenantTelemetryDataPointsRateLimit: [''], + transportDeviceMsgRateLimit: [''], + transportDeviceTelemetryMsgRateLimit: [''], + transportDeviceTelemetryDataPointsRateLimit: [''], + transportGatewayMsgRateLimit: [''], + transportGatewayTelemetryMsgRateLimit: [''], + transportGatewayTelemetryDataPointsRateLimit: [''], + transportGatewayDeviceMsgRateLimit: [''], + transportGatewayDeviceTelemetryMsgRateLimit: [''], + transportGatewayDeviceTelemetryDataPointsRateLimit: [''], + tenantEntityExportRateLimit: [''], + tenantEntityImportRateLimit: [''], + tenantNotificationRequestsRateLimit: [''], + tenantNotificationRequestsPerRuleRateLimit: [''], + maxTransportMessages: [0, [Validators.required, Validators.min(0)]], + maxTransportDataPoints: [0, [Validators.required, Validators.min(0)]], + maxREExecutions: [0, [Validators.required, Validators.min(0)]], + maxJSExecutions: [0, [Validators.required, Validators.min(0)]], + maxTbelExecutions: [0, [Validators.required, Validators.min(0)]], + maxDPStorageDays: [0, [Validators.required, Validators.min(0)]], + maxRuleNodeExecutionsPerMessage: [0, [Validators.required, Validators.min(0)]], + maxEmails: [0, [Validators.required, Validators.min(0)]], + maxSms: [0], + smsEnabled: [false], + maxCreatedAlarms: [0, [Validators.required, Validators.min(0)]], + maxDebugModeDurationMinutes: [0, [Validators.min(0)]], + defaultStorageTtlDays: [0, [Validators.required, Validators.min(0)]], + alarmsTtlDays: [0, [Validators.required, Validators.min(0)]], + rpcTtlDays: [0, [Validators.required, Validators.min(0)]], + queueStatsTtlDays: [0, [Validators.required, Validators.min(0)]], + ruleEngineExceptionsTtlDays: [0, [Validators.required, Validators.min(0)]], + tenantServerRestLimitsConfiguration: [''], + customerServerRestLimitsConfiguration: [''], + maxWsSessionsPerTenant: [0, [Validators.min(0)]], + maxWsSessionsPerCustomer: [0, [Validators.min(0)]], + maxWsSessionsPerRegularUser: [0, [Validators.min(0)]], + maxWsSessionsPerPublicUser: [0, [Validators.min(0)]], + wsMsgQueueLimitPerSession: [0, [Validators.min(0)]], + maxWsSubscriptionsPerTenant: [0, [Validators.min(0)]], + maxWsSubscriptionsPerCustomer: [0, [Validators.min(0)]], + maxWsSubscriptionsPerRegularUser: [0, [Validators.min(0)]], + maxWsSubscriptionsPerPublicUser: [0, [Validators.min(0)]], + wsUpdatesPerSessionRateLimit: [''], + cassandraWriteQueryTenantCoreRateLimits: [''], + cassandraReadQueryTenantCoreRateLimits: [''], + cassandraWriteQueryTenantRuleEngineRateLimits: [''], + cassandraReadQueryTenantRuleEngineRateLimits: [''], + edgeEventRateLimits: [''], + edgeEventRateLimitsPerEdge: [''], + edgeUplinkMessagesRateLimits: [''], + edgeUplinkMessagesRateLimitsPerEdge: [''], + maxCalculatedFieldsPerEntity: [0, [Validators.required, Validators.min(0)]], + maxArgumentsPerCF: [0, [Validators.required, Validators.min(0)]], + maxRelationLevelPerCfArgument: [1, [Validators.required, Validators.min(1)]], + maxRelatedEntitiesToReturnPerCfArgument: [1, [Validators.required, Validators.min(1)]], + minAllowedScheduledUpdateIntervalInSecForCF: [0, [Validators.required, Validators.min(0)]], + maxDataPointsPerRollingArg: [0, [Validators.required, Validators.min(0)]], + maxStateSizeInKBytes: [0, [Validators.required, Validators.min(0)]], + calculatedFieldDebugEventsRateLimit: [''], + maxSingleValueArgumentSizeInKBytes: [0, [Validators.required, Validators.min(0)]], }); - this.defaultTenantProfileConfigurationFormGroup.get('smsEnabled').valueChanges.pipe( - takeUntil(this.destroy$) + this.tenantProfileConfigurationForm.get('smsEnabled').valueChanges.pipe( + takeUntilDestroyed() ).subscribe((value: boolean) => { this.maxSmsValidation(value); } ); - this.defaultTenantProfileConfigurationFormGroup.valueChanges.pipe( - takeUntil(this.destroy$) + this.tenantProfileConfigurationForm.valueChanges.pipe( + takeUntilDestroyed() ).subscribe(() => { this.updateModel(); }); @@ -148,48 +140,40 @@ export class DefaultTenantProfileConfigurationComponent implements ControlValueA private maxSmsValidation(smsEnabled: boolean) { if (smsEnabled) { - this.defaultTenantProfileConfigurationFormGroup.get('maxSms').addValidators([Validators.required, Validators.min(0)]); + this.tenantProfileConfigurationForm.get('maxSms').addValidators([Validators.required, Validators.min(0)]); } else { - this.defaultTenantProfileConfigurationFormGroup.get('maxSms').clearValidators(); + this.tenantProfileConfigurationForm.get('maxSms').clearValidators(); } - this.defaultTenantProfileConfigurationFormGroup.get('maxSms').updateValueAndValidity({emitEvent: false}); - } - - ngOnDestroy(): void { - this.destroy$.next(); - this.destroy$.complete(); + this.tenantProfileConfigurationForm.get('maxSms').updateValueAndValidity({emitEvent: false}); } registerOnChange(fn: any): void { this.propagateChange = fn; } - registerOnTouched(fn: any): void { - } - - ngOnInit() { + registerOnTouched(_fn: any): void { } setDisabledState(isDisabled: boolean): void { this.disabled = isDisabled; if (this.disabled) { - this.defaultTenantProfileConfigurationFormGroup.disable({emitEvent: false}); + this.tenantProfileConfigurationForm.disable({emitEvent: false}); } else { - this.defaultTenantProfileConfigurationFormGroup.enable({emitEvent: false}); + this.tenantProfileConfigurationForm.enable({emitEvent: false}); } } writeValue(value: DefaultTenantProfileConfiguration | null): void { if (isDefinedAndNotNull(value)) { this.maxSmsValidation(value.smsEnabled); - this.defaultTenantProfileConfigurationFormGroup.patchValue(value, {emitEvent: false}); + this.tenantProfileConfigurationForm.patchValue(value, {emitEvent: false}); } } private updateModel() { - let configuration: TenantProfileConfiguration = null; - if (this.defaultTenantProfileConfigurationFormGroup.valid) { - configuration = this.defaultTenantProfileConfigurationFormGroup.getRawValue(); + let configuration: DefaultTenantProfileConfiguration = null; + if (this.tenantProfileConfigurationForm.valid) { + configuration = this.tenantProfileConfigurationForm.getRawValue(); } this.propagateChange(configuration); } diff --git a/ui-ngx/src/app/shared/models/tenant.model.ts b/ui-ngx/src/app/shared/models/tenant.model.ts index 059fe39ada..1ed1092207 100644 --- a/ui-ngx/src/app/shared/models/tenant.model.ts +++ b/ui-ngx/src/app/shared/models/tenant.model.ts @@ -19,6 +19,11 @@ import { TenantId } from './id/tenant-id'; import { TenantProfileId } from '@shared/models/id/tenant-profile-id'; import { BaseData, ExportableEntity } from '@shared/models/base-data'; import { QueueInfo } from '@shared/models/queue.models'; +import { FormControl } from '@angular/forms'; + +export type FormControlsFrom = { + [K in keyof T]-?: FormControl; +}; export enum TenantProfileType { DEFAULT = 'DEFAULT' @@ -101,6 +106,9 @@ export interface DefaultTenantProfileConfiguration { maxCalculatedFieldsPerEntity: number; maxArgumentsPerCF: number; + maxRelationLevelPerCfArgument: number; + maxRelatedEntitiesToReturnPerCfArgument: number; + minAllowedScheduledUpdateIntervalInSecForCF: number; maxDataPointsPerRollingArg: number; maxStateSizeInKBytes: number; maxSingleValueArgumentSizeInKBytes: number; @@ -165,6 +173,9 @@ export function createTenantProfileConfiguration(type: TenantProfileType): Tenan maxCalculatedFieldsPerEntity: 5, maxArgumentsPerCF: 10, maxDataPointsPerRollingArg: 1000, + maxRelationLevelPerCfArgument: 10, + maxRelatedEntitiesToReturnPerCfArgument: 100, + minAllowedScheduledUpdateIntervalInSecForCF: 0, maxStateSizeInKBytes: 32, maxSingleValueArgumentSizeInKBytes: 2, calculatedFieldDebugEventsRateLimit: '' diff --git a/ui-ngx/src/assets/locale/locale.constant-en_US.json b/ui-ngx/src/assets/locale/locale.constant-en_US.json index 7f728c0b63..3ee738a8b0 100644 --- a/ui-ngx/src/assets/locale/locale.constant-en_US.json +++ b/ui-ngx/src/assets/locale/locale.constant-en_US.json @@ -5952,6 +5952,10 @@ "ws-limit-max-subscriptions-per-regular-user": "Subscriptions per regular user maximum number", "ws-limit-max-subscriptions-per-public-user": "Subscriptions per public user maximum number", "ws-limit-updates-per-session": "WS updates per session", + "relation-search-entity-limit": "Relation search entity limit", + "relation-search-entity-limit-hint": "Limits the number of entities resolved at the last level of the relation path. Applies to 'Related entities' arguments and Propagation fields.", + "relation-search-entity-limit-required": "Relation search entity limit", + "relation-search-entity-limit-range": "Relation search entity limit can't be less than '1'", "rate-limits": { "add-limit": "Add limit", "and-also-less-than": "and also less than", From 4f89242e60ca7741180cf8a6be0958308d2530ad Mon Sep 17 00:00:00 2001 From: dashevchenko Date: Fri, 17 Oct 2025 11:04:17 +0300 Subject: [PATCH 39/43] fixed entity save methods to not retrieve old value from cache --- .../java/org/thingsboard/server/dao/asset/BaseAssetService.java | 2 +- .../thingsboard/server/dao/customer/CustomerServiceImpl.java | 2 +- .../org/thingsboard/server/dao/device/DeviceServiceImpl.java | 2 +- .../server/dao/entityview/EntityViewServiceImpl.java | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/dao/src/main/java/org/thingsboard/server/dao/asset/BaseAssetService.java b/dao/src/main/java/org/thingsboard/server/dao/asset/BaseAssetService.java index 5f8cbe7064..7d8b2e0e8c 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/asset/BaseAssetService.java +++ b/dao/src/main/java/org/thingsboard/server/dao/asset/BaseAssetService.java @@ -160,7 +160,7 @@ public class BaseAssetService extends AbstractCachedEntityService Date: Mon, 20 Oct 2025 13:09:10 +0300 Subject: [PATCH 40/43] Fix NotificationRuleApiTest --- .../alarm/AlarmCalculatedFieldState.java | 2 - .../notification/NotificationRuleApiTest.java | 91 ++++++++----------- 2 files changed, 40 insertions(+), 53 deletions(-) diff --git a/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/alarm/AlarmCalculatedFieldState.java b/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/alarm/AlarmCalculatedFieldState.java index 61d55f376f..e36892378c 100644 --- a/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/alarm/AlarmCalculatedFieldState.java +++ b/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/alarm/AlarmCalculatedFieldState.java @@ -189,8 +189,6 @@ public class AlarmCalculatedFieldState extends BaseCalculatedFieldState { @Override public ListenableFuture performCalculation(Map updatedArgs, CalculatedFieldCtx ctx) { initCurrentAlarm(ctx); - // FIXME: don't create alarm if attrs were deleted, or config is updated - // TODO: what if expression is changed? do we reevaluate? or only on new events? TbAlarmResult result = createOrClearAlarms(state -> { if (updatedArgs != null) { boolean newEvent = !updatedArgs.isEmpty(); diff --git a/application/src/test/java/org/thingsboard/server/service/notification/NotificationRuleApiTest.java b/application/src/test/java/org/thingsboard/server/service/notification/NotificationRuleApiTest.java index bab70ea505..2bfdc2f330 100644 --- a/application/src/test/java/org/thingsboard/server/service/notification/NotificationRuleApiTest.java +++ b/application/src/test/java/org/thingsboard/server/service/notification/NotificationRuleApiTest.java @@ -27,7 +27,7 @@ import org.springframework.data.util.Pair; import org.springframework.test.context.TestPropertySource; import org.thingsboard.common.util.JacksonUtil; import org.thingsboard.server.cache.limits.RateLimitService; -import org.thingsboard.server.common.data.DataConstants; +import org.thingsboard.server.common.data.AttributeScope; import org.thingsboard.server.common.data.Device; import org.thingsboard.server.common.data.DeviceProfile; import org.thingsboard.server.common.data.EntityType; @@ -39,17 +39,19 @@ import org.thingsboard.server.common.data.alarm.AlarmCommentType; import org.thingsboard.server.common.data.alarm.AlarmSearchStatus; import org.thingsboard.server.common.data.alarm.AlarmSeverity; import org.thingsboard.server.common.data.alarm.AlarmStatus; +import org.thingsboard.server.common.data.alarm.rule.AlarmRule; +import org.thingsboard.server.common.data.alarm.rule.condition.SimpleAlarmCondition; +import org.thingsboard.server.common.data.alarm.rule.condition.expression.TbelAlarmConditionExpression; import org.thingsboard.server.common.data.asset.Asset; +import org.thingsboard.server.common.data.cf.CalculatedField; +import org.thingsboard.server.common.data.cf.CalculatedFieldType; +import org.thingsboard.server.common.data.cf.configuration.AlarmCalculatedFieldConfiguration; +import org.thingsboard.server.common.data.cf.configuration.Argument; +import org.thingsboard.server.common.data.cf.configuration.ArgumentType; +import org.thingsboard.server.common.data.cf.configuration.ReferencedEntityKey; import org.thingsboard.server.common.data.device.data.DefaultDeviceConfiguration; import org.thingsboard.server.common.data.device.data.DefaultDeviceTransportConfiguration; import org.thingsboard.server.common.data.device.data.DeviceData; -import org.thingsboard.server.common.data.device.profile.AlarmCondition; -import org.thingsboard.server.common.data.device.profile.AlarmConditionFilter; -import org.thingsboard.server.common.data.device.profile.AlarmConditionFilterKey; -import org.thingsboard.server.common.data.device.profile.AlarmConditionKeyType; -import org.thingsboard.server.common.data.device.profile.AlarmRule; -import org.thingsboard.server.common.data.device.profile.DeviceProfileAlarm; -import org.thingsboard.server.common.data.device.profile.SimpleAlarmConditionSpec; import org.thingsboard.server.common.data.edge.Edge; import org.thingsboard.server.common.data.id.AlarmId; import org.thingsboard.server.common.data.id.DeviceId; @@ -87,9 +89,6 @@ import org.thingsboard.server.common.data.notification.targets.platform.SystemAd import org.thingsboard.server.common.data.notification.template.NotificationTemplate; import org.thingsboard.server.common.data.page.PageData; import org.thingsboard.server.common.data.page.PageLink; -import org.thingsboard.server.common.data.query.BooleanFilterPredicate; -import org.thingsboard.server.common.data.query.EntityKeyValueType; -import org.thingsboard.server.common.data.query.FilterPredicateValue; import org.thingsboard.server.common.data.rule.RuleChain; import org.thingsboard.server.common.data.rule.RuleChainMetaData; import org.thingsboard.server.common.data.security.Authority; @@ -106,12 +105,10 @@ import org.thingsboard.server.service.system.DefaultSystemInfoService; import org.thingsboard.server.service.telemetry.AlarmSubscriptionService; import java.lang.reflect.Method; -import java.util.ArrayList; import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.Set; -import java.util.TreeMap; import java.util.UUID; import java.util.concurrent.Callable; import java.util.concurrent.TimeUnit; @@ -193,7 +190,7 @@ public class NotificationRuleApiTest extends AbstractNotificationApiTest { @Test public void testNotificationRuleProcessing_alarmTrigger() throws Exception { String notificationSubject = "Alarm type: ${alarmType}, status: ${alarmStatus}, " + - "severity: ${alarmSeverity}, deviceId: ${alarmOriginatorId}"; + "severity: ${alarmSeverity}, deviceId: ${alarmOriginatorId}"; String notificationText = "Status: ${alarmStatus}, severity: ${alarmSeverity}"; NotificationTemplate notificationTemplate = createNotificationTemplate(NotificationType.ALARM, notificationSubject, notificationText, NotificationDeliveryMethod.WEB); @@ -234,8 +231,8 @@ public class NotificationRuleApiTest extends AbstractNotificationApiTest { }); JsonNode attr = JacksonUtil.newObjectNode() - .set("bool", BooleanNode.TRUE); - doPost("/api/plugins/telemetry/" + device.getId() + "/" + DataConstants.SHARED_SCOPE, attr); + .set("createAlarm", BooleanNode.TRUE); + postAttributes(device.getId(), AttributeScope.SERVER_SCOPE, attr.toString()); await().atMost(10, TimeUnit.SECONDS) .until(() -> alarmSubscriptionService.findLatestByOriginatorAndType(tenantId, device.getId(), alarmType) != null); @@ -250,7 +247,7 @@ public class NotificationRuleApiTest extends AbstractNotificationApiTest { assertThat(actualDelay).isCloseTo(expectedDelay, offset(2.0)); assertThat(notification.getSubject()).isEqualTo("Alarm type: " + alarmType + ", status: " + AlarmStatus.ACTIVE_UNACK + ", " + - "severity: " + AlarmSeverity.CRITICAL.toString().toLowerCase() + ", deviceId: " + device.getId()); + "severity: " + AlarmSeverity.CRITICAL.toString().toLowerCase() + ", deviceId: " + device.getId()); assertThat(notification.getText()).isEqualTo("Status: " + AlarmStatus.ACTIVE_UNACK + ", severity: " + AlarmSeverity.CRITICAL.toString().toLowerCase()); assertThat(notification.getType()).isEqualTo(NotificationType.ALARM); @@ -270,7 +267,7 @@ public class NotificationRuleApiTest extends AbstractNotificationApiTest { wsClient.waitForUpdate(true); Notification updatedNotification = wsClient.getLastDataUpdate().getUpdate(); assertThat(updatedNotification.getSubject()).isEqualTo("Alarm type: " + alarmType + ", status: " + expectedStatus + ", " + - "severity: " + expectedSeverity.toString().toLowerCase() + ", deviceId: " + device.getId()); + "severity: " + expectedSeverity.toString().toLowerCase() + ", deviceId: " + device.getId()); assertThat(updatedNotification.getText()).isEqualTo("Status: " + expectedStatus + ", severity: " + expectedSeverity.toString().toLowerCase()); wsClient.close(); @@ -296,7 +293,7 @@ public class NotificationRuleApiTest extends AbstractNotificationApiTest { List notifications = getMyNotifications(false, 10); assertThat(notifications).singleElement().matches(notification -> { return notification.getType() == NotificationType.ALARM && - notification.getSubject().equals("New alarm 'testAlarm'"); + notification.getSubject().equals("New alarm 'testAlarm'"); }); }); } @@ -341,8 +338,8 @@ public class NotificationRuleApiTest extends AbstractNotificationApiTest { getWsClient().subscribeForUnreadNotifications(10).waitForReply(true); getWsClient().registerWaitForUpdate(); JsonNode attr = JacksonUtil.newObjectNode() - .set("bool", BooleanNode.TRUE); - doPost("/api/plugins/telemetry/" + device.getId() + "/" + DataConstants.SHARED_SCOPE, attr); + .set("createAlarm", BooleanNode.TRUE); + postAttributes(device.getId(), AttributeScope.SERVER_SCOPE, attr.toString()); await().atMost(10, TimeUnit.SECONDS) .until(() -> alarmSubscriptionService.findLatestByOriginatorAndType(tenantId, device.getId(), alarmType) != null); @@ -491,11 +488,11 @@ public class NotificationRuleApiTest extends AbstractNotificationApiTest { }); assertThat(notifications).anySatisfy(notification -> { assertThat(notification.getText()).isEqualTo("Rate limits for REST API requests per customer " + - "exceeded for 'Customer'"); + "exceeded for 'Customer'"); }); assertThat(notifications).anySatisfy(notification -> { assertThat(notification.getText()).isEqualTo("Rate limits for notification requests " + - "per rule exceeded for '" + rule.getName() + "'"); + "per rule exceeded for '" + rule.getName() + "'"); }); loginSysAdmin(); @@ -748,7 +745,7 @@ public class NotificationRuleApiTest extends AbstractNotificationApiTest { .build(); assertThat(DefaultNotificationDeduplicationService.getDeduplicationKey(expectedTrigger, rule)) .isEqualTo("RATE_LIMITS:TENANT:" + tenantId + ":ENTITY_EXPORT_" + - target.getId() + ":ENTITY_EXPORT,TRANSPORT_MESSAGES_PER_DEVICE"); + target.getId() + ":ENTITY_EXPORT,TRANSPORT_MESSAGES_PER_DEVICE"); loginTenantAdmin(); getWsClient().subscribeForUnreadNotifications(10).waitForReply(); @@ -944,35 +941,27 @@ public class NotificationRuleApiTest extends AbstractNotificationApiTest { private DeviceProfile createDeviceProfileWithAlarmRules(String alarmType) { DeviceProfile deviceProfile = createDeviceProfile("For notification rule test"); deviceProfile.setTenantId(tenantId); + deviceProfile = doPost("/api/deviceProfile", deviceProfile, DeviceProfile.class); - List alarms = new ArrayList<>(); - DeviceProfileAlarm alarm = new DeviceProfileAlarm(); - alarm.setAlarmType(alarmType); - alarm.setId(alarmType); + CalculatedField alarmCf = new CalculatedField(); + alarmCf.setType(CalculatedFieldType.ALARM); + alarmCf.setEntityId(deviceProfile.getId()); + alarmCf.setName(alarmType); + AlarmCalculatedFieldConfiguration configuration = new AlarmCalculatedFieldConfiguration(); + Argument argument = new Argument(); + argument.setRefEntityKey(new ReferencedEntityKey("createAlarm", ArgumentType.ATTRIBUTE, AttributeScope.SERVER_SCOPE)); + configuration.setArguments(Map.of("createAlarm", argument)); AlarmRule alarmRule = new AlarmRule(); - alarmRule.setAlarmDetails("Details"); - AlarmCondition alarmCondition = new AlarmCondition(); - alarmCondition.setSpec(new SimpleAlarmConditionSpec()); - List condition = new ArrayList<>(); - - AlarmConditionFilter alarmConditionFilter = new AlarmConditionFilter(); - alarmConditionFilter.setKey(new AlarmConditionFilterKey(AlarmConditionKeyType.ATTRIBUTE, "bool")); - BooleanFilterPredicate predicate = new BooleanFilterPredicate(); - predicate.setOperation(BooleanFilterPredicate.BooleanOperation.EQUAL); - predicate.setValue(new FilterPredicateValue<>(true)); - - alarmConditionFilter.setPredicate(predicate); - alarmConditionFilter.setValueType(EntityKeyValueType.BOOLEAN); - condition.add(alarmConditionFilter); - alarmCondition.setCondition(condition); - alarmRule.setCondition(alarmCondition); - TreeMap createRules = new TreeMap<>(); - createRules.put(AlarmSeverity.CRITICAL, alarmRule); - alarm.setCreateRules(createRules); - alarms.add(alarm); - - deviceProfile.getProfileData().setAlarms(alarms); - deviceProfile = doPost("/api/deviceProfile", deviceProfile, DeviceProfile.class); + SimpleAlarmCondition condition = new SimpleAlarmCondition(); + TbelAlarmConditionExpression expression = new TbelAlarmConditionExpression(); + expression.setExpression("return createAlarm == true;"); + condition.setExpression(expression); + alarmRule.setCondition(condition); + configuration.setCreateRules(Map.of( + AlarmSeverity.CRITICAL, alarmRule + )); + alarmCf.setConfiguration(configuration); + saveCalculatedField(alarmCf); return deviceProfile; } From 663b69fb706354b6365c7eff76b074f0be0b9c0f Mon Sep 17 00:00:00 2001 From: VIacheslavKlimov Date: Mon, 20 Oct 2025 13:11:16 +0300 Subject: [PATCH 41/43] Fix findByEntityIdAndTypeAndName for CF --- .../server/dao/sql/cf/CalculatedFieldRepository.java | 3 +-- .../thingsboard/server/dao/sql/cf/JpaCalculatedFieldDao.java | 2 +- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/dao/src/main/java/org/thingsboard/server/dao/sql/cf/CalculatedFieldRepository.java b/dao/src/main/java/org/thingsboard/server/dao/sql/cf/CalculatedFieldRepository.java index 9a1f904788..d4e471b838 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/sql/cf/CalculatedFieldRepository.java +++ b/dao/src/main/java/org/thingsboard/server/dao/sql/cf/CalculatedFieldRepository.java @@ -19,7 +19,6 @@ import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.Query; -import org.thingsboard.server.common.data.cf.CalculatedFieldType; import org.thingsboard.server.common.data.id.CalculatedFieldId; import org.thingsboard.server.dao.model.sql.CalculatedFieldEntity; @@ -30,7 +29,7 @@ public interface CalculatedFieldRepository extends JpaRepository findCalculatedFieldIdsByTenantIdAndEntityId(UUID tenantId, UUID entityId); diff --git a/dao/src/main/java/org/thingsboard/server/dao/sql/cf/JpaCalculatedFieldDao.java b/dao/src/main/java/org/thingsboard/server/dao/sql/cf/JpaCalculatedFieldDao.java index e0e5ef60c4..2b29d1afcc 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/sql/cf/JpaCalculatedFieldDao.java +++ b/dao/src/main/java/org/thingsboard/server/dao/sql/cf/JpaCalculatedFieldDao.java @@ -69,7 +69,7 @@ public class JpaCalculatedFieldDao extends JpaAbstractDao Date: Mon, 20 Oct 2025 13:19:56 +0300 Subject: [PATCH 42/43] TestRestClient code clean up --- .../src/test/java/org/thingsboard/server/msa/TestRestClient.java | 1 - 1 file changed, 1 deletion(-) diff --git a/msa/black-box-tests/src/test/java/org/thingsboard/server/msa/TestRestClient.java b/msa/black-box-tests/src/test/java/org/thingsboard/server/msa/TestRestClient.java index 7bca833bf4..7a4d3f1cfe 100644 --- a/msa/black-box-tests/src/test/java/org/thingsboard/server/msa/TestRestClient.java +++ b/msa/black-box-tests/src/test/java/org/thingsboard/server/msa/TestRestClient.java @@ -415,7 +415,6 @@ public class TestRestClient { queryParams.put("toType", toId.getEntityType().name()); return given().spec(requestSpec) .queryParams(queryParams) - //.delete("/api/v2/relation?fromId={fromId}&fromType={fromType}&relationType={relationType}&toId={toId}&toType={toType}") .delete("/api/v2/relation") .then() .statusCode(HTTP_OK) From fab3cfbc863e06a68d4cf3dd3d4570dd46ce2d2f Mon Sep 17 00:00:00 2001 From: VIacheslavKlimov Date: Mon, 20 Oct 2025 14:16:28 +0300 Subject: [PATCH 43/43] Alarm rules CF: add test for manual alarm clear --- .../alarm/AlarmCalculatedFieldState.java | 4 +- .../thingsboard/server/cf/AlarmRulesTest.java | 47 ++++++++++++++++--- .../src/test/resources/logback-test.xml | 2 + 3 files changed, 44 insertions(+), 9 deletions(-) diff --git a/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/alarm/AlarmCalculatedFieldState.java b/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/alarm/AlarmCalculatedFieldState.java index e36892378c..02f1725cf2 100644 --- a/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/alarm/AlarmCalculatedFieldState.java +++ b/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/alarm/AlarmCalculatedFieldState.java @@ -220,10 +220,8 @@ public class AlarmCalculatedFieldState extends BaseCalculatedFieldState { private void processAlarmClear(Alarm alarm) { currentAlarm = null; - createRuleStates.values().forEach(AlarmRuleState::clear); - createRuleStates.clear(); + createRuleStates.values().forEach(this::clearState); clearState(clearRuleState); - clearRuleState = null; } private void processAlarmAck(Alarm alarm) { diff --git a/application/src/test/java/org/thingsboard/server/cf/AlarmRulesTest.java b/application/src/test/java/org/thingsboard/server/cf/AlarmRulesTest.java index ae0010ea57..2b43373ee5 100644 --- a/application/src/test/java/org/thingsboard/server/cf/AlarmRulesTest.java +++ b/application/src/test/java/org/thingsboard/server/cf/AlarmRulesTest.java @@ -29,6 +29,7 @@ import org.thingsboard.server.actors.ActorSystemContext; import org.thingsboard.server.common.data.AttributeScope; import org.thingsboard.server.common.data.Device; import org.thingsboard.server.common.data.alarm.Alarm; +import org.thingsboard.server.common.data.alarm.AlarmInfo; import org.thingsboard.server.common.data.alarm.AlarmSeverity; import org.thingsboard.server.common.data.alarm.AlarmStatus; import org.thingsboard.server.common.data.alarm.rule.AlarmRule; @@ -689,16 +690,49 @@ public class AlarmRulesTest extends AbstractControllerTest { }); } + @Test + public void testManualClearAlarm() throws Exception { + Argument temperatureArgument = new Argument(); + temperatureArgument.setRefEntityKey(new ReferencedEntityKey("temperature", ArgumentType.TS_LATEST, null)); + temperatureArgument.setDefaultValue("0"); + Map arguments = Map.of( + "temperature", temperatureArgument + ); + + Map createRules = Map.of( + AlarmSeverity.CRITICAL, new Condition("return temperature >= 50;", null, null) + ); + + CalculatedField calculatedField = createAlarmCf(deviceId, "High Temperature Alarm", + arguments, createRules, null); + + postTelemetry(deviceId, "{\"temperature\":50}"); + Alarm alarm = checkAlarmResult(calculatedField, alarmResult -> { + assertThat(alarmResult.isCreated()).isTrue(); + assertThat(alarmResult.getAlarm().getSeverity()).isEqualTo(AlarmSeverity.CRITICAL); + assertThat(alarmResult.getAlarm().getStatus()).isEqualTo(AlarmStatus.ACTIVE_UNACK); + }).getAlarm(); + + doPost("/api/alarm/" + alarm.getId() + "/clear", AlarmInfo.class); + Thread.sleep(1000); + postTelemetry(deviceId, "{\"temperature\":50}"); + checkAlarmResult(calculatedField, alarmResult -> { + assertThat(alarmResult.getAlarm().getId()).isNotEqualTo(alarm.getId()); + assertThat(alarmResult.isCreated()).isTrue(); + assertThat(alarmResult.getAlarm().getSeverity()).isEqualTo(AlarmSeverity.CRITICAL); + assertThat(alarmResult.getAlarm().getStatus()).isEqualTo(AlarmStatus.ACTIVE_UNACK); + }); + } + // TODO: MSA tests - // TODO: test when attribute or telemetry is deleted without default value - perform calculation not happens - private void checkAlarmResult(CalculatedField calculatedField, Consumer assertion) { - checkAlarmResult(calculatedField, null, assertion); + private TbAlarmResult checkAlarmResult(CalculatedField calculatedField, Consumer assertion) { + return checkAlarmResult(calculatedField, null, assertion); } - private void checkAlarmResult(CalculatedField calculatedField, - Predicate waitFor, - Consumer assertion) { + private TbAlarmResult checkAlarmResult(CalculatedField calculatedField, + Predicate waitFor, + Consumer assertion) { TbAlarmResult alarmResult = await().atMost(TIMEOUT, TimeUnit.SECONDS) .until(() -> getLatestAlarmResult(calculatedField.getId()), result -> result != null && (waitFor == null || waitFor.test(result))); @@ -707,6 +741,7 @@ public class AlarmRulesTest extends AbstractControllerTest { Alarm alarm = alarmResult.getAlarm(); assertThat(alarm.getOriginator()).isEqualTo(originatorId); assertThat(alarm.getType()).isEqualTo(calculatedField.getName()); + return alarmResult; } private TbAlarmResult getLatestAlarmResult(CalculatedFieldId calculatedFieldId) { diff --git a/application/src/test/resources/logback-test.xml b/application/src/test/resources/logback-test.xml index 13c93da411..56dbbfc125 100644 --- a/application/src/test/resources/logback-test.xml +++ b/application/src/test/resources/logback-test.xml @@ -17,6 +17,8 @@ + +