From 827f63ea9ff22783f7753078a0cc200abece1eab Mon Sep 17 00:00:00 2001 From: Dmytro Skarzhynets Date: Tue, 28 Oct 2025 15:32:49 +0200 Subject: [PATCH 01/39] Add tests for rule engine consumer loop --- ...faultTbCalculatedFieldConsumerService.java | 6 +- .../queue/DefaultTbCoreConsumerService.java | 6 +- .../queue/DefaultTbEdgeConsumerService.java | 6 +- .../DefaultTbRuleEngineConsumerService.java | 6 +- .../TbMsgPackProcessingContextFactory.java | 35 +++ .../processing/AbstractConsumerService.java | 6 +- .../AbstractTbRuleEngineSubmitStrategy.java | 12 +- .../BatchTbRuleEngineSubmitStrategy.java | 2 +- .../BurstTbRuleEngineSubmitStrategy.java | 2 +- .../service/queue/processing/IdMsgPair.java | 14 +- ...lByEntityIdTbRuleEngineSubmitStrategy.java | 10 +- .../SequentialTbRuleEngineSubmitStrategy.java | 6 +- .../TbRuleEngineQueueConsumerManager.java | 8 +- .../src/main/resources/thingsboard.yml | 3 + .../queue/TbMsgPackProcessingContextTest.java | 260 ++++++++++++++-- .../RuleEngineConsumerLoopTest.java | 281 ++++++++++++++++++ 16 files changed, 595 insertions(+), 68 deletions(-) create mode 100644 application/src/main/java/org/thingsboard/server/service/queue/TbMsgPackProcessingContextFactory.java create mode 100644 application/src/test/java/org/thingsboard/server/service/queue/ruleengine/RuleEngineConsumerLoopTest.java diff --git a/application/src/main/java/org/thingsboard/server/service/queue/DefaultTbCalculatedFieldConsumerService.java b/application/src/main/java/org/thingsboard/server/service/queue/DefaultTbCalculatedFieldConsumerService.java index acb36449e8..91981cf86f 100644 --- a/application/src/main/java/org/thingsboard/server/service/queue/DefaultTbCalculatedFieldConsumerService.java +++ b/application/src/main/java/org/thingsboard/server/service/queue/DefaultTbCalculatedFieldConsumerService.java @@ -146,15 +146,15 @@ public class DefaultTbCalculatedFieldConsumerService extends AbstractPartitionBa private void processMsgs(List> msgs, TbQueueConsumer> consumer, Object consumerKey, QueueConfig config) throws Exception { List> orderedMsgList = msgs.stream().map(msg -> new IdMsgPair<>(UUID.randomUUID(), msg)).toList(); ConcurrentMap> pendingMap = orderedMsgList.stream().collect( - Collectors.toConcurrentMap(IdMsgPair::getUuid, IdMsgPair::getMsg)); + Collectors.toConcurrentMap(IdMsgPair::uuid, IdMsgPair::msg)); CountDownLatch processingTimeoutLatch = new CountDownLatch(1); TbPackProcessingContext> ctx = new TbPackProcessingContext<>( processingTimeoutLatch, pendingMap, new ConcurrentHashMap<>()); PendingMsgHolder pendingMsgHolder = new PendingMsgHolder<>(); Future packSubmitFuture = consumersExecutor.submit(() -> { orderedMsgList.forEach((element) -> { - UUID id = element.getUuid(); - TbProtoQueueMsg msg = element.getMsg(); + UUID id = element.uuid(); + TbProtoQueueMsg msg = element.msg(); log.trace("[{}] Creating main callback for message: {}", id, msg.getValue()); TbCallback callback = new TbPackCallback<>(id, ctx); try { diff --git a/application/src/main/java/org/thingsboard/server/service/queue/DefaultTbCoreConsumerService.java b/application/src/main/java/org/thingsboard/server/service/queue/DefaultTbCoreConsumerService.java index 9ab5a062eb..57a7018310 100644 --- a/application/src/main/java/org/thingsboard/server/service/queue/DefaultTbCoreConsumerService.java +++ b/application/src/main/java/org/thingsboard/server/service/queue/DefaultTbCoreConsumerService.java @@ -260,15 +260,15 @@ public class DefaultTbCoreConsumerService extends AbstractConsumerService> msgs, TbQueueConsumer> consumer, Object consumerKey, QueueConfig config) throws Exception { List> orderedMsgList = msgs.stream().map(msg -> new IdMsgPair<>(UUID.randomUUID(), msg)).toList(); ConcurrentMap> pendingMap = orderedMsgList.stream().collect( - Collectors.toConcurrentMap(IdMsgPair::getUuid, IdMsgPair::getMsg)); + Collectors.toConcurrentMap(IdMsgPair::uuid, IdMsgPair::msg)); CountDownLatch processingTimeoutLatch = new CountDownLatch(1); TbPackProcessingContext> ctx = new TbPackProcessingContext<>( processingTimeoutLatch, pendingMap, new ConcurrentHashMap<>()); PendingMsgHolder pendingMsgHolder = new PendingMsgHolder<>(); Future packSubmitFuture = consumersExecutor.submit(() -> { orderedMsgList.forEach((element) -> { - UUID id = element.getUuid(); - TbProtoQueueMsg msg = element.getMsg(); + UUID id = element.uuid(); + TbProtoQueueMsg msg = element.msg(); log.trace("[{}] Creating main callback for message: {}", id, msg.getValue()); TbCallback callback = new TbPackCallback<>(id, ctx); try { diff --git a/application/src/main/java/org/thingsboard/server/service/queue/DefaultTbEdgeConsumerService.java b/application/src/main/java/org/thingsboard/server/service/queue/DefaultTbEdgeConsumerService.java index 40ef9bfeca..71dacdbdaf 100644 --- a/application/src/main/java/org/thingsboard/server/service/queue/DefaultTbEdgeConsumerService.java +++ b/application/src/main/java/org/thingsboard/server/service/queue/DefaultTbEdgeConsumerService.java @@ -129,15 +129,15 @@ public class DefaultTbEdgeConsumerService extends AbstractConsumerService> msgs, TbQueueConsumer> consumer, Object consumerKey, QueueConfig edgeQueueConfig) throws InterruptedException { List> orderedMsgList = msgs.stream().map(msg -> new IdMsgPair<>(UUID.randomUUID(), msg)).toList(); ConcurrentMap> pendingMap = orderedMsgList.stream().collect( - Collectors.toConcurrentMap(IdMsgPair::getUuid, IdMsgPair::getMsg)); + Collectors.toConcurrentMap(IdMsgPair::uuid, IdMsgPair::msg)); CountDownLatch processingTimeoutLatch = new CountDownLatch(1); TbPackProcessingContext> ctx = new TbPackProcessingContext<>( processingTimeoutLatch, pendingMap, new ConcurrentHashMap<>()); PendingMsgHolder pendingMsgHolder = new PendingMsgHolder<>(); Future submitFuture = consumersExecutor.submit(() -> { orderedMsgList.forEach((element) -> { - UUID id = element.getUuid(); - TbProtoQueueMsg msg = element.getMsg(); + UUID id = element.uuid(); + TbProtoQueueMsg msg = element.msg(); TbCallback callback = new TbPackCallback<>(id, ctx); try { ToEdgeMsg toEdgeMsg = msg.getValue(); diff --git a/application/src/main/java/org/thingsboard/server/service/queue/DefaultTbRuleEngineConsumerService.java b/application/src/main/java/org/thingsboard/server/service/queue/DefaultTbRuleEngineConsumerService.java index a809f2bb11..d2a8e7a441 100644 --- a/application/src/main/java/org/thingsboard/server/service/queue/DefaultTbRuleEngineConsumerService.java +++ b/application/src/main/java/org/thingsboard/server/service/queue/DefaultTbRuleEngineConsumerService.java @@ -70,6 +70,7 @@ public class DefaultTbRuleEngineConsumerService extends AbstractPartitionBasedCo private final TbRuleEngineConsumerContext ctx; private final QueueService queueService; private final TbRuleEngineDeviceRpcService tbDeviceRpcService; + private final TbMsgPackProcessingContextFactory packProcessingContextFactory; private final ConcurrentMap consumers = new ConcurrentHashMap<>(); @@ -85,11 +86,13 @@ public class DefaultTbRuleEngineConsumerService extends AbstractPartitionBasedCo PartitionService partitionService, ApplicationEventPublisher eventPublisher, JwtSettingsService jwtSettingsService, - CalculatedFieldCache calculatedFieldCache) { + CalculatedFieldCache calculatedFieldCache, + TbMsgPackProcessingContextFactory packProcessingContextFactory) { super(actorContext, tenantProfileCache, deviceProfileCache, assetProfileCache, tbResourceDataCache, calculatedFieldCache, apiUsageStateService, partitionService, eventPublisher, jwtSettingsService); this.ctx = ctx; this.tbDeviceRpcService = tbDeviceRpcService; this.queueService = queueService; + this.packProcessingContextFactory = packProcessingContextFactory; } @Override @@ -255,6 +258,7 @@ public class DefaultTbRuleEngineConsumerService extends AbstractPartitionBasedCo .consumerExecutor(consumersExecutor) .scheduler(scheduler) .taskExecutor(mgmtExecutor) + .packProcessingContextFactory(packProcessingContextFactory) .build(); consumers.put(queueKey, consumer); consumer.init(queue); diff --git a/application/src/main/java/org/thingsboard/server/service/queue/TbMsgPackProcessingContextFactory.java b/application/src/main/java/org/thingsboard/server/service/queue/TbMsgPackProcessingContextFactory.java new file mode 100644 index 0000000000..ff804ad304 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/queue/TbMsgPackProcessingContextFactory.java @@ -0,0 +1,35 @@ +/** + * 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.queue; + +import org.springframework.stereotype.Component; +import org.thingsboard.server.service.queue.processing.TbRuleEngineSubmitStrategy; + +public interface TbMsgPackProcessingContextFactory { + + TbMsgPackProcessingContext create(String queueName, TbRuleEngineSubmitStrategy submitStrategy, boolean skipTimeouts); + + @Component + class DefaultTbMsgPackProcessingContextFactory implements TbMsgPackProcessingContextFactory { + + @Override + public TbMsgPackProcessingContext create(String queueName, TbRuleEngineSubmitStrategy submitStrategy, boolean skipTimeouts) { + return new TbMsgPackProcessingContext(queueName, submitStrategy, skipTimeouts); + } + + } + +} diff --git a/application/src/main/java/org/thingsboard/server/service/queue/processing/AbstractConsumerService.java b/application/src/main/java/org/thingsboard/server/service/queue/processing/AbstractConsumerService.java index e18cc0b09e..7e9ada0e75 100644 --- a/application/src/main/java/org/thingsboard/server/service/queue/processing/AbstractConsumerService.java +++ b/application/src/main/java/org/thingsboard/server/service/queue/processing/AbstractConsumerService.java @@ -134,13 +134,13 @@ public abstract class AbstractConsumerService> msgs, TbQueueConsumer> consumer) throws Exception { List> orderedMsgList = msgs.stream().map(msg -> new IdMsgPair<>(UUID.randomUUID(), msg)).toList(); ConcurrentMap> pendingMap = orderedMsgList.stream().collect( - Collectors.toConcurrentMap(IdMsgPair::getUuid, IdMsgPair::getMsg)); + Collectors.toConcurrentMap(IdMsgPair::uuid, IdMsgPair::msg)); CountDownLatch processingTimeoutLatch = new CountDownLatch(1); TbPackProcessingContext> ctx = new TbPackProcessingContext<>( processingTimeoutLatch, pendingMap, new ConcurrentHashMap<>()); orderedMsgList.forEach(element -> { - UUID id = element.getUuid(); - TbProtoQueueMsg msg = element.getMsg(); + UUID id = element.uuid(); + TbProtoQueueMsg msg = element.msg(); log.trace("[{}] Creating notification callback for message: {}", id, msg.getValue()); TbCallback callback = new TbPackCallback<>(id, ctx); try { diff --git a/application/src/main/java/org/thingsboard/server/service/queue/processing/AbstractTbRuleEngineSubmitStrategy.java b/application/src/main/java/org/thingsboard/server/service/queue/processing/AbstractTbRuleEngineSubmitStrategy.java index 019573631a..5a1bdae13a 100644 --- a/application/src/main/java/org/thingsboard/server/service/queue/processing/AbstractTbRuleEngineSubmitStrategy.java +++ b/application/src/main/java/org/thingsboard/server/service/queue/processing/AbstractTbRuleEngineSubmitStrategy.java @@ -44,21 +44,21 @@ public abstract class AbstractTbRuleEngineSubmitStrategy implements TbRuleEngine @Override public ConcurrentMap> getPendingMap() { - return orderedMsgList.stream().collect(Collectors.toConcurrentMap(pair -> pair.uuid, pair -> pair.msg)); + return orderedMsgList.stream().collect(Collectors.toConcurrentMap(pair -> pair.uuid(), pair -> pair.msg())); } @Override public void update(ConcurrentMap> reprocessMap) { List> newOrderedMsgList = new ArrayList<>(reprocessMap.size()); for (IdMsgPair pair : orderedMsgList) { - if (reprocessMap.containsKey(pair.uuid)) { - if (StringUtils.isNotEmpty(pair.getMsg().getValue().getFailureMessage())) { - var toRuleEngineMsg = TransportProtos.ToRuleEngineMsg.newBuilder(pair.getMsg().getValue()) + if (reprocessMap.containsKey(pair.uuid())) { + if (StringUtils.isNotEmpty(pair.msg().getValue().getFailureMessage())) { + var toRuleEngineMsg = TransportProtos.ToRuleEngineMsg.newBuilder(pair.msg().getValue()) .clearFailureMessage() .clearRelationTypes() .build(); - var newMsg = new TbProtoQueueMsg<>(pair.getMsg().getKey(), toRuleEngineMsg, pair.getMsg().getHeaders()); - newOrderedMsgList.add(new IdMsgPair<>(pair.getUuid(), newMsg)); + var newMsg = new TbProtoQueueMsg<>(pair.msg().getKey(), toRuleEngineMsg, pair.msg().getHeaders()); + newOrderedMsgList.add(new IdMsgPair<>(pair.uuid(), newMsg)); } else { newOrderedMsgList.add(pair); } diff --git a/application/src/main/java/org/thingsboard/server/service/queue/processing/BatchTbRuleEngineSubmitStrategy.java b/application/src/main/java/org/thingsboard/server/service/queue/processing/BatchTbRuleEngineSubmitStrategy.java index 93478eab18..a47c43422b 100644 --- a/application/src/main/java/org/thingsboard/server/service/queue/processing/BatchTbRuleEngineSubmitStrategy.java +++ b/application/src/main/java/org/thingsboard/server/service/queue/processing/BatchTbRuleEngineSubmitStrategy.java @@ -73,7 +73,7 @@ public class BatchTbRuleEngineSubmitStrategy extends AbstractTbRuleEngineSubmitS pendingPack.clear(); for (int i = startIdx; i < endIdx; i++) { IdMsgPair pair = orderedMsgList.get(i); - pendingPack.put(pair.uuid, pair.msg); + pendingPack.put(pair.uuid(), pair.msg()); } tmpPack = new LinkedHashMap<>(pendingPack); } diff --git a/application/src/main/java/org/thingsboard/server/service/queue/processing/BurstTbRuleEngineSubmitStrategy.java b/application/src/main/java/org/thingsboard/server/service/queue/processing/BurstTbRuleEngineSubmitStrategy.java index af465686e9..9f9dd0a60f 100644 --- a/application/src/main/java/org/thingsboard/server/service/queue/processing/BurstTbRuleEngineSubmitStrategy.java +++ b/application/src/main/java/org/thingsboard/server/service/queue/processing/BurstTbRuleEngineSubmitStrategy.java @@ -34,7 +34,7 @@ public class BurstTbRuleEngineSubmitStrategy extends AbstractTbRuleEngineSubmitS if (log.isDebugEnabled()) { log.debug("[{}] submitting [{}] messages to rule engine", queueName, orderedMsgList.size()); } - orderedMsgList.forEach(pair -> msgConsumer.accept(pair.uuid, pair.msg)); + orderedMsgList.forEach(pair -> msgConsumer.accept(pair.uuid(), pair.msg())); } @Override diff --git a/application/src/main/java/org/thingsboard/server/service/queue/processing/IdMsgPair.java b/application/src/main/java/org/thingsboard/server/service/queue/processing/IdMsgPair.java index e74362699b..6795b2822f 100644 --- a/application/src/main/java/org/thingsboard/server/service/queue/processing/IdMsgPair.java +++ b/application/src/main/java/org/thingsboard/server/service/queue/processing/IdMsgPair.java @@ -15,19 +15,9 @@ */ package org.thingsboard.server.service.queue.processing; -import lombok.Getter; +import com.google.protobuf.GeneratedMessageV3; import org.thingsboard.server.queue.common.TbProtoQueueMsg; import java.util.UUID; -public class IdMsgPair { - @Getter - final UUID uuid; - @Getter - final TbProtoQueueMsg msg; - - public IdMsgPair(UUID uuid, TbProtoQueueMsg msg) { - this.uuid = uuid; - this.msg = msg; - } -} +public record IdMsgPair(UUID uuid, TbProtoQueueMsg msg) {} diff --git a/application/src/main/java/org/thingsboard/server/service/queue/processing/SequentialByEntityIdTbRuleEngineSubmitStrategy.java b/application/src/main/java/org/thingsboard/server/service/queue/processing/SequentialByEntityIdTbRuleEngineSubmitStrategy.java index c72f1274e7..1aa1b20284 100644 --- a/application/src/main/java/org/thingsboard/server/service/queue/processing/SequentialByEntityIdTbRuleEngineSubmitStrategy.java +++ b/application/src/main/java/org/thingsboard/server/service/queue/processing/SequentialByEntityIdTbRuleEngineSubmitStrategy.java @@ -51,7 +51,7 @@ public abstract class SequentialByEntityIdTbRuleEngineSubmitStrategy extends Abs entityIdToListMap.forEach((entityId, queue) -> { IdMsgPair msg = queue.peek(); if (msg != null) { - msgConsumer.accept(msg.uuid, msg.msg); + msgConsumer.accept(msg.uuid(), msg.msg()); } }); } @@ -71,13 +71,13 @@ public abstract class SequentialByEntityIdTbRuleEngineSubmitStrategy extends Abs IdMsgPair next = null; synchronized (queue) { IdMsgPair expected = queue.peek(); - if (expected != null && expected.uuid.equals(id)) { + if (expected != null && expected.uuid().equals(id)) { queue.poll(); next = queue.peek(); } } if (next != null) { - msgConsumer.accept(next.uuid, next.msg); + msgConsumer.accept(next.uuid(), next.msg()); } } } @@ -87,9 +87,9 @@ public abstract class SequentialByEntityIdTbRuleEngineSubmitStrategy extends Abs msgToEntityIdMap.clear(); entityIdToListMap.clear(); for (IdMsgPair pair : orderedMsgList) { - EntityId entityId = getEntityId(pair.msg.getValue()); + EntityId entityId = getEntityId(pair.msg().getValue()); if (entityId != null) { - msgToEntityIdMap.put(pair.uuid, entityId); + msgToEntityIdMap.put(pair.uuid(), entityId); entityIdToListMap.computeIfAbsent(entityId, id -> new LinkedList<>()).add(pair); } } diff --git a/application/src/main/java/org/thingsboard/server/service/queue/processing/SequentialTbRuleEngineSubmitStrategy.java b/application/src/main/java/org/thingsboard/server/service/queue/processing/SequentialTbRuleEngineSubmitStrategy.java index be97853310..c048f42ee0 100644 --- a/application/src/main/java/org/thingsboard/server/service/queue/processing/SequentialTbRuleEngineSubmitStrategy.java +++ b/application/src/main/java/org/thingsboard/server/service/queue/processing/SequentialTbRuleEngineSubmitStrategy.java @@ -60,11 +60,11 @@ public class SequentialTbRuleEngineSubmitStrategy extends AbstractTbRuleEngineSu int idx = msgIdx.get(); if (idx < listSize) { IdMsgPair pair = orderedMsgList.get(idx); - expectedMsgId = pair.uuid; + expectedMsgId = pair.uuid(); if (log.isDebugEnabled()) { - log.debug("[{}] submitting [{}] message to rule engine", queueName, pair.msg); + log.debug("[{}] submitting [{}] message to rule engine", queueName, pair.msg()); } - msgConsumer.accept(pair.uuid, pair.msg); + msgConsumer.accept(pair.uuid(), pair.msg()); } } diff --git a/application/src/main/java/org/thingsboard/server/service/queue/ruleengine/TbRuleEngineQueueConsumerManager.java b/application/src/main/java/org/thingsboard/server/service/queue/ruleengine/TbRuleEngineQueueConsumerManager.java index d067be49a0..a6f7e34208 100644 --- a/application/src/main/java/org/thingsboard/server/service/queue/ruleengine/TbRuleEngineQueueConsumerManager.java +++ b/application/src/main/java/org/thingsboard/server/service/queue/ruleengine/TbRuleEngineQueueConsumerManager.java @@ -41,6 +41,7 @@ import org.thingsboard.server.queue.common.consumer.TbQueueConsumerTask; import org.thingsboard.server.queue.discovery.QueueKey; import org.thingsboard.server.service.queue.TbMsgPackCallback; import org.thingsboard.server.service.queue.TbMsgPackProcessingContext; +import org.thingsboard.server.service.queue.TbMsgPackProcessingContextFactory; import org.thingsboard.server.service.queue.TbRuleEngineConsumerStats; import org.thingsboard.server.service.queue.processing.TbRuleEngineProcessingDecision; import org.thingsboard.server.service.queue.processing.TbRuleEngineProcessingResult; @@ -66,13 +67,15 @@ public class TbRuleEngineQueueConsumerManager extends MainQueueConsumerManager { Integer partitionId = tpi != null ? tpi.getPartition().orElse(-1) : null; @@ -81,6 +84,7 @@ public class TbRuleEngineQueueConsumerManager extends MainQueueConsumerManager submitMessage(packCtx, id, msg)); final boolean timeout = !packCtx.await(queue.getPackProcessingTimeout(), TimeUnit.MILLISECONDS); diff --git a/application/src/main/resources/thingsboard.yml b/application/src/main/resources/thingsboard.yml index 61b797515a..aa0b5d7920 100644 --- a/application/src/main/resources/thingsboard.yml +++ b/application/src/main/resources/thingsboard.yml @@ -1889,6 +1889,9 @@ queue: print-interval-ms: "${TB_QUEUE_RULE_ENGINE_STATS_PRINT_INTERVAL_MS:60000}" # Max length of the error message that is printed by statistics max-error-message-length: "${TB_QUEUE_RULE_ENGINE_MAX_ERROR_MESSAGE_LENGTH:4096}" + prometheus-stats: + # Enable/disable Prometheus statistics for individual Rule Engine message processing (records time in ms for success/failure). + enabled: "${TB_QUEUE_RULE_ENGINE_PROMETHEUS_STATS_ENABLED:true}" # After a queue is deleted (or the profile's isolation option was disabled), Rule Engine will continue reading related topics during this period before deleting the actual topics topic-deletion-delay: "${TB_QUEUE_RULE_ENGINE_TOPIC_DELETION_DELAY_SEC:15}" # Size of the thread pool that handles such operations as partition changes, config updates, queue deletion diff --git a/application/src/test/java/org/thingsboard/server/service/queue/TbMsgPackProcessingContextTest.java b/application/src/test/java/org/thingsboard/server/service/queue/TbMsgPackProcessingContextTest.java index ef1febfa6c..b5d65ea035 100644 --- a/application/src/test/java/org/thingsboard/server/service/queue/TbMsgPackProcessingContextTest.java +++ b/application/src/test/java/org/thingsboard/server/service/queue/TbMsgPackProcessingContextTest.java @@ -15,14 +15,17 @@ */ package org.thingsboard.server.service.queue; -import lombok.extern.slf4j.Slf4j; -import org.junit.After; -import org.junit.Assert; -import org.junit.Test; -import org.junit.runner.RunWith; -import org.mockito.junit.MockitoJUnitRunner; +import com.google.common.util.concurrent.MoreExecutors; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; import org.thingsboard.common.util.ThingsBoardThreadFactory; import org.thingsboard.server.common.data.DataConstants; +import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.common.msg.queue.RuleEngineException; import org.thingsboard.server.gen.transport.TransportProtos; import org.thingsboard.server.queue.common.TbProtoQueueMsg; import org.thingsboard.server.service.queue.processing.TbRuleEngineSubmitStrategy; @@ -35,30 +38,241 @@ import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; import java.util.concurrent.TimeUnit; -import static org.junit.Assert.assertTrue; +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.junit.jupiter.api.Assertions.fail; import static org.mockito.ArgumentMatchers.any; +import static org.mockito.BDDMockito.then; +import static org.mockito.Mockito.lenient; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; -@Slf4j -@RunWith(MockitoJUnitRunner.class) -public class TbMsgPackProcessingContextTest { +@ExtendWith(MockitoExtension.class) +class TbMsgPackProcessingContextTest { + + TenantId tenantId = TenantId.fromUUID(UUID.randomUUID()); + + @Mock + TbRuleEngineSubmitStrategy submitStrategy; + @Mock + TbProtoQueueMsg mockMsg; + + ConcurrentMap> pendingMap; - public static final int TIMEOUT = 10; ExecutorService executorService; - @After - public void tearDown() { + @BeforeEach + void setup() { + pendingMap = new ConcurrentHashMap<>(); + lenient().when(submitStrategy.getPendingMap()).thenReturn(pendingMap); + } + + @AfterEach + void tearDown() { if (executorService != null) { - executorService.shutdownNow(); + MoreExecutors.shutdownAndAwaitTermination(executorService, 5, TimeUnit.SECONDS); } } @Test - public void testHighConcurrencyCase() throws InterruptedException { - //log.warn("preparing the test..."); + void testAwait_shouldReturnTrue_whenOnSuccessIsCalledBeforeTimeout() throws InterruptedException { + // GIVEN - a context with one pending message + executorService = Executors.newSingleThreadExecutor(); + + UUID msgId = UUID.randomUUID(); + pendingMap.put(msgId, mockMsg); + var context = new TbMsgPackProcessingContext("test-queue", submitStrategy, false); + + // WHEN - onSuccess() is called in another thread before timeout + executorService.submit(() -> { + try { + Thread.sleep(100); + context.onSuccess(msgId); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + }); + + // THEN - await() should return true (successful completion) + boolean result = context.await(5000, TimeUnit.MILLISECONDS); + assertThat(result).as("await() should return true when latch is counted down before timeout").isTrue(); + + // Verify the message was moved to success map + assertThat(context.getSuccessMap()).containsKey(msgId); + assertThat(context.getPendingMap()).isEmpty(); + assertThat(context.getExceptionsMap()).isEmpty(); + + + // Verify submit strategy was notified about successful message processing + then(submitStrategy).should().onSuccess(msgId); + } + + @Test + void testAwait_shouldReturnTrue_whenOnFailureIsCalledBeforeTimeout() throws InterruptedException { + // GIVEN - a context with one pending message + executorService = Executors.newSingleThreadExecutor(); + + UUID msgId = UUID.randomUUID(); + pendingMap.put(msgId, mockMsg); + var context = new TbMsgPackProcessingContext("test-queue", submitStrategy, false); + + var exception = new RuleEngineException("Test exception"); + + // WHEN - onFailure() is called in another thread before timeout + executorService.submit(() -> { + try { + Thread.sleep(100); + context.onFailure(tenantId, msgId, exception); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + }); + + // THEN - await() should return true (successful completion, even if message processing failed) + boolean result = context.await(5000, TimeUnit.MILLISECONDS); + assertThat(result).as("await() should return true when latch is counted down before timeout").isTrue(); + + // Verify the exception was added to exceptions map + assertThat(context.getSuccessMap()).isEmpty(); + assertThat(context.getPendingMap()).isEmpty(); + assertThat(context.getExceptionsMap()).containsEntry(tenantId, exception); + } + + @Test + void testAwait_shouldReturnFalse_whenTimeoutOccurs() throws InterruptedException { + // GIVEN - a context with one pending message and no processing + UUID msgId = UUID.randomUUID(); + pendingMap.put(msgId, mockMsg); + var context = new TbMsgPackProcessingContext("test-queue", submitStrategy, false); + + // WHEN - await() is called with short timeout and no message processing happens + long startTime = System.nanoTime(); + boolean result = context.await(100, TimeUnit.MILLISECONDS); + long elapsedTime = System.nanoTime() - startTime; + + // THEN - await() should return false (timeout occurred) + assertThat(result).as("await() should return false when timeout occurs").isFalse(); + assertThat(elapsedTime).as("await() should wait for at least the timeout duration").isGreaterThanOrEqualTo(100L); + + // Message should still be in pending map + assertThat(context.getSuccessMap()).isEmpty(); + assertThat(context.getPendingMap()).containsKey(msgId); + assertThat(context.getExceptionsMap()).isEmpty(); + } + + @Test + void testAwait_shouldHandleMultiplePendingMessages() throws InterruptedException { + // GIVEN - a context with multiple pending messages + executorService = Executors.newSingleThreadExecutor(); + + UUID msgId1 = UUID.randomUUID(); + UUID msgId2 = UUID.randomUUID(); + UUID msgId3 = UUID.randomUUID(); + + pendingMap.put(msgId1, mockMsg); + pendingMap.put(msgId2, mockMsg); + pendingMap.put(msgId3, mockMsg); + + var context = new TbMsgPackProcessingContext("test-queue", submitStrategy, false); + + // WHEN - messages are processed one by one + executorService.submit(() -> { + try { + Thread.sleep(50); + context.onSuccess(msgId1); + Thread.sleep(50); + context.onSuccess(msgId2); + Thread.sleep(50); + context.onSuccess(msgId3); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + }); + + // THEN - await() should return true only after all messages are processed + boolean result = context.await(5000, TimeUnit.MILLISECONDS); + assertThat(result).as("await() should return true after all messages are processed").isTrue(); + + // All messages should be in success map + assertThat(context.getSuccessMap()).containsKeys(msgId1, msgId2, msgId3); + assertThat(context.getPendingMap()).isEmpty(); + assertThat(context.getExceptionsMap()).isEmpty(); + } + + @Test + void testAwait_shouldNotCountDownPrematurely_withMultipleMessages() throws InterruptedException { + // GIVEN - a context with multiple pending messages + executorService = Executors.newSingleThreadExecutor(); + + UUID msgId1 = UUID.randomUUID(); + UUID msgId2 = UUID.randomUUID(); + + pendingMap.put(msgId1, mockMsg); + pendingMap.put(msgId2, mockMsg); + + var context = new TbMsgPackProcessingContext("test-queue", submitStrategy, false); + + // WHEN - only one message is processed + executorService.submit(() -> { + try { + Thread.sleep(100); + context.onSuccess(msgId1); + // msgId2 still in processing + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + }); + + // THEN: await should timeout because not all messages were processed + boolean result = context.await(2000, TimeUnit.MILLISECONDS); + assertThat(result).as("await() should timeout when not all messages are processed").isFalse(); + + // One message in success, one still pending + assertThat(context.getSuccessMap()).containsOnlyKeys(msgId1); + assertThat(context.getPendingMap()).containsOnlyKeys(msgId2); + assertThat(context.getExceptionsMap()).isEmpty(); + } + + @Test + void testAwait_shouldHandleMixedSuccessAndFailure() throws InterruptedException { + // GIVEN - multiple messages + executorService = Executors.newSingleThreadExecutor(); + + UUID msgId1 = UUID.randomUUID(); + UUID msgId2 = UUID.randomUUID(); + + pendingMap.put(msgId1, mockMsg); + pendingMap.put(msgId2, mockMsg); + + var context = new TbMsgPackProcessingContext("test-queue", submitStrategy, false); + + var exception = new RuleEngineException("Test exception"); + + // WHEN - one succeeds, one fails + executorService.submit(() -> { + try { + Thread.sleep(50); + context.onSuccess(msgId1); + Thread.sleep(50); + context.onFailure(tenantId, msgId2, exception); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + }); + + // THEN - await() should complete successfully + boolean result = context.await(5000, TimeUnit.MILLISECONDS); + assertThat(result).as("await() should return true when all messages are processed").isTrue(); + + assertThat(context.getSuccessMap()).containsOnlyKeys(msgId1); + assertThat(context.getPendingMap()).isEmpty(); + assertThat(context.getExceptionsMap()).containsEntry(tenantId, exception); + } + + @Test + void testHighConcurrencyCase() throws InterruptedException { int msgCount = 1000; int parallelCount = 5; executorService = Executors.newFixedThreadPool(parallelCount, ThingsBoardThreadFactory.forName(getClass().getSimpleName() + "-test-scope")); @@ -76,28 +290,24 @@ public class TbMsgPackProcessingContextTest { final CountDownLatch startLatch = new CountDownLatch(1); final CountDownLatch finishLatch = new CountDownLatch(parallelCount); for (int i = 0; i < parallelCount; i++) { - //final String taskName = "" + uuid + " " + i; executorService.submit(() -> { - //log.warn("ready {}", taskName); readyLatch.countDown(); try { startLatch.await(); } catch (InterruptedException e) { - Assert.fail("failed to await"); + fail("failed to await"); } - //log.warn("go {}", taskName); - context.onSuccess(uuid); - finishLatch.countDown(); }); } - assertTrue(readyLatch.await(TIMEOUT, TimeUnit.SECONDS)); + assertTrue(readyLatch.await(10, TimeUnit.SECONDS)); Thread.yield(); startLatch.countDown(); //run all-at-once submitted tasks - assertTrue(finishLatch.await(TIMEOUT, TimeUnit.SECONDS)); + assertTrue(finishLatch.await(10, TimeUnit.SECONDS)); } - assertTrue(context.await(TIMEOUT, TimeUnit.SECONDS)); + assertTrue(context.await(10, TimeUnit.SECONDS)); verify(strategyMock, times(msgCount)).onSuccess(any(UUID.class)); } + } diff --git a/application/src/test/java/org/thingsboard/server/service/queue/ruleengine/RuleEngineConsumerLoopTest.java b/application/src/test/java/org/thingsboard/server/service/queue/ruleengine/RuleEngineConsumerLoopTest.java new file mode 100644 index 0000000000..1619283586 --- /dev/null +++ b/application/src/test/java/org/thingsboard/server/service/queue/ruleengine/RuleEngineConsumerLoopTest.java @@ -0,0 +1,281 @@ +/** + * 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.queue.ruleengine; + +import com.google.common.util.concurrent.MoreExecutors; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InOrder; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.thingsboard.common.util.ThingsBoardExecutors; +import org.thingsboard.common.util.ThingsBoardThreadFactory; +import org.thingsboard.server.actors.ActorSystemContext; +import org.thingsboard.server.common.data.id.DeviceId; +import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.common.data.msg.TbMsgType; +import org.thingsboard.server.common.data.queue.ProcessingStrategy; +import org.thingsboard.server.common.data.queue.ProcessingStrategyType; +import org.thingsboard.server.common.data.queue.Queue; +import org.thingsboard.server.common.data.queue.SubmitStrategy; +import org.thingsboard.server.common.data.queue.SubmitStrategyType; +import org.thingsboard.server.common.msg.TbMsg; +import org.thingsboard.server.common.msg.TbMsgMetaData; +import org.thingsboard.server.common.msg.queue.ServiceType; +import org.thingsboard.server.common.msg.queue.TopicPartitionInfo; +import org.thingsboard.server.common.stats.StatsFactory; +import org.thingsboard.server.gen.transport.TransportProtos; +import org.thingsboard.server.queue.TbQueueAdmin; +import org.thingsboard.server.queue.TbQueueConsumer; +import org.thingsboard.server.queue.TbQueueMsg; +import org.thingsboard.server.queue.common.DefaultTbQueueMsgHeaders; +import org.thingsboard.server.queue.common.TbProtoQueueMsg; +import org.thingsboard.server.queue.discovery.PartitionService; +import org.thingsboard.server.queue.discovery.QueueKey; +import org.thingsboard.server.queue.discovery.TbServiceInfoProvider; +import org.thingsboard.server.queue.memory.DefaultInMemoryStorage; +import org.thingsboard.server.queue.memory.InMemoryStorage; +import org.thingsboard.server.queue.memory.InMemoryTbQueueConsumer; +import org.thingsboard.server.queue.provider.TbQueueProducerProvider; +import org.thingsboard.server.queue.provider.TbRuleEngineQueueFactory; +import org.thingsboard.server.service.queue.TbMsgPackProcessingContext; +import org.thingsboard.server.service.queue.TbMsgPackProcessingContextFactory; +import org.thingsboard.server.service.queue.processing.TbRuleEngineProcessingStrategyFactory; +import org.thingsboard.server.service.queue.processing.TbRuleEngineSubmitStrategy; +import org.thingsboard.server.service.queue.processing.TbRuleEngineSubmitStrategyFactory; +import org.thingsboard.server.service.stats.RuleEngineStatisticsService; + +import java.time.Duration; +import java.util.List; +import java.util.Set; +import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.awaitility.Awaitility.await; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.ArgumentMatchers.isNull; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.doAnswer; +import static org.mockito.Mockito.inOrder; +import static org.mockito.Mockito.spy; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class RuleEngineConsumerLoopTest { + + TenantId tenantId = TenantId.fromUUID(UUID.randomUUID()); + DeviceId deviceId = new DeviceId(UUID.randomUUID()); + + InMemoryStorage storage; + + @Mock + ActorSystemContext actorContext; + @Mock + StatsFactory statsFactory; + @Mock + TbRuleEngineQueueFactory queueFactory; + @Mock + RuleEngineStatisticsService statisticsService; + @Mock + TbServiceInfoProvider serviceInfoProvider; + @Mock + PartitionService partitionService; + @Mock + TbQueueProducerProvider producerProvider; + @Mock + TbQueueAdmin queueAdmin; + @Mock + TbMsgPackProcessingContextFactory packProcessingContextFactory; + @Mock + TbMsgPackProcessingContext packCtx; + + Queue mainQueue; + + TbQueueConsumer> consumer; + + TbRuleEngineConsumerContext ruleEngineConsumerContext; + TbRuleEngineQueueConsumerManager consumerManager; + + ExecutorService consumersExecutor; + ScheduledExecutorService scheduler; + ExecutorService mgmtExecutor; + + @BeforeEach + void setup() throws InterruptedException { + consumersExecutor = Executors.newCachedThreadPool(ThingsBoardThreadFactory.forName("tb-rule-engine-consumer")); + scheduler = ThingsBoardExecutors.newSingleThreadScheduledExecutor("tb-rule-engine-consumer-scheduler"); + mgmtExecutor = ThingsBoardExecutors.newWorkStealingPool(1, "tb-rule-engine-mgmt"); + + mainQueue = new Queue(); + mainQueue.setTenantId(TenantId.SYS_TENANT_ID); + mainQueue.setName("Main"); + mainQueue.setTopic("tb_rule_engine.main"); + mainQueue.setPollInterval(25); + mainQueue.setPartitions(1); + mainQueue.setConsumerPerPartition(false); + mainQueue.setPackProcessingTimeout(2000L); + + var submitStrategy = new SubmitStrategy(); + submitStrategy.setType(SubmitStrategyType.BURST); + submitStrategy.setBatchSize(1000); + mainQueue.setSubmitStrategy(submitStrategy); + + var processingStrategy = new ProcessingStrategy(); + processingStrategy.setType(ProcessingStrategyType.SKIP_ALL_FAILURES); + processingStrategy.setRetries(3); + processingStrategy.setFailurePercentage(0.0); + processingStrategy.setPauseBetweenRetries(3); + processingStrategy.setMaxPauseBetweenRetries(3); + mainQueue.setProcessingStrategy(processingStrategy); + + storage = new DefaultInMemoryStorage(); + + consumer = spy(new InMemoryTbQueueConsumer<>(storage, mainQueue.getTopic())); + given(queueFactory.createToRuleEngineMsgConsumer(eq(mainQueue), isNull())).willReturn(consumer); + + ruleEngineConsumerContext = new TbRuleEngineConsumerContext( + actorContext, statsFactory, new TbRuleEngineSubmitStrategyFactory(), new TbRuleEngineProcessingStrategyFactory(), + queueFactory, statisticsService, serviceInfoProvider, partitionService, producerProvider, queueAdmin + ); + ruleEngineConsumerContext.setPollDuration(25); + ruleEngineConsumerContext.setPackProcessingTimeout(2000); + ruleEngineConsumerContext.setStatsEnabled(false); // true by default + ruleEngineConsumerContext.setPrometheusStatsEnabled(false); + ruleEngineConsumerContext.setTopicDeletionDelayInSec(15); + ruleEngineConsumerContext.setMgmtThreadPoolSize(12); + + // Tell the (mock) context factory to return (mock) message pack context + given(packProcessingContextFactory.create( + eq(mainQueue.getName()), + any(TbRuleEngineSubmitStrategy.class), + eq(false) + )).willAnswer(invocation -> { + TbRuleEngineSubmitStrategy realStrategy = invocation.getArgument(1); + when(packCtx.getPendingMap()).thenAnswer(i -> realStrategy.getPendingMap()); + when(packCtx.getFailedMap()).thenReturn(new ConcurrentHashMap<>()); + return packCtx; + }); + + // Tell the (mock) context's await() to return 'false' (always timeout) immediately + given(packCtx.await(anyLong(), any(TimeUnit.class))).willReturn(false); + + consumerManager = TbRuleEngineQueueConsumerManager.create() + .ctx(ruleEngineConsumerContext) + .queueKey(new QueueKey(ServiceType.TB_RULE_ENGINE, mainQueue)) + .consumerExecutor(consumersExecutor) + .scheduler(scheduler) + .taskExecutor(mgmtExecutor) + .packProcessingContextFactory(packProcessingContextFactory) + .build(); + } + + @AfterEach + void destroy() { + MoreExecutors.shutdownAndAwaitTermination(scheduler, Duration.ofSeconds(30)); + MoreExecutors.shutdownAndAwaitTermination(mgmtExecutor, Duration.ofSeconds(30)); + MoreExecutors.shutdownAndAwaitTermination(consumersExecutor, Duration.ofSeconds(30)); + } + + @Test + void consumerLoopTest_verifyOperationsOrder() throws InterruptedException { + // Create partition + var partition = TopicPartitionInfo.builder() + .tenantId(TenantId.SYS_TENANT_ID) + .topic(mainQueue.getTopic()) + .partition(0) + .myPartition(true) + .useInternalPartition(false) + .build(); + + // Put 10k messages to the queue + for (int i = 0; i < 10_000; i++) { + var tbMsg = TbMsg.newMsg() + .type(TbMsgType.POST_TELEMETRY_REQUEST) + .originator(deviceId) + .data("{\"temperature\":123}") + .metaData(TbMsgMetaData.EMPTY) + .build(); + + var toRuleEngineMsg = TransportProtos.ToRuleEngineMsg.newBuilder() + .setTenantIdLSB(tenantId.getId().getLeastSignificantBits()) + .setTenantIdMSB(tenantId.getId().getMostSignificantBits()) + .setTbMsgProto(TbMsg.toProto(tbMsg)) + .addAllRelationTypes(Set.of("Success")) + .build(); + + storage.put(partition.getFullTopicName(), new TbProtoQueueMsg<>(UUID.randomUUID(), toRuleEngineMsg, new DefaultTbQueueMsgHeaders())); + } + + // Count how many polls were made + var totalPolls = new AtomicInteger(0); + var emptyPolls = new AtomicInteger(0); + doAnswer(invocation -> { + totalPolls.incrementAndGet(); + @SuppressWarnings("unchecked") + var messages = (List) invocation.callRealMethod(); + if (messages.isEmpty()) { + emptyPolls.incrementAndGet(); + } + return messages; + }).when(consumer).poll(mainQueue.getPollInterval()); + + // Count how many commits were made + var totalCommits = new AtomicInteger(0); + doAnswer(invocation -> { + totalCommits.incrementAndGet(); + return invocation.callRealMethod(); + }).when(consumer).commit(); + + // Initialize consumer + consumerManager.init(mainQueue); + + // Assign partition to the consumer + consumerManager.update(Set.of(partition)); + + // Give some time for the consumer to get all messages + await().atMost(Duration.ofSeconds(10L)).until(() -> storage.getLagTotal() == 0); + + // Stop consumer + consumerManager.stop(); + consumerManager.awaitStop(); + + // Determine number of non-empty consumer iterations made, since polling does not stop immediately after consuming all messages and may do a few empty polls + int nonEmptyPolls = totalPolls.get() - emptyPolls.get(); + + // Verify that there is 10 polls and 10 matching commits + // Each poll consumes 1k messages and queue has 10k total, so that means 10k total msgs / 1k msgs per poll = 10 polls + assertThat(nonEmptyPolls).isEqualTo(10).isEqualTo(totalCommits.get()); + + // Verify that poll-await-commit cycle happened in order with correct await timeout + InOrder inOrder = inOrder(consumer, packCtx); + for (int i = 0; i < nonEmptyPolls; i++) { + inOrder.verify(consumer).poll(mainQueue.getPollInterval()); + inOrder.verify(packCtx).await(mainQueue.getPackProcessingTimeout(), TimeUnit.MILLISECONDS); + inOrder.verify(consumer).commit(); + } + } + +} From 56a83b6cfec7794a92cae6889722d0bb5e565d0d Mon Sep 17 00:00:00 2001 From: Dmytro Skarzhynets Date: Tue, 28 Oct 2025 16:19:05 +0200 Subject: [PATCH 02/39] Fix existing test --- .../queue/ruleengine/TbRuleEngineQueueConsumerManagerTest.java | 2 ++ .../service/queue/ruleengine/TbRuleEngineStrategyTest.java | 2 ++ 2 files changed, 4 insertions(+) diff --git a/application/src/test/java/org/thingsboard/server/service/queue/ruleengine/TbRuleEngineQueueConsumerManagerTest.java b/application/src/test/java/org/thingsboard/server/service/queue/ruleengine/TbRuleEngineQueueConsumerManagerTest.java index bcbe52b5c9..3c54d421e4 100644 --- a/application/src/test/java/org/thingsboard/server/service/queue/ruleengine/TbRuleEngineQueueConsumerManagerTest.java +++ b/application/src/test/java/org/thingsboard/server/service/queue/ruleengine/TbRuleEngineQueueConsumerManagerTest.java @@ -59,6 +59,7 @@ import org.thingsboard.server.queue.provider.KafkaMonolithQueueFactory; import org.thingsboard.server.queue.provider.KafkaTbRuleEngineQueueFactory; import org.thingsboard.server.queue.provider.TbQueueProducerProvider; import org.thingsboard.server.queue.provider.TbRuleEngineQueueFactory; +import org.thingsboard.server.service.queue.TbMsgPackProcessingContextFactory; import org.thingsboard.server.service.queue.processing.TbRuleEngineProcessingStrategyFactory; import org.thingsboard.server.service.queue.processing.TbRuleEngineSubmitStrategyFactory; import org.thingsboard.server.service.stats.RuleEngineStatisticsService; @@ -194,6 +195,7 @@ public class TbRuleEngineQueueConsumerManagerTest { .consumerExecutor(consumersExecutor) .scheduler(scheduler) .taskExecutor(mgmtExecutor) + .packProcessingContextFactory(new TbMsgPackProcessingContextFactory.DefaultTbMsgPackProcessingContextFactory()) .build(); } diff --git a/application/src/test/java/org/thingsboard/server/service/queue/ruleengine/TbRuleEngineStrategyTest.java b/application/src/test/java/org/thingsboard/server/service/queue/ruleengine/TbRuleEngineStrategyTest.java index 1106fad5b6..c27a03619a 100644 --- a/application/src/test/java/org/thingsboard/server/service/queue/ruleengine/TbRuleEngineStrategyTest.java +++ b/application/src/test/java/org/thingsboard/server/service/queue/ruleengine/TbRuleEngineStrategyTest.java @@ -44,6 +44,7 @@ import org.thingsboard.server.gen.transport.TransportProtos.ToRuleEngineMsg; import org.thingsboard.server.queue.TbQueueConsumer; import org.thingsboard.server.queue.common.TbProtoQueueMsg; import org.thingsboard.server.queue.discovery.QueueKey; +import org.thingsboard.server.service.queue.TbMsgPackProcessingContextFactory; import org.thingsboard.server.service.queue.processing.TbRuleEngineProcessingStrategyFactory; import org.thingsboard.server.service.queue.processing.TbRuleEngineSubmitStrategyFactory; @@ -194,6 +195,7 @@ public class TbRuleEngineStrategyTest { var consumerManager = TbRuleEngineQueueConsumerManager.create() .ctx(ruleEngineConsumerContext) .queueKey(queueKey) + .packProcessingContextFactory(new TbMsgPackProcessingContextFactory.DefaultTbMsgPackProcessingContextFactory()) .build(); consumerManager.init(queue); From cb2bd954b6a99e32596efc9be84d16040d18fab8 Mon Sep 17 00:00:00 2001 From: Dmytro Skarzhynets Date: Wed, 3 Dec 2025 13:20:15 +0200 Subject: [PATCH 03/39] Fix default value for Prometheus rule engine stats --- application/src/main/resources/thingsboard.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/application/src/main/resources/thingsboard.yml b/application/src/main/resources/thingsboard.yml index 2ee0d23502..2cb53a8a4b 100644 --- a/application/src/main/resources/thingsboard.yml +++ b/application/src/main/resources/thingsboard.yml @@ -1913,7 +1913,7 @@ queue: max-error-message-length: "${TB_QUEUE_RULE_ENGINE_MAX_ERROR_MESSAGE_LENGTH:4096}" prometheus-stats: # Enable/disable Prometheus statistics for individual Rule Engine message processing (records time in ms for success/failure). - enabled: "${TB_QUEUE_RULE_ENGINE_PROMETHEUS_STATS_ENABLED:true}" + enabled: "${TB_QUEUE_RULE_ENGINE_PROMETHEUS_STATS_ENABLED:false}" # After a queue is deleted (or the profile's isolation option was disabled), Rule Engine will continue reading related topics during this period before deleting the actual topics topic-deletion-delay: "${TB_QUEUE_RULE_ENGINE_TOPIC_DELETION_DELAY_SEC:15}" # Size of the thread pool that handles such operations as partition changes, config updates, queue deletion From eafa9e6846f6d5548e8f6d22d323dc22e798d543 Mon Sep 17 00:00:00 2001 From: nickAS21 Date: Wed, 7 Jan 2026 15:58:41 +0200 Subject: [PATCH 04/39] fix bug validateKeyValueProtos String empty - ok --- .../org/thingsboard/server/common/adaptor/ProtoConverter.java | 4 ---- 1 file changed, 4 deletions(-) diff --git a/common/proto/src/main/java/org/thingsboard/server/common/adaptor/ProtoConverter.java b/common/proto/src/main/java/org/thingsboard/server/common/adaptor/ProtoConverter.java index 6dc9387a37..a47caae954 100644 --- a/common/proto/src/main/java/org/thingsboard/server/common/adaptor/ProtoConverter.java +++ b/common/proto/src/main/java/org/thingsboard/server/common/adaptor/ProtoConverter.java @@ -156,11 +156,7 @@ public class ProtoConverter { case BOOLEAN_V: case LONG_V: case DOUBLE_V: - break; case STRING_V: - if (StringUtils.isEmpty(keyValueProto.getStringV())) { - throw new IllegalArgumentException("Value is empty for key: " + key + "!"); - } break; case JSON_V: try { From 493aabebc1bf82ce91d33cb27ab013078ce90285 Mon Sep 17 00:00:00 2001 From: nickAS21 Date: Wed, 7 Jan 2026 16:28:10 +0200 Subject: [PATCH 05/39] fix bug validateKeyValueProtos String empty - add test --- .../AbstractMqttV5ClientSparkplugTest.java | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/application/src/test/java/org/thingsboard/server/transport/mqtt/sparkplug/AbstractMqttV5ClientSparkplugTest.java b/application/src/test/java/org/thingsboard/server/transport/mqtt/sparkplug/AbstractMqttV5ClientSparkplugTest.java index 6fb08f9eea..4e4d0debf9 100644 --- a/application/src/test/java/org/thingsboard/server/transport/mqtt/sparkplug/AbstractMqttV5ClientSparkplugTest.java +++ b/application/src/test/java/org/thingsboard/server/transport/mqtt/sparkplug/AbstractMqttV5ClientSparkplugTest.java @@ -159,14 +159,17 @@ public abstract class AbstractMqttV5ClientSparkplugTest extends AbstractMqttInte protected List connectClientWithCorrectAccessTokenWithNDEATHCreatedDevices(int cntDevices, long ts) throws Exception { List devices = new ArrayList<>(); clientWithCorrectNodeAccessTokenWithNDEATH(); - MetricDataType metricDataType = Int32; - String key = "Node Metric int32"; + String keyInt = "Node Metric int32"; int valueDeviceInt32 = 1024; - SparkplugBProto.Payload.Metric metric = createMetric(valueDeviceInt32, ts, key, metricDataType, -1L); + SparkplugBProto.Payload.Metric metricInt = createMetric(valueDeviceInt32, ts, keyInt, Int32, -1L); + String keyStringEmpty = "Node Metric String Empty"; + String valueDeviceStringEmpty = ""; + SparkplugBProto.Payload.Metric metricStringEmpty = createMetric(valueDeviceStringEmpty, ts, keyStringEmpty, MetricDataType.String, -1L); SparkplugBProto.Payload.Builder payloadBirthNode = SparkplugBProto.Payload.newBuilder() .setTimestamp(ts) .setSeq(getBdSeqNum()); - payloadBirthNode.addMetrics(metric); + payloadBirthNode.addMetrics(metricInt); + payloadBirthNode.addMetrics(metricStringEmpty); payloadBirthNode.setTimestamp(ts); if (client.isConnected()) { client.publish(TOPIC_ROOT_SPB_V_1_0 + "/" + groupId + "/" + SparkplugMessageType.NBIRTH.name() + "/" + edgeNode, @@ -174,14 +177,14 @@ public abstract class AbstractMqttV5ClientSparkplugTest extends AbstractMqttInte } valueDeviceInt32 = 4024; - metric = createMetric(valueDeviceInt32, ts, metricBirthName_Int32, metricBirthDataType_Int32, -1L); + metricInt = createMetric(valueDeviceInt32, ts, metricBirthName_Int32, metricBirthDataType_Int32, -1L); for (int i = 0; i < cntDevices; i++) { SparkplugBProto.Payload.Builder payloadBirthDevice = SparkplugBProto.Payload.newBuilder() .setTimestamp(ts) .setSeq(getSeqNum()); String deviceName = deviceId + "_" + i; - payloadBirthDevice.addMetrics(metric); + payloadBirthDevice.addMetrics(metricInt); if (client.isConnected()) { client.publish(TOPIC_ROOT_SPB_V_1_0 + "/" + groupId + "/" + SparkplugMessageType.DBIRTH.name() + "/" + edgeNode + "/" + deviceName, payloadBirthDevice.build().toByteArray(), 0, false); From 49c34ebe91f20fa65fed267f9509f15b783c4156 Mon Sep 17 00:00:00 2001 From: Dmytro Skarzhynets Date: Thu, 15 Jan 2026 17:33:12 +0200 Subject: [PATCH 06/39] Use min value from tenant profile for scheduled update interval when not provided --- ...SupportedCalculatedFieldConfiguration.java | 8 +- ...gregationCalculatedFieldConfiguration.java | 2 +- ...eofencingCalculatedFieldConfiguration.java | 5 +- ...ncingCalculatedFieldConfigurationTest.java | 14 ++ .../dao/cf/BaseCalculatedFieldService.java | 14 ++ .../service/CalculatedFieldServiceTest.java | 169 +++++++++++++++++- 6 files changed, 201 insertions(+), 11 deletions(-) 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 f5b45c6d5c..f78ff72af0 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 @@ -22,14 +22,14 @@ public interface ScheduledUpdateSupportedCalculatedFieldConfiguration extends Ca boolean isScheduledUpdateEnabled(); @PositiveOrZero - int getScheduledUpdateInterval(); + Integer getScheduledUpdateInterval(); - void setScheduledUpdateInterval(int interval); + void setScheduledUpdateInterval(Integer interval); default void validate(long minAllowedScheduledUpdateInterval) { if (getScheduledUpdateInterval() < minAllowedScheduledUpdateInterval) { - throw new IllegalArgumentException("Scheduled update interval is less than configured " + - "minimum allowed interval in tenant profile: " + minAllowedScheduledUpdateInterval); + throw new IllegalArgumentException("Scheduled update interval (" + getScheduledUpdateInterval() + + " seconds) is less than minimum allowed interval in tenant profile: " + minAllowedScheduledUpdateInterval + " seconds"); } } } diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/aggregation/RelatedEntitiesAggregationCalculatedFieldConfiguration.java b/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/aggregation/RelatedEntitiesAggregationCalculatedFieldConfiguration.java index 3b0df7ed82..98b4b691e3 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/aggregation/RelatedEntitiesAggregationCalculatedFieldConfiguration.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/aggregation/RelatedEntitiesAggregationCalculatedFieldConfiguration.java @@ -44,7 +44,7 @@ public class RelatedEntitiesAggregationCalculatedFieldConfiguration implements A private Output output; private boolean useLatestTs; - private int scheduledUpdateInterval; + private Integer scheduledUpdateInterval; @Override public CalculatedFieldType getType() { 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 2d1abd0024..c85604d2ab 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 @@ -46,7 +46,7 @@ public class GeofencingCalculatedFieldConfiguration implements ArgumentsBasedCal private Map zoneGroups; private boolean scheduledUpdateEnabled; - private int scheduledUpdateInterval; + private Integer scheduledUpdateInterval; private Output output; @@ -79,6 +79,9 @@ public class GeofencingCalculatedFieldConfiguration implements ArgumentsBasedCal @Override public void validate() { + if (scheduledUpdateEnabled && scheduledUpdateInterval == null) { + throw new IllegalArgumentException("Refresh interval is required when periodic zone group refresh is enabled."); + } zoneGroups.forEach((key, value) -> value.validate(key)); } 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 2f391435b2..2c68bcd8c5 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,6 +28,7 @@ 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; @@ -103,4 +104,17 @@ public class GeofencingCalculatedFieldConfigurationTest { assertThat(allowedZonesArgument.getRefEntityKey()).isEqualTo(new ReferencedEntityKey("perimeter", ArgumentType.ATTRIBUTE, AttributeScope.SERVER_SCOPE)); } + @Test + void validateShouldThrowWhenScheduledUpdateEnabledButIntervalNotSet() { + var cfg = new GeofencingCalculatedFieldConfiguration(); + cfg.setEntityCoordinates(mock(EntityCoordinates.class)); + cfg.setZoneGroups(Map.of("zone", mock(ZoneGroupConfiguration.class))); + cfg.setScheduledUpdateEnabled(true); + cfg.setScheduledUpdateInterval(null); + + assertThatThrownBy(cfg::validate) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("Refresh interval is required when periodic zone group refresh is enabled."); + } + } 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 00c5ec372e..075e2d8a9d 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 @@ -26,18 +26,21 @@ import org.thingsboard.server.common.data.cf.CalculatedFieldFilter; import org.thingsboard.server.common.data.cf.CalculatedFieldInfo; import org.thingsboard.server.common.data.cf.CalculatedFieldType; import org.thingsboard.server.common.data.cf.configuration.CalculatedFieldConfiguration; +import org.thingsboard.server.common.data.cf.configuration.aggregation.RelatedEntitiesAggregationCalculatedFieldConfiguration; import org.thingsboard.server.common.data.id.CalculatedFieldId; 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.common.data.tenant.profile.DefaultTenantProfileConfiguration; import org.thingsboard.server.dao.entity.AbstractEntityService; import org.thingsboard.server.dao.entity.EntityService; import org.thingsboard.server.dao.eventsourcing.DeleteEntityEvent; import org.thingsboard.server.dao.eventsourcing.SaveEntityEvent; import org.thingsboard.server.dao.exception.IncorrectParameterException; import org.thingsboard.server.dao.service.validator.CalculatedFieldDataValidator; +import org.thingsboard.server.dao.usagerecord.ApiLimitService; import java.util.EnumSet; import java.util.List; @@ -62,6 +65,7 @@ public class BaseCalculatedFieldService extends AbstractEntityService implements private final EntityService entityService; private final CalculatedFieldDao calculatedFieldDao; private final CalculatedFieldDataValidator calculatedFieldDataValidator; + private final ApiLimitService apiLimitService; @Override public CalculatedField save(CalculatedField calculatedField) { @@ -70,6 +74,7 @@ public class BaseCalculatedFieldService extends AbstractEntityService implements @Override public CalculatedField save(CalculatedField calculatedField, boolean doValidate) { + setConfigurationDefaults(calculatedField); CalculatedField oldCalculatedField = null; if (doValidate) { oldCalculatedField = calculatedFieldDataValidator.validate(calculatedField, CalculatedField::getTenantId); @@ -79,6 +84,15 @@ public class BaseCalculatedFieldService extends AbstractEntityService implements return doSave(calculatedField, oldCalculatedField); } + private void setConfigurationDefaults(CalculatedField calculatedField) { + if (calculatedField.getConfiguration() instanceof RelatedEntitiesAggregationCalculatedFieldConfiguration config + && config.getScheduledUpdateInterval() == null) { + int minScheduledUpdateInterval = (int) apiLimitService.getLimit( + calculatedField.getTenantId(), DefaultTenantProfileConfiguration::getMinAllowedScheduledUpdateIntervalInSecForCF + ); + config.setScheduledUpdateInterval(minScheduledUpdateInterval); + } + } private CalculatedField doSave(CalculatedField calculatedField, CalculatedField oldCalculatedField) { try { diff --git a/dao/src/test/java/org/thingsboard/server/dao/service/CalculatedFieldServiceTest.java b/dao/src/test/java/org/thingsboard/server/dao/service/CalculatedFieldServiceTest.java index 97e06ed879..afb380c5be 100644 --- a/dao/src/test/java/org/thingsboard/server/dao/service/CalculatedFieldServiceTest.java +++ b/dao/src/test/java/org/thingsboard/server/dao/service/CalculatedFieldServiceTest.java @@ -15,6 +15,7 @@ */ package org.thingsboard.server.dao.service; +import org.apache.commons.lang3.RandomUtils; import org.junit.Test; import org.springframework.beans.factory.annotation.Autowired; import org.thingsboard.server.common.data.Device; @@ -27,6 +28,10 @@ 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; import org.thingsboard.server.common.data.cf.configuration.TimeSeriesOutput; +import org.thingsboard.server.common.data.cf.configuration.aggregation.AggFunction; +import org.thingsboard.server.common.data.cf.configuration.aggregation.AggKeyInput; +import org.thingsboard.server.common.data.cf.configuration.aggregation.AggMetric; +import org.thingsboard.server.common.data.cf.configuration.aggregation.RelatedEntitiesAggregationCalculatedFieldConfiguration; import org.thingsboard.server.common.data.cf.configuration.geofencing.EntityCoordinates; import org.thingsboard.server.common.data.cf.configuration.geofencing.GeofencingCalculatedFieldConfiguration; import org.thingsboard.server.common.data.cf.configuration.geofencing.ZoneGroupConfiguration; @@ -36,8 +41,8 @@ import org.thingsboard.server.common.data.relation.EntitySearchDirection; import org.thingsboard.server.common.data.relation.RelationPathLevel; import org.thingsboard.server.dao.cf.CalculatedFieldService; import org.thingsboard.server.dao.device.DeviceService; -import org.thingsboard.server.exception.DataValidationException; import org.thingsboard.server.dao.tenant.TbTenantProfileCache; +import org.thingsboard.server.exception.DataValidationException; import java.util.ArrayList; import java.util.List; @@ -107,11 +112,11 @@ public class CalculatedFieldServiceTest extends AbstractServiceTest { int min = tbTenantProfileCache.get(tenantId) .getDefaultProfileConfiguration() .getMinAllowedScheduledUpdateIntervalInSecForCF(); - int valueFromConfig = min - 10; // Enable scheduling with an interval below tenant min cfg.setScheduledUpdateEnabled(true); - cfg.setScheduledUpdateInterval(valueFromConfig); + int invalidInterval = RandomUtils.insecure().randomInt(1, min); + cfg.setScheduledUpdateInterval(invalidInterval); // Create & save Calculated Field CalculatedField cf = new CalculatedField(); @@ -125,8 +130,8 @@ public class CalculatedFieldServiceTest extends AbstractServiceTest { assertThatThrownBy(() -> calculatedFieldService.save(cf)) .isInstanceOf(DataValidationException.class) .hasCauseInstanceOf(IllegalArgumentException.class) - .hasMessageStartingWith("Scheduled update interval is less than configured " + - "minimum allowed interval in tenant profile: "); + .hasMessage("Scheduled update interval (" + invalidInterval + + " seconds) is less than minimum allowed interval in tenant profile: " + min + " seconds"); } @Test @@ -253,6 +258,160 @@ public class CalculatedFieldServiceTest extends AbstractServiceTest { assertThat(calculatedFieldService.findById(tenantId, savedCalculatedField.getId())).isNull(); } + @Test + public void testSaveRelatedEntitiesAggregationCF_shouldUseMinScheduledUpdateIntervalFromTenantProfileWhenNotSet() { + // GIVEN + var device = createTestDevice(); + + var cfg = new RelatedEntitiesAggregationCalculatedFieldConfiguration(); + cfg.setRelation(new RelationPathLevel(EntitySearchDirection.FROM, EntityRelation.CONTAINS_TYPE)); + + var argument = new Argument(); + argument.setRefEntityKey(new ReferencedEntityKey("temperature", ArgumentType.TS_LATEST, null)); + cfg.setArguments(Map.of("temp", argument)); + + var metric = new AggMetric(); + metric.setFunction(AggFunction.AVG); + metric.setInput(new AggKeyInput("temp")); + cfg.setMetrics(Map.of("avgTemp", metric)); + + var output = new TimeSeriesOutput(); + output.setName("avgTemperature"); + cfg.setOutput(output); + + int minDeduplicationInterval = (int) tbTenantProfileCache.get(tenantId) + .getDefaultProfileConfiguration() + .getMinAllowedDeduplicationIntervalInSecForCF(); + cfg.setDeduplicationIntervalInSec(minDeduplicationInterval); + + // Do NOT set scheduledUpdateInterval - it should default to tenant profile min value + + var cf = new CalculatedField(); + cf.setTenantId(tenantId); + cf.setEntityId(device.getId()); + cf.setType(CalculatedFieldType.RELATED_ENTITIES_AGGREGATION); + cf.setName("Related Entities Aggregation CF - default scheduled interval test"); + cf.setConfigurationVersion(0); + cf.setConfiguration(cfg); + + // WHEN + CalculatedField saved = calculatedFieldService.save(cf); + + // THEN + assertThat(saved).isNotNull(); + assertThat(saved.getConfiguration()).isInstanceOf(RelatedEntitiesAggregationCalculatedFieldConfiguration.class); + + var savedConfig = (RelatedEntitiesAggregationCalculatedFieldConfiguration) saved.getConfiguration(); + int expectedMinScheduledUpdateInterval = tbTenantProfileCache.get(tenantId) + .getDefaultProfileConfiguration() + .getMinAllowedScheduledUpdateIntervalInSecForCF(); + + assertThat(savedConfig.getScheduledUpdateInterval()).isEqualTo(expectedMinScheduledUpdateInterval); + + calculatedFieldService.deleteCalculatedField(tenantId, saved.getId()); + } + + @Test + public void testSaveRelatedEntitiesAggregationCF_shouldThrowWhenScheduledUpdateIntervalLessThanMinAllowed() { + // GIVEN + var device = createTestDevice(); + + var cfg = new RelatedEntitiesAggregationCalculatedFieldConfiguration(); + cfg.setRelation(new RelationPathLevel(EntitySearchDirection.FROM, EntityRelation.CONTAINS_TYPE)); + + var argument = new Argument(); + argument.setRefEntityKey(new ReferencedEntityKey("temperature", ArgumentType.TS_LATEST, null)); + cfg.setArguments(Map.of("temp", argument)); + + var metric = new AggMetric(); + metric.setFunction(AggFunction.AVG); + metric.setInput(new AggKeyInput("temp")); + cfg.setMetrics(Map.of("avgTemp", metric)); + + var output = new TimeSeriesOutput(); + output.setName("avgTemperature"); + cfg.setOutput(output); + + int minDeduplicationInterval = (int) tbTenantProfileCache.get(tenantId) + .getDefaultProfileConfiguration() + .getMinAllowedDeduplicationIntervalInSecForCF(); + cfg.setDeduplicationIntervalInSec(minDeduplicationInterval); + + int minScheduledUpdateInterval = tbTenantProfileCache.get(tenantId) + .getDefaultProfileConfiguration() + .getMinAllowedScheduledUpdateIntervalInSecForCF(); + int invalidInterval = RandomUtils.insecure().randomInt(1, minScheduledUpdateInterval); + cfg.setScheduledUpdateInterval(invalidInterval); + + var cf = new CalculatedField(); + cf.setTenantId(tenantId); + cf.setEntityId(device.getId()); + cf.setType(CalculatedFieldType.RELATED_ENTITIES_AGGREGATION); + cf.setName("Related Entities Aggregation CF - invalid scheduled interval test"); + cf.setConfigurationVersion(0); + cf.setConfiguration(cfg); + + // WHEN-THEN + assertThatThrownBy(() -> calculatedFieldService.save(cf)) + .isInstanceOf(DataValidationException.class) + .hasCauseInstanceOf(IllegalArgumentException.class) + .hasMessage("Scheduled update interval (" + invalidInterval + + " seconds) is less than minimum allowed interval in tenant profile: " + minScheduledUpdateInterval + " seconds"); + } + + @Test + public void testSaveRelatedEntitiesAggregationCF_shouldAcceptValidScheduledUpdateInterval() { + // GIVEN + var device = createTestDevice(); + + var cfg = new RelatedEntitiesAggregationCalculatedFieldConfiguration(); + cfg.setRelation(new RelationPathLevel(EntitySearchDirection.FROM, EntityRelation.CONTAINS_TYPE)); + + var argument = new Argument(); + argument.setRefEntityKey(new ReferencedEntityKey("temperature", ArgumentType.TS_LATEST, null)); + cfg.setArguments(Map.of("temp", argument)); + + var metric = new AggMetric(); + metric.setFunction(AggFunction.AVG); + metric.setInput(new AggKeyInput("temp")); + cfg.setMetrics(Map.of("avgTemp", metric)); + + var output = new TimeSeriesOutput(); + output.setName("avgTemperature"); + cfg.setOutput(output); + + int minDeduplicationInterval = (int) tbTenantProfileCache.get(tenantId) + .getDefaultProfileConfiguration() + .getMinAllowedDeduplicationIntervalInSecForCF(); + cfg.setDeduplicationIntervalInSec(minDeduplicationInterval); + + int minScheduledUpdateInterval = tbTenantProfileCache.get(tenantId) + .getDefaultProfileConfiguration() + .getMinAllowedScheduledUpdateIntervalInSecForCF(); + int customScheduledUpdateInterval = minScheduledUpdateInterval + 100; + cfg.setScheduledUpdateInterval(customScheduledUpdateInterval); + + var cf = new CalculatedField(); + cf.setTenantId(tenantId); + cf.setEntityId(device.getId()); + cf.setType(CalculatedFieldType.RELATED_ENTITIES_AGGREGATION); + cf.setName("Related Entities Aggregation CF - valid scheduled interval test"); + cf.setConfigurationVersion(0); + cf.setConfiguration(cfg); + + // WHEN + CalculatedField saved = calculatedFieldService.save(cf); + + // THEN + assertThat(saved).isNotNull(); + assertThat(saved.getConfiguration()).isInstanceOf(RelatedEntitiesAggregationCalculatedFieldConfiguration.class); + + var savedConfig = (RelatedEntitiesAggregationCalculatedFieldConfiguration) saved.getConfiguration(); + assertThat(savedConfig.getScheduledUpdateInterval()).isEqualTo(customScheduledUpdateInterval); + + calculatedFieldService.deleteCalculatedField(tenantId, saved.getId()); + } + private CalculatedField saveValidCalculatedField() { Device device = createTestDevice(); CalculatedField calculatedField = getCalculatedField(device.getId(), device.getId()); From 231b2fe8bc178f075ff66b5b3d05b8e74f2f88d6 Mon Sep 17 00:00:00 2001 From: Dmytro Skarzhynets Date: Thu, 15 Jan 2026 18:07:35 +0200 Subject: [PATCH 07/39] Add validation for min scheduled update interval in tenant profile --- .../DefaultTenantProfileConfiguration.java | 2 + .../TenantProfileDataValidatorTest.java | 56 +++++++++++++++++-- 2 files changed, 53 insertions(+), 5 deletions(-) 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 a04816365b..297f92cffb 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 @@ -17,6 +17,7 @@ package org.thingsboard.server.common.data.tenant.profile; import io.swagger.v3.oas.annotations.media.Schema; import jakarta.validation.constraints.Positive; +import jakarta.validation.constraints.PositiveOrZero; import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Data; @@ -173,6 +174,7 @@ public class DefaultTenantProfileConfiguration implements TenantProfileConfigura @Schema(example = "10") private long maxArgumentsPerCF = 10; @Schema(example = "10") + @PositiveOrZero private int minAllowedScheduledUpdateIntervalInSecForCF = 10; @Builder.Default @Schema(example = "2") diff --git a/dao/src/test/java/org/thingsboard/server/dao/service/validator/TenantProfileDataValidatorTest.java b/dao/src/test/java/org/thingsboard/server/dao/service/validator/TenantProfileDataValidatorTest.java index 170b452fee..373344cb6d 100644 --- a/dao/src/test/java/org/thingsboard/server/dao/service/validator/TenantProfileDataValidatorTest.java +++ b/dao/src/test/java/org/thingsboard/server/dao/service/validator/TenantProfileDataValidatorTest.java @@ -16,29 +16,35 @@ package org.thingsboard.server.dao.service.validator; import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; import org.springframework.boot.test.context.SpringBootTest; -import org.springframework.boot.test.mock.mockito.MockBean; -import org.springframework.boot.test.mock.mockito.SpyBean; +import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.springframework.test.context.bean.override.mockito.MockitoSpyBean; import org.thingsboard.server.common.data.TenantProfile; import org.thingsboard.server.common.data.id.TenantId; import org.thingsboard.server.common.data.tenant.profile.DefaultTenantProfileConfiguration; import org.thingsboard.server.common.data.tenant.profile.TenantProfileData; import org.thingsboard.server.dao.tenant.TenantProfileDao; import org.thingsboard.server.dao.tenant.TenantProfileService; +import org.thingsboard.server.exception.DataValidationException; import java.util.UUID; +import static org.assertj.core.api.Assertions.assertThatCode; +import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.mockito.Mockito.verify; @SpringBootTest(classes = TenantProfileDataValidator.class) class TenantProfileDataValidatorTest { - @MockBean + @MockitoBean TenantProfileDao tenantProfileDao; - @MockBean + @MockitoBean TenantProfileService tenantProfileService; - @SpyBean + @MockitoSpyBean TenantProfileDataValidator validator; + TenantId tenantId = TenantId.fromUUID(UUID.fromString("9ef79cdf-37a8-4119-b682-2e7ed4e018da")); @Test @@ -53,4 +59,44 @@ class TenantProfileDataValidatorTest { verify(validator).validateString("Tenant profile name", tenantProfile.getName()); } + @ParameterizedTest + @ValueSource(ints = {-1, -100, Integer.MIN_VALUE}) + void minAllowedScheduledUpdateIntervalInSecForCF_shouldRejectNegativeValues(int value) { + // GIVEN + var config = new DefaultTenantProfileConfiguration(); + config.setMinAllowedScheduledUpdateIntervalInSecForCF(value); + + var tenantProfileData = new TenantProfileData(); + tenantProfileData.setConfiguration(config); + + var tenantProfile = new TenantProfile(); + tenantProfile.setName("Test"); + tenantProfile.setProfileData(tenantProfileData); + + // WHEN/THEN + assertThatThrownBy(() -> validator.validate(tenantProfile, __ -> TenantId.SYS_TENANT_ID)) + .isInstanceOf(DataValidationException.class) + .hasMessageContaining("minAllowedScheduledUpdateIntervalInSecForCF") + .hasMessageContaining("must be greater than or equal to 0"); + } + + @ParameterizedTest + @ValueSource(ints = {0, 1, 60, Integer.MAX_VALUE}) + void minAllowedScheduledUpdateIntervalInSecForCF_shouldAcceptValidValues(int value) { + // GIVEN + var config = new DefaultTenantProfileConfiguration(); + config.setMinAllowedScheduledUpdateIntervalInSecForCF(value); + + var tenantProfileData = new TenantProfileData(); + tenantProfileData.setConfiguration(config); + + var tenantProfile = new TenantProfile(); + tenantProfile.setName("Test"); + tenantProfile.setProfileData(tenantProfileData); + + // WHEN/THEN + assertThatCode(() -> validator.validate(tenantProfile, __ -> TenantId.SYS_TENANT_ID)) + .doesNotThrowAnyException(); + } + } From b688ca1dcdac48a4f04daad9249a148dfc24242b Mon Sep 17 00:00:00 2001 From: Dmytro Skarzhynets Date: Fri, 16 Jan 2026 09:32:52 +0200 Subject: [PATCH 08/39] Add tests for zero min scheduled update interval --- ...ortedCalculatedFieldConfigurationTest.java | 4 +- .../dao/service/AbstractServiceTest.java | 4 +- .../service/CalculatedFieldServiceTest.java | 69 ++++++++++++++++--- 3 files changed, 65 insertions(+), 12 deletions(-) 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 23e0f1add3..a2df32b483 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 @@ -47,8 +47,8 @@ public class ScheduledUpdateSupportedCalculatedFieldConfigurationTest { assertThatThrownBy(() -> cfg.validate(minAllowedInterval)) .isInstanceOf(IllegalArgumentException.class) - .hasMessage("Scheduled update interval is less than configured " + - "minimum allowed interval in tenant profile: " + minAllowedInterval); + .hasMessage("Scheduled update interval (1 seconds) is less than " + + "minimum allowed interval in tenant profile: " + minAllowedInterval + " seconds"); } } diff --git a/dao/src/test/java/org/thingsboard/server/dao/service/AbstractServiceTest.java b/dao/src/test/java/org/thingsboard/server/dao/service/AbstractServiceTest.java index 9a467071ef..5ee9af6292 100644 --- a/dao/src/test/java/org/thingsboard/server/dao/service/AbstractServiceTest.java +++ b/dao/src/test/java/org/thingsboard/server/dao/service/AbstractServiceTest.java @@ -93,11 +93,13 @@ public abstract class AbstractServiceTest { @Autowired protected EntityServiceRegistry entityServiceRegistry; + protected Tenant tenant; protected TenantId tenantId; @Before public void beforeAbstractService() { - tenantId = createTenant().getId(); + tenant = createTenant(); + tenantId = tenant.getId(); } @After diff --git a/dao/src/test/java/org/thingsboard/server/dao/service/CalculatedFieldServiceTest.java b/dao/src/test/java/org/thingsboard/server/dao/service/CalculatedFieldServiceTest.java index afb380c5be..b5bc51537c 100644 --- a/dao/src/test/java/org/thingsboard/server/dao/service/CalculatedFieldServiceTest.java +++ b/dao/src/test/java/org/thingsboard/server/dao/service/CalculatedFieldServiceTest.java @@ -19,6 +19,7 @@ import org.apache.commons.lang3.RandomUtils; import org.junit.Test; import org.springframework.beans.factory.annotation.Autowired; import org.thingsboard.server.common.data.Device; +import org.thingsboard.server.common.data.TenantProfile; 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; @@ -42,6 +43,7 @@ import org.thingsboard.server.common.data.relation.RelationPathLevel; import org.thingsboard.server.dao.cf.CalculatedFieldService; import org.thingsboard.server.dao.device.DeviceService; import org.thingsboard.server.dao.tenant.TbTenantProfileCache; +import org.thingsboard.server.dao.tenant.TenantProfileService; import org.thingsboard.server.exception.DataValidationException; import java.util.ArrayList; @@ -62,6 +64,8 @@ public class CalculatedFieldServiceTest extends AbstractServiceTest { private DeviceService deviceService; @Autowired private TbTenantProfileCache tbTenantProfileCache; + @Autowired + private TenantProfileService tenantProfileService; @Test public void testSaveCalculatedField() { @@ -85,8 +89,6 @@ public class CalculatedFieldServiceTest extends AbstractServiceTest { assertThat(updatedCalculatedField.getName()).isEqualTo(savedCalculatedField.getName()); assertThat(updatedCalculatedField.getVersion()).isEqualTo(savedCalculatedField.getVersion() + 1); - - calculatedFieldService.deleteCalculatedField(tenantId, savedCalculatedField.getId()); } @Test @@ -224,8 +226,63 @@ public class CalculatedFieldServiceTest extends AbstractServiceTest { int savedInterval = geofencingCalculatedFieldConfiguration.getScheduledUpdateInterval(); assertThat(savedInterval).isEqualTo(valueFromConfig); + } - calculatedFieldService.deleteCalculatedField(tenantId, saved.getId()); + @Test + public void testSaveGeofencingCalculatedField_shouldAcceptZeroScheduledUpdateIntervalWhenTenantProfileAllows() { + // GIVEN + var device = createTestDevice(); + + // Store original value and update tenant profile to allow 0 as min scheduled update interval + TenantProfile tenantProfile = tenantProfileService.findTenantProfileById(tenantId, tenant.getTenantProfileId()); + int originalMinScheduledUpdateInterval = tenantProfile.getDefaultProfileConfiguration().getMinAllowedScheduledUpdateIntervalInSecForCF(); + tenantProfile.getDefaultProfileConfiguration().setMinAllowedScheduledUpdateIntervalInSecForCF(0); + tenantProfileService.saveTenantProfile(tenantId, tenantProfile); + tbTenantProfileCache.evict(tenantProfile.getId()); + + try { + // Build a valid Geofencing configuration + var cfg = new GeofencingCalculatedFieldConfiguration(); + + // Coordinates: TS_LATEST, no dynamic source + var entityCoordinates = new EntityCoordinates("latitude", "longitude"); + cfg.setEntityCoordinates(entityCoordinates); + + // Zone-group argument (ATTRIBUTE) — make it DYNAMIC so scheduling is enabled + var zoneGroupConfiguration = new ZoneGroupConfiguration("allowed", REPORT_TRANSITION_EVENTS_AND_PRESENCE_STATUS, false); + var dynamicSourceConfiguration = new RelationPathQueryDynamicSourceConfiguration(); + dynamicSourceConfiguration.setLevels(List.of(new RelationPathLevel(EntitySearchDirection.FROM, EntityRelation.CONTAINS_TYPE))); + zoneGroupConfiguration.setRefDynamicSourceConfiguration(dynamicSourceConfiguration); + cfg.setZoneGroups(Map.of("allowed", zoneGroupConfiguration)); + + // Enable scheduling with interval = 0 + cfg.setScheduledUpdateEnabled(true); + cfg.setScheduledUpdateInterval(0); + + // Create Calculated Field + var cf = new CalculatedField(); + cf.setTenantId(tenantId); + cf.setEntityId(device.getId()); + cf.setType(CalculatedFieldType.GEOFENCING); + cf.setName("GF zero scheduled update interval test"); + cf.setConfigurationVersion(0); + cf.setConfiguration(cfg); + + // WHEN + CalculatedField saved = calculatedFieldService.save(cf); + + // THEN + assertThat(saved).isNotNull(); + assertThat(saved.getConfiguration()).isInstanceOf(GeofencingCalculatedFieldConfiguration.class); + + var savedConfig = (GeofencingCalculatedFieldConfiguration) saved.getConfiguration(); + assertThat(savedConfig.getScheduledUpdateInterval()).isEqualTo(0); + } finally { + // Restore original tenant profile value + tenantProfile.getProfileConfiguration().orElseThrow().setMinAllowedScheduledUpdateIntervalInSecForCF(originalMinScheduledUpdateInterval); + tenantProfileService.saveTenantProfile(tenantId, tenantProfile); + tbTenantProfileCache.evict(tenantProfile.getId()); + } } @Test @@ -245,8 +302,6 @@ public class CalculatedFieldServiceTest extends AbstractServiceTest { CalculatedField fetchedCalculatedField = calculatedFieldService.findById(tenantId, savedCalculatedField.getId()); assertThat(fetchedCalculatedField).isEqualTo(savedCalculatedField); - - calculatedFieldService.deleteCalculatedField(tenantId, savedCalculatedField.getId()); } @Test @@ -307,8 +362,6 @@ public class CalculatedFieldServiceTest extends AbstractServiceTest { .getMinAllowedScheduledUpdateIntervalInSecForCF(); assertThat(savedConfig.getScheduledUpdateInterval()).isEqualTo(expectedMinScheduledUpdateInterval); - - calculatedFieldService.deleteCalculatedField(tenantId, saved.getId()); } @Test @@ -408,8 +461,6 @@ public class CalculatedFieldServiceTest extends AbstractServiceTest { var savedConfig = (RelatedEntitiesAggregationCalculatedFieldConfiguration) saved.getConfiguration(); assertThat(savedConfig.getScheduledUpdateInterval()).isEqualTo(customScheduledUpdateInterval); - - calculatedFieldService.deleteCalculatedField(tenantId, saved.getId()); } private CalculatedField saveValidCalculatedField() { From 1ea5dbf8256d9efcddc95b0b3c22b8f4dc5c25fe Mon Sep 17 00:00:00 2001 From: Dmytro Skarzhynets Date: Mon, 26 Jan 2026 12:46:13 +0200 Subject: [PATCH 09/39] Fix invalid CF config in test --- .../RelatedEntitiesAggregationCalculatedFieldStateTest.java | 2 ++ 1 file changed, 2 insertions(+) diff --git a/application/src/test/java/org/thingsboard/server/service/cf/ctx/state/RelatedEntitiesAggregationCalculatedFieldStateTest.java b/application/src/test/java/org/thingsboard/server/service/cf/ctx/state/RelatedEntitiesAggregationCalculatedFieldStateTest.java index cbde708ff8..02b063a499 100644 --- a/application/src/test/java/org/thingsboard/server/service/cf/ctx/state/RelatedEntitiesAggregationCalculatedFieldStateTest.java +++ b/application/src/test/java/org/thingsboard/server/service/cf/ctx/state/RelatedEntitiesAggregationCalculatedFieldStateTest.java @@ -234,6 +234,8 @@ public class RelatedEntitiesAggregationCalculatedFieldStateTest { config.setUseLatestTs(true); + config.setScheduledUpdateInterval(10); + calculatedField.setConfiguration(config); calculatedField.setVersion(1L); return calculatedField; From 168de4620a01181c6210f228e21d63ff376fe5ad Mon Sep 17 00:00:00 2001 From: nickAS21 Date: Mon, 2 Feb 2026 10:50:16 +0200 Subject: [PATCH 10/39] lwm2m - fix bug tests testFirmwareUpdateByObject5_Ok --- .../transport/lwm2m/client/FwLwM2MDevice.java | 52 ++++++++++------ .../transport/lwm2m/client/SwLwM2MDevice.java | 60 ++++++++++++------- 2 files changed, 71 insertions(+), 41 deletions(-) diff --git a/application/src/test/java/org/thingsboard/server/transport/lwm2m/client/FwLwM2MDevice.java b/application/src/test/java/org/thingsboard/server/transport/lwm2m/client/FwLwM2MDevice.java index 979a2b8c96..9265893fa2 100644 --- a/application/src/test/java/org/thingsboard/server/transport/lwm2m/client/FwLwM2MDevice.java +++ b/application/src/test/java/org/thingsboard/server/transport/lwm2m/client/FwLwM2MDevice.java @@ -140,45 +140,61 @@ public class FwLwM2MDevice extends BaseInstanceEnabler implements Destroyable { } private void startDownloading() { + long delay = 0; + // Step 1: state = 1 scheduler.schedule(() -> { - try { - state.set(1); - fireResourceChange(3); - Thread.sleep(100); - state.set(2); - fireResourceChange(3); - } catch (Exception e) { - } - }, 100, TimeUnit.MILLISECONDS); + state.set(1); + fireResourceChange(3); + log.info("Downloading started: state=[{}]", state.get()); + }, delay, TimeUnit.MILLISECONDS); + + delay += 100; // next step after 100 ms + + // Step 2: state = 2 + scheduler.schedule(() -> { + state.set(2); + fireResourceChange(3); + log.info("Downloading in progress: state=[{}]", state.get()); + }, delay, TimeUnit.MILLISECONDS); } + private void startUpdating(LwM2mServer identity) { scheduler.schedule(() -> { try { + // Update state + result state.set(3); fireResourceChange(3); - Thread.sleep(100); + updateResult.set(1); fireResourceChange(5); - this.pkgName = TITLE; - fireResourceChange(6); - this.pkgVersion = TARGET_FW_VERSION; - fireResourceChange(7); + if (this.leshanClient != null) { log.info("Stop/reboot LwM2M client {}", this.leshanClient.getEndpoint(identity)); this.leshanClient.stop(false); + log.info("Start after update fw LwM2M client {}", this.leshanClient.getEndpoint(identity)); this.leshanClient.start(); - this.pkgName = this.pkgNameDef; - this.pkgVersion = this.pkgVersionDef; + + // Delayed reset pkgName/pkgVersion, after reboot + registration + scheduler.schedule(() -> { + this.pkgName = this.pkgNameDef; + fireResourceChange(6); + + this.pkgVersion = this.pkgVersionDef; + fireResourceChange(7); + + log.info("FW resources updating to new values: pkgName=[{}], pkgVersion=[{}]", + this.pkgName, this.pkgVersion); + }, 15, TimeUnit.SECONDS); // 15 sec — safe timing } } catch (Exception e) { + log.error("Error during firmware update", e); } - }, 100, TimeUnit.MILLISECONDS); + }, 0, TimeUnit.SECONDS); // start immediately, without further delay } protected void setLeshanClient(LeshanClient leshanClient) { this.leshanClient = leshanClient; } - } diff --git a/application/src/test/java/org/thingsboard/server/transport/lwm2m/client/SwLwM2MDevice.java b/application/src/test/java/org/thingsboard/server/transport/lwm2m/client/SwLwM2MDevice.java index 76b9827f5a..6db2cf2e9b 100644 --- a/application/src/test/java/org/thingsboard/server/transport/lwm2m/client/SwLwM2MDevice.java +++ b/application/src/test/java/org/thingsboard/server/transport/lwm2m/client/SwLwM2MDevice.java @@ -33,6 +33,8 @@ import java.util.concurrent.ScheduledExecutorService; import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicInteger; +import static org.thingsboard.server.controller.AbstractWebTest.TIMEOUT; + @Slf4j public class SwLwM2MDevice extends BaseInstanceEnabler implements Destroyable { @@ -85,10 +87,7 @@ public class SwLwM2MDevice extends BaseInstanceEnabler implements Destroyable { log.info("Write on Device resource /{}/{}/{}", getModel().id, getId(), resourceId); switch (resourceId) { - case 2: - startDownloading(); - return WriteResponse.success(); - case 3: + case 2, 3: startDownloading(); return WriteResponse.success(); default: @@ -123,25 +122,34 @@ public class SwLwM2MDevice extends BaseInstanceEnabler implements Destroyable { } private void startDownloading() { + long delay = 0; + + // Step 1: start downloading scheduler.schedule(() -> { - try { - state.set(1); - updateResult.set(1); - fireResourceChange(7); - fireResourceChange(9); - Thread.sleep(100); - state.set(2); - fireResourceChange(7); - Thread.sleep(100); - state.set(3); - fireResourceChange(7); - Thread.sleep(100); - updateResult.set(3); - fireResourceChange(9); - } catch (Exception e) { - - } - }, 100, TimeUnit.MILLISECONDS); + state.set(1); + updateResult.set(1); + fireResourceChange(7); + fireResourceChange(9); + }, delay, TimeUnit.MILLISECONDS); + + delay += 100; + + // Step 2: downloading in progress + scheduler.schedule(() -> { + state.set(2); + fireResourceChange(7); + }, delay, TimeUnit.MILLISECONDS); + + delay += 100; + + // Step 3: downloading finished + scheduler.schedule(() -> { + state.set(3); + fireResourceChange(7); + + updateResult.set(3); + fireResourceChange(9); + }, delay, TimeUnit.MILLISECONDS); } private void startUpdating() { @@ -150,7 +158,13 @@ public class SwLwM2MDevice extends BaseInstanceEnabler implements Destroyable { updateResult.set(2); fireResourceChange(7); fireResourceChange(9); + + // Optional: delayed log about FW update + scheduler.schedule(() -> { + log.info("FW resources updating to new values: state=[{}], updateResult=[{}]", + state.get(), updateResult.get()); + }, 500, TimeUnit.MILLISECONDS); + }, 100, TimeUnit.MILLISECONDS); } - } From 609703e48d0fb9da306756fe26f8413dd9d8c486 Mon Sep 17 00:00:00 2001 From: dashevchenko Date: Tue, 3 Feb 2026 08:48:42 +0200 Subject: [PATCH 11/39] fixed yml parameter --- application/src/main/resources/thingsboard.yml | 2 +- edqs/src/main/resources/edqs.yml | 2 +- msa/vc-executor/src/main/resources/tb-vc-executor.yml | 2 +- transport/coap/src/main/resources/tb-coap-transport.yml | 2 +- transport/http/src/main/resources/tb-http-transport.yml | 2 +- transport/lwm2m/src/main/resources/tb-lwm2m-transport.yml | 2 +- transport/mqtt/src/main/resources/tb-mqtt-transport.yml | 2 +- transport/snmp/src/main/resources/tb-snmp-transport.yml | 2 +- 8 files changed, 8 insertions(+), 8 deletions(-) diff --git a/application/src/main/resources/thingsboard.yml b/application/src/main/resources/thingsboard.yml index bdef34df3b..25463bdc6c 100644 --- a/application/src/main/resources/thingsboard.yml +++ b/application/src/main/resources/thingsboard.yml @@ -2011,7 +2011,7 @@ management: web: exposure: # Expose metrics endpoint (use value 'prometheus' to enable prometheus metrics). - include: '${METRICS_ENDPOINTS_EXPOSE:info}' + include: "${METRICS_ENDPOINTS_EXPOSE:info}" health: elasticsearch: # Enable the org.springframework.boot.actuate.elasticsearch.ElasticsearchRestClientHealthIndicator.doHealthCheck diff --git a/edqs/src/main/resources/edqs.yml b/edqs/src/main/resources/edqs.yml index a2a6ca6814..010994dc88 100644 --- a/edqs/src/main/resources/edqs.yml +++ b/edqs/src/main/resources/edqs.yml @@ -210,7 +210,7 @@ management: web: exposure: # Expose metrics endpoint (use value 'prometheus' to enable prometheus metrics). - include: '${METRICS_ENDPOINTS_EXPOSE:info}' + include: "${METRICS_ENDPOINTS_EXPOSE:info}" health: elasticsearch: # Enable the org.springframework.boot.actuate.elasticsearch.ElasticsearchRestClientHealthIndicator.doHealthCheck diff --git a/msa/vc-executor/src/main/resources/tb-vc-executor.yml b/msa/vc-executor/src/main/resources/tb-vc-executor.yml index 4f5dc7ef63..8314985622 100644 --- a/msa/vc-executor/src/main/resources/tb-vc-executor.yml +++ b/msa/vc-executor/src/main/resources/tb-vc-executor.yml @@ -232,7 +232,7 @@ management: web: exposure: # Expose metrics endpoint (use value 'prometheus' to enable prometheus metrics). - include: '${METRICS_ENDPOINTS_EXPOSE:info}' + include: "${METRICS_ENDPOINTS_EXPOSE:info}" # Service common properties service: diff --git a/transport/coap/src/main/resources/tb-coap-transport.yml b/transport/coap/src/main/resources/tb-coap-transport.yml index 1c45984257..9ab06ca996 100644 --- a/transport/coap/src/main/resources/tb-coap-transport.yml +++ b/transport/coap/src/main/resources/tb-coap-transport.yml @@ -435,7 +435,7 @@ management: web: exposure: # Expose metrics endpoint (use value 'prometheus' to enable prometheus metrics). - include: '${METRICS_ENDPOINTS_EXPOSE:info}' + include: "${METRICS_ENDPOINTS_EXPOSE:info}" # Notification system parameters notification_system: diff --git a/transport/http/src/main/resources/tb-http-transport.yml b/transport/http/src/main/resources/tb-http-transport.yml index 39f7d15fb9..f869534088 100644 --- a/transport/http/src/main/resources/tb-http-transport.yml +++ b/transport/http/src/main/resources/tb-http-transport.yml @@ -384,7 +384,7 @@ management: web: exposure: # Expose metrics endpoint (use value 'prometheus' to enable prometheus metrics). - include: '${METRICS_ENDPOINTS_EXPOSE:info}' + include: "${METRICS_ENDPOINTS_EXPOSE:info}" # Notification system parameters notification_system: diff --git a/transport/lwm2m/src/main/resources/tb-lwm2m-transport.yml b/transport/lwm2m/src/main/resources/tb-lwm2m-transport.yml index 8a57c5829b..d22bd34505 100644 --- a/transport/lwm2m/src/main/resources/tb-lwm2m-transport.yml +++ b/transport/lwm2m/src/main/resources/tb-lwm2m-transport.yml @@ -485,7 +485,7 @@ management: web: exposure: # Expose metrics endpoint (use value 'prometheus' to enable prometheus metrics). - include: '${METRICS_ENDPOINTS_EXPOSE:info}' + include: "${METRICS_ENDPOINTS_EXPOSE:info}" # Notification system parameters notification_system: diff --git a/transport/mqtt/src/main/resources/tb-mqtt-transport.yml b/transport/mqtt/src/main/resources/tb-mqtt-transport.yml index 4a7571a734..ccbd3901ce 100644 --- a/transport/mqtt/src/main/resources/tb-mqtt-transport.yml +++ b/transport/mqtt/src/main/resources/tb-mqtt-transport.yml @@ -418,7 +418,7 @@ management: web: exposure: # Expose metrics endpoint (use value 'prometheus' to enable prometheus metrics). - include: '${METRICS_ENDPOINTS_EXPOSE:info}' + include: "${METRICS_ENDPOINTS_EXPOSE:info}" # Notification system parameters notification_system: diff --git a/transport/snmp/src/main/resources/tb-snmp-transport.yml b/transport/snmp/src/main/resources/tb-snmp-transport.yml index b98ad2a9e9..656c01524d 100644 --- a/transport/snmp/src/main/resources/tb-snmp-transport.yml +++ b/transport/snmp/src/main/resources/tb-snmp-transport.yml @@ -373,7 +373,7 @@ management: web: exposure: # Expose metrics endpoint (use value 'prometheus' to enable prometheus metrics). - include: '${METRICS_ENDPOINTS_EXPOSE:info}' + include: "${METRICS_ENDPOINTS_EXPOSE:info}" # Notification system parameters notification_system: From 482cb6d43f17d0677e7018cd7cd5342a5ebcf6ba Mon Sep 17 00:00:00 2001 From: Maksym Tsymbarov Date: Wed, 4 Feb 2026 15:48:10 +0200 Subject: [PATCH 12/39] Fixed loading and placement of Material icons on Power Button widget --- .../lib/rpc/power-button-widget.models.ts | 51 +++++++++++++++---- 1 file changed, 40 insertions(+), 11 deletions(-) diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/rpc/power-button-widget.models.ts b/ui-ngx/src/app/modules/home/components/widget/lib/rpc/power-button-widget.models.ts index f538e8cfc8..4836cc3804 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/rpc/power-button-widget.models.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/rpc/power-button-widget.models.ts @@ -28,7 +28,7 @@ import { Circle, Effect, Element, G, Gradient, Path, Runner, Svg, Text, Timeline import '@svgdotjs/svg.filter.js'; import tinycolor from 'tinycolor2'; import { WidgetContext } from '@home/models/widget-component.models'; -import { Observable, of } from 'rxjs'; +import { Observable, of, shareReplay } from 'rxjs'; import { isSvgIcon, splitIconName } from '@shared/models/icon.models'; import { catchError, map, take } from 'rxjs/operators'; import { MatIconRegistry } from '@angular/material/icon'; @@ -336,6 +336,11 @@ export abstract class PowerButtonShape { protected onPowerSymbolCircle: Path; protected onPowerSymbolLine: Path; + private onIcon$: Observable; + private offIcon$: Observable; + protected onIconOffsetX: number = 0; + protected onIconOffsetY: number = 0; + protected constructor(protected widgetContext: WidgetContext, protected svgShape: Svg, protected iconRegistry: MatIconRegistry, @@ -360,10 +365,14 @@ export abstract class PowerButtonShape { take(1), map((svgElement) => { const element = new Element(svgElement.firstChild); + const iconGroup = this.svgShape.group(); + element.addTo(iconGroup); const box = element.bbox(); const scale = size / box.height; element.scale(scale); - return element; + const scaledBox = iconGroup.bbox(); + iconGroup.translate(-scaledBox.cx, -scaledBox.cy); + return iconGroup; }), catchError(() => of(null) )); @@ -414,8 +423,15 @@ export abstract class PowerButtonShape { public drawOffShape(centerGroup: G, label: boolean, labelWeight?: string, circleStroke?: boolean) { if (this.icons.offButtonIcon.showIcon) { - this.createIconElement(this.icons.offButtonIcon.icon, this.icons.offButtonIcon.iconSize).subscribe(icon => - this.offPowerSymbolIcon = icon.center(cx, cy).addTo(centerGroup)); + this.offIcon$ = this.createIconElement(this.icons.offButtonIcon.icon, this.icons.offButtonIcon.iconSize) + .pipe(shareReplay(1)); + + this.offIcon$.pipe(take(1)).subscribe(icon => { + this.offPowerSymbolIcon = icon; + icon.translate(cx, cy); + centerGroup.add(icon); + }); + } else { if (label) { this.offLabelShape = this.createOffLabel(labelWeight).addTo(centerGroup); @@ -428,10 +444,16 @@ export abstract class PowerButtonShape { public drawOnShape(onCenterGroup?: G, label?: boolean, labelWeight?: string, circleStroke?: boolean, mask?: Circle) { if (this.icons.onButtonIcon.showIcon) { - this.createIconElement(this.icons.onButtonIcon.icon, this.icons.onButtonIcon.iconSize).subscribe(icon => { - this.onPowerSymbolIcon = icon.center(cx, cy); + this.onIcon$ = this.createIconElement(this.icons.onButtonIcon.icon, this.icons.onButtonIcon.iconSize) + .pipe(shareReplay(1)); + + this.onIcon$.subscribe(icon => { + const iconBox = icon.bbox(); + this.onIconOffsetX = iconBox.cx; + this.onIconOffsetY = iconBox.cy; + this.onPowerSymbolIcon = icon.translate(cx, cy); if (isDefinedAndNotNull(onCenterGroup)) { - this.onPowerSymbolIcon.addTo(onCenterGroup); + onCenterGroup.add(this.onPowerSymbolIcon); } if (isDefinedAndNotNull(mask)) { this.createMask(mask, [this.onPowerSymbolIcon]); @@ -462,7 +484,9 @@ export abstract class PowerButtonShape { public onCenterTimeLine(timeline: Timeline, label: boolean) { if (this.icons.onButtonIcon.showIcon) { - this.onPowerSymbolIcon.timeline(timeline); + if (this.onIcon$) { + this.onIcon$.subscribe(icon => icon.timeline(timeline)); + } } else { if (label) { this.onLabelShape.timeline(timeline); @@ -475,7 +499,7 @@ export abstract class PowerButtonShape { public offCenterColor(mainColor: PowerButtonColor, label: boolean) { if (this.icons.offButtonIcon.showIcon) { - this.offPowerSymbolIcon.attr({ fill: mainColor.hex, 'fill-opacity': mainColor.opacity}); + this.offIcon$.subscribe(icon => icon.attr({ fill: mainColor.hex, 'fill-opacity': mainColor.opacity})) } else { if (label) { this.offLabelShape.attr({ fill: mainColor.hex, 'fill-opacity': mainColor.opacity}); @@ -488,7 +512,7 @@ export abstract class PowerButtonShape { public onCenterColor(mainColor: PowerButtonColor, label: boolean) { if (this.icons.onButtonIcon.showIcon) { - this.onPowerSymbolIcon.attr({ fill: mainColor.hex, 'fill-opacity': mainColor.opacity}); + this.onIcon$.subscribe((icon)=> icon.attr({ fill: mainColor.hex, 'fill-opacity': mainColor.opacity})) } else { if (label) { this.onLabelShape.attr({ fill: mainColor.hex, 'fill-opacity': mainColor.opacity}); @@ -501,7 +525,12 @@ export abstract class PowerButtonShape { public buttonAnimation(scale: number, label: boolean) { if (this.icons.onButtonIcon.showIcon) { - powerButtonAnimation(this.onPowerSymbolIcon).transform({scale}); + const translateX = cx - this.onIconOffsetX; + const translateY = cy - this.onIconOffsetY; + powerButtonAnimation(this.onPowerSymbolIcon).transform({ + scale: scale, + translate: [translateX, translateY] + }); } else { if (label) { powerButtonAnimation(this.onLabelShape).transform({scale, origin: {x: cx, y: cy}}); From 50ee9c4888bba9491387cf75acc7d4c50a7f3bbc Mon Sep 17 00:00:00 2001 From: Dmytro Skarzhynets Date: Sun, 8 Feb 2026 17:19:12 +0200 Subject: [PATCH 13/39] feat: add RestClient support for getCalculatedFields API Add getCalculatedFields method to RestClient that calls the /api/calculatedFields endpoint with filtering by types, entityType, entities, and names. Uses Apache URIBuilder to construct the URL. Co-Authored-By: Claude Opus 4.6 --- rest-client/pom.xml | 4 ++ .../thingsboard/rest/client/RestClient.java | 45 +++++++++++++++++++ 2 files changed, 49 insertions(+) diff --git a/rest-client/pom.xml b/rest-client/pom.xml index c12ba7be2c..159ef826a0 100644 --- a/rest-client/pom.xml +++ b/rest-client/pom.xml @@ -51,6 +51,10 @@ com.auth0 java-jwt + + org.apache.httpcomponents.core5 + httpcore5 + 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 bd1d7b05c3..102fb90d1f 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 @@ -22,6 +22,7 @@ import com.google.common.base.Strings; import lombok.Getter; import lombok.SneakyThrows; import org.apache.commons.io.IOUtils; +import org.apache.hc.core5.net.URIBuilder; import org.apache.commons.lang3.concurrent.LazyInitializer; import org.springframework.core.ParameterizedTypeReference; import org.springframework.core.io.ByteArrayResource; @@ -93,6 +94,8 @@ import org.thingsboard.server.common.data.asset.AssetSearchQuery; import org.thingsboard.server.common.data.audit.ActionType; import org.thingsboard.server.common.data.audit.AuditLog; import org.thingsboard.server.common.data.cf.CalculatedField; +import org.thingsboard.server.common.data.cf.CalculatedFieldInfo; +import org.thingsboard.server.common.data.cf.CalculatedFieldType; import org.thingsboard.server.common.data.device.DeviceSearchQuery; import org.thingsboard.server.common.data.domain.Domain; import org.thingsboard.server.common.data.domain.DomainInfo; @@ -210,11 +213,13 @@ import org.thingsboard.server.common.data.widget.WidgetsBundle; import java.io.Closeable; import java.io.IOException; import java.net.URI; +import java.net.URISyntaxException; import java.util.Collections; import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.Optional; +import java.util.Set; import java.util.UUID; import java.util.concurrent.ExecutorService; import java.util.concurrent.Future; @@ -4309,6 +4314,46 @@ public class RestClient implements Closeable { } + @SneakyThrows(URISyntaxException.class) + public PageData getCalculatedFields(PageLink pageLink, + Set types, + EntityType entityType, + Set entities, + Set names) { + var urlBuilder = new URIBuilder(baseURL).appendPath("/api/calculatedFields"); + urlBuilder.addParameter("pageSize", String.valueOf(pageLink.getPageSize())); + urlBuilder.addParameter("page", String.valueOf(pageLink.getPage())); + if (!isEmpty(pageLink.getTextSearch())) { + urlBuilder.addParameter("textSearch", pageLink.getTextSearch()); + } + if (pageLink.getSortOrder() != null) { + urlBuilder.addParameter("sortProperty", pageLink.getSortOrder().getProperty()); + urlBuilder.addParameter("sortOrder", pageLink.getSortOrder().getDirection().name()); + } + if (!CollectionUtils.isEmpty(types)) { + for (CalculatedFieldType type : types) { + urlBuilder.addParameter("types", type.name()); + } + } + if (entityType != null) { + urlBuilder.addParameter("entityType", entityType.name()); + } + if (!CollectionUtils.isEmpty(entities)) { + for (UUID entity : entities) { + urlBuilder.addParameter("entities", entity.toString()); + } + } + if (!CollectionUtils.isEmpty(names)) { + for (String name : names) { + urlBuilder.addParameter("name", name); + } + } + return restTemplate.exchange( + urlBuilder.build(), + HttpMethod.GET, HttpEntity.EMPTY, + new ParameterizedTypeReference>() {}).getBody(); + } + public void deleteCalculatedField(CalculatedFieldId calculatedFieldId) { restTemplate.delete(baseURL + "/api/calculatedField/{calculatedFieldId}", calculatedFieldId.getId()); } From ceb407f6c171b716ac5ac2ce6e16d97412472086 Mon Sep 17 00:00:00 2001 From: Dmytro Skarzhynets Date: Mon, 9 Feb 2026 10:03:32 +0200 Subject: [PATCH 14/39] chore: add .claude/ to .gitignore Co-Authored-By: Claude Opus 4.6 --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index e9a4e11b85..777a52efc7 100644 --- a/.gitignore +++ b/.gitignore @@ -37,3 +37,4 @@ rebuild-docker.sh */.run/** .run/** .run +.claude/ From 20afefc33cd1179ea6e9ede140f637ca460626f4 Mon Sep 17 00:00:00 2001 From: Nikita Mazurenko Date: Mon, 9 Feb 2026 10:15:51 +0200 Subject: [PATCH 15/39] Set next 4.2 EdgeVersion proto value to canonical one --- common/edge-api/src/main/proto/edge.proto | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/common/edge-api/src/main/proto/edge.proto b/common/edge-api/src/main/proto/edge.proto index 1998789d91..8e14926fbb 100644 --- a/common/edge-api/src/main/proto/edge.proto +++ b/common/edge-api/src/main/proto/edge.proto @@ -45,9 +45,9 @@ enum EdgeVersion { V_4_1_0 = 11; V_4_2_0 = 12; V_4_2_1_2 = 14; - V_4_2_1_3 = 420; + V_4_2_1_3 = 4213; - V_LATEST = 999; + V_LATEST = 99999; } /** From 2e03ea175172819597f6d5be8200febc5675e179 Mon Sep 17 00:00:00 2001 From: Dmytro Skarzhynets Date: Mon, 9 Feb 2026 14:53:25 +0200 Subject: [PATCH 16/39] fix: update copyright year to 2026 in license headers Co-Authored-By: Claude Opus 4.6 --- .../server/service/queue/TbMsgPackProcessingContextFactory.java | 2 +- .../service/queue/ruleengine/RuleEngineConsumerLoopTest.java | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/application/src/main/java/org/thingsboard/server/service/queue/TbMsgPackProcessingContextFactory.java b/application/src/main/java/org/thingsboard/server/service/queue/TbMsgPackProcessingContextFactory.java index ff804ad304..08307a68ca 100644 --- a/application/src/main/java/org/thingsboard/server/service/queue/TbMsgPackProcessingContextFactory.java +++ b/application/src/main/java/org/thingsboard/server/service/queue/TbMsgPackProcessingContextFactory.java @@ -1,5 +1,5 @@ /** - * Copyright © 2016-2025 The Thingsboard Authors + * Copyright © 2016-2026 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. diff --git a/application/src/test/java/org/thingsboard/server/service/queue/ruleengine/RuleEngineConsumerLoopTest.java b/application/src/test/java/org/thingsboard/server/service/queue/ruleengine/RuleEngineConsumerLoopTest.java index 1619283586..20ea6fa881 100644 --- a/application/src/test/java/org/thingsboard/server/service/queue/ruleengine/RuleEngineConsumerLoopTest.java +++ b/application/src/test/java/org/thingsboard/server/service/queue/ruleengine/RuleEngineConsumerLoopTest.java @@ -1,5 +1,5 @@ /** - * Copyright © 2016-2025 The Thingsboard Authors + * Copyright © 2016-2026 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. From b07858b1137d993de1758e8049387073406bc352 Mon Sep 17 00:00:00 2001 From: Vladyslav Prykhodko Date: Mon, 9 Feb 2026 15:33:51 +0200 Subject: [PATCH 17/39] UI: Improved default tenant home dashboard (#15000) --- .../recent-dashboards-widget.component.html | 11 ++++- .../recent-dashboards-widget.component.ts | 45 +++++++++++++++++++ .../dashboard/tenant_admin_home_page.json | 2 +- 3 files changed, 55 insertions(+), 3 deletions(-) diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/home-page/recent-dashboards-widget.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/home-page/recent-dashboards-widget.component.html index f832b1982e..e8fba081ee 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/home-page/recent-dashboards-widget.component.html +++ b/ui-ngx/src/app/modules/home/components/widget/lib/home-page/recent-dashboards-widget.component.html @@ -24,8 +24,15 @@ {{ 'widgets.recent-dashboards.last' | translate }} {{ 'widgets.recent-dashboards.starred' | translate }} - {{ 'dashboard.add' | translate }} + + @if (hasDevice) { + {{ 'dashboard.add' | translate }} + } @else { + {{ 'dashboard.add' | translate }} + } + diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/home-page/recent-dashboards-widget.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/home-page/recent-dashboards-widget.component.ts index 899ffa5aea..5dfc520856 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/home-page/recent-dashboards-widget.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/home-page/recent-dashboards-widget.component.ts @@ -48,6 +48,13 @@ import { Direction, SortOrder } from '@shared/models/page/sort-order'; import { MatSort } from '@angular/material/sort'; import { DashboardInfo } from '@shared/models/dashboard.models'; import { DashboardAutocompleteComponent } from '@shared/components/dashboard-autocomplete.component'; +import { UtilsService } from '@core/services/utils.service'; +import { Datasource, DatasourceType, widgetType } from '@shared/models/widget.models'; +import { IWidgetSubscription, WidgetSubscriptionOptions } from '@core/api/widget-api.models'; +import { formattedDataFormDatasourceData } from '@core/utils'; +import { AliasFilterType } from '@shared/models/alias.models'; +import { DataKeyType } from '@shared/models/telemetry/telemetry.models'; +import { EntityType } from '@shared/models/entity-type.models'; @Component({ selector: 'tb-recent-dashboards-widget', @@ -78,13 +85,16 @@ export class RecentDashboardsWidgetComponent extends PageComponent implements On starredDashboardValue = null; hasDashboardsAccess = true; + hasDevice = true; dirty = false; public customerId: string; private isFullscreenMode = getCurrentAuthState(this.store).forceFullscreen; + private subscription: IWidgetSubscription; constructor(protected store: Store, private cd: ChangeDetectorRef, + private utils: UtilsService, private userSettingService: UserSettingsService) { super(store); } @@ -96,6 +106,41 @@ export class RecentDashboardsWidgetComponent extends PageComponent implements On this.hasDashboardsAccess = [Authority.TENANT_ADMIN, Authority.CUSTOMER_USER].includes(this.authUser.authority); if (this.hasDashboardsAccess) { this.reload(); + + if (window.location.pathname.startsWith('/home') && this.authUser.authority === Authority.TENANT_ADMIN) { + const ds: Datasource = { + type: DatasourceType.entityCount, + name: '', + entityFilter: { + entityType: EntityType.DEVICE, + type: AliasFilterType.entityType + }, + dataKeys: [this.utils.createKey({ name: 'count'}, DataKeyType.count)] + } + + const apiUsageSubscriptionOptions: WidgetSubscriptionOptions = { + datasources: [ds], + useDashboardTimewindow: false, + type: widgetType.latest, + callbacks: { + onDataUpdated: (subscription) => { + const data = formattedDataFormDatasourceData(subscription.data); + this.hasDevice = (data[0].count || 0) !== 0; + this.cd.detectChanges(); + } + } + }; + this.ctx.subscriptionApi.createSubscription(apiUsageSubscriptionOptions, true).subscribe((subscription) => { + this.subscription = subscription; + }); + } + } + } + + ngOnDestroy() { + super.ngOnDestroy(); + if (this.subscription) { + this.ctx.subscriptionApi.removeSubscription(this.subscription.id); } } diff --git a/ui-ngx/src/assets/dashboard/tenant_admin_home_page.json b/ui-ngx/src/assets/dashboard/tenant_admin_home_page.json index f09ab73de7..9fb2d25cf8 100644 --- a/ui-ngx/src/assets/dashboard/tenant_admin_home_page.json +++ b/ui-ngx/src/assets/dashboard/tenant_admin_home_page.json @@ -224,7 +224,7 @@ "padding": "16px", "settings": { "useMarkdownTextFunction": false, - "markdownTextPattern": "", + "markdownTextPattern": "", "applyDefaultMarkdownStyle": false, "markdownCss": ".tb-card-content {\n width: 100%;\n height: 100%;\n display: flex;\n flex-direction: row;\n}\n\n.tb-content-container {\n flex: 1;\n display: flex;\n flex-direction: column;\n justify-content: flex-start;\n gap: 12px;\n}\n\n.tb-card-header {\n height: 36px;\n display: flex;\n flex-direction: row;\n justify-content: space-between;\n}\n\n.tb-item-cards {\n flex: 1;\n display: flex;\n flex-direction: row;\n gap: 12px;\n overflow: hidden;\n}\n\na.tb-item-card {\n flex: 1;\n display: flex;\n flex-direction: column;\n padding: 8px 12px;\n border: 1px solid;\n border-radius: 10px;\n margin-bottom: 12px;\n overflow: hidden;\n justify-content: space-evenly;\n}\n\na.tb-item-card.tb-inactive {\n background: rgba(209, 39, 48, 0.04);\n border-color: rgba(209, 39, 48, 0.06);\n}\n\na.tb-item-card.tb-active {\n background: rgba(48, 86, 128, 0.04);\n border-color: rgba(48, 86, 128, 0.12);\n}\n\na.tb-item-card.tb-total {\n background: rgba(0, 0, 0, 0.01);\n border-color: rgba(0, 0, 0, 0.05);\n}\n\n.tb-item-title-container {\n display: grid;\n}\n\n.tb-item-title {\n font-weight: 400;\n font-size: 14px;\n line-height: 20px;\n letter-spacing: 0.2px;\n white-space: nowrap;\n overflow: hidden;\n text-overflow: ellipsis; \n color: rgba(0, 0, 0, 0.76);\n}\n\n.tb-item-title.tb-home-widget-link:after {\n position: absolute;\n right: 0;\n}\n\na.tb-item-card:hover .tb-item-title.tb-home-widget-link:after { \n color: rgba(0, 0, 0, 0.38);\n}\n\na.tb-item-card:hover {\n box-shadow: 0px 4px 10px rgba(23, 33, 90, 0.08);\n}\n\n.tb-count-container {\n flex: 1;\n display: flex;\n flex-direction: column;\n align-items: flex-start;\n justify-content: center;\n}\n\n.tb-count {\n font-style: normal;\n font-weight: 500;\n font-size: 24px;\n line-height: 36px;\n white-space: nowrap;\n color: rgba(0, 0, 0, 0.87);\n}\n\n@media screen and (max-width: 959px) {\n .tb-item-cards {\n flex-direction: column;\n }\n a.tb-item-card {\n margin-bottom: 0;\n }\n}\n\n@media screen and (max-width: 1279px) {\n a.tb-item-card {\n flex-direction: row;\n align-items: center;\n }\n .tb-item-title.tb-home-widget-link:after {\n position: relative;\n }\n .tb-count-container {\n align-items: flex-end;\n }\n}\n\n@media screen and (min-width: 960px) and (max-width: 1819px) {\n .tb-item-title {\n font-size: 11px;\n line-height: 16px;\n }\n .tb-count {\n font-size: 16px;\n line-height: 24px;\n }\n a.tb-item-card {\n padding: 4px 8px;\n margin-bottom: 6px;\n }\n a.tb-item-card:hover {\n box-shadow: 0px 2px 5px rgba(23, 33, 90, 0.08);\n }\n}\n" }, From e0151095f743dbd2dcc499b5654a6f51c2b7a82c Mon Sep 17 00:00:00 2001 From: Vladyslav Prykhodko Date: Mon, 9 Feb 2026 15:40:32 +0200 Subject: [PATCH 18/39] Changed default "Add" button style in entity tables (#14984) * UI: Use accent text button as default "Add" style in entity tables * UI: Change default text button style in entity table * UI: Change default text button style in image gallery --------- Co-authored-by: Viacheslav Klimov --- .../alarm-rules/alarm-rules-table-config.ts | 4 +- .../api-key/api-keys-table-config.ts | 1 - .../calculated-fields-table-config.ts | 2 + .../entity/entities-table.component.html | 104 ++++++++++++------ .../vc/entity-versions-table.component.html | 18 +-- .../entity/entities-table-config.models.ts | 2 +- .../ai-model/ai-model-table-config.resolve.ts | 1 - .../mobile-app-table-config.resolver.ts | 1 - .../mobile-bundle-table-config.resolve.ts | 1 - .../recipient-table-config.resolver.ts | 1 - .../rule/rule-table-config.resolver.ts | 1 - .../template-table-config.resolver.ts | 1 - .../image/image-gallery.component.html | 6 +- .../app/shared/models/entity-type.models.ts | 1 + .../assets/locale/locale.constant-en_US.json | 1 + 15 files changed, 94 insertions(+), 51 deletions(-) diff --git a/ui-ngx/src/app/modules/home/components/alarm-rules/alarm-rules-table-config.ts b/ui-ngx/src/app/modules/home/components/alarm-rules/alarm-rules-table-config.ts index cadac572c6..039ee7d5fd 100644 --- a/ui-ngx/src/app/modules/home/components/alarm-rules/alarm-rules-table-config.ts +++ b/ui-ngx/src/app/modules/home/components/alarm-rules/alarm-rules-table-config.ts @@ -106,6 +106,8 @@ export class AlarmRulesTableConfig extends EntityTableConfig { this.entityType = EntityType.API_KEY; this.detailsPanelEnabled = false; - this.addAsTextButton = true; this.pageMode = false; this.entityTranslations = entityTypeTranslations.get(EntityType.API_KEY); 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 efb767f92e..aac49a3f37 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 @@ -110,6 +110,8 @@ export class CalculatedFieldsTableConfig extends EntityTableConfig -
- - - - - - - - + - - -
@@ -118,6 +94,72 @@ matTooltipPosition="above"> search +
+ + + + + + + + + + + + + + + + +
+ + + diff --git a/ui-ngx/src/app/modules/home/components/vc/entity-versions-table.component.html b/ui-ngx/src/app/modules/home/components/vc/entity-versions-table.component.html index cc3fb23da5..829c1414db 100644 --- a/ui-ngx/src/app/modules/home/components/vc/entity-versions-table.component.html +++ b/ui-ngx/src/app/modules/home/components/vc/entity-versions-table.component.html @@ -32,7 +32,8 @@ -
+
-
+
diff --git a/ui-ngx/src/app/modules/home/models/entity/entities-table-config.models.ts b/ui-ngx/src/app/modules/home/models/entity/entities-table-config.models.ts index 913e754fb7..336ac035da 100644 --- a/ui-ngx/src/app/modules/home/models/entity/entities-table-config.models.ts +++ b/ui-ngx/src/app/modules/home/models/entity/entities-table-config.models.ts @@ -175,7 +175,7 @@ export class EntityTableConfig, P extends PageLink = P selectionEnabled = true; searchEnabled = true; addEnabled = true; - addAsTextButton = false; + addAsTextButton = true; entitiesDeleteEnabled = true; detailsPanelEnabled = true; hideDetailsTabsOnEdit = true; diff --git a/ui-ngx/src/app/modules/home/pages/ai-model/ai-model-table-config.resolve.ts b/ui-ngx/src/app/modules/home/pages/ai-model/ai-model-table-config.resolve.ts index 2a73488d7e..7c78bd7c02 100644 --- a/ui-ngx/src/app/modules/home/pages/ai-model/ai-model-table-config.resolve.ts +++ b/ui-ngx/src/app/modules/home/pages/ai-model/ai-model-table-config.resolve.ts @@ -47,7 +47,6 @@ export class AiModelsTableConfigResolver { ) { this.config.selectionEnabled = true; this.config.entityType = EntityType.AI_MODEL; - this.config.addAsTextButton = true; this.config.rowPointer = true; this.config.detailsPanelEnabled = false; this.config.entityTranslations = entityTypeTranslations.get(EntityType.AI_MODEL); diff --git a/ui-ngx/src/app/modules/home/pages/mobile/applications/mobile-app-table-config.resolver.ts b/ui-ngx/src/app/modules/home/pages/mobile/applications/mobile-app-table-config.resolver.ts index 13698ac4a5..274edf5933 100644 --- a/ui-ngx/src/app/modules/home/pages/mobile/applications/mobile-app-table-config.resolver.ts +++ b/ui-ngx/src/app/modules/home/pages/mobile/applications/mobile-app-table-config.resolver.ts @@ -57,7 +57,6 @@ export class MobileAppTableConfigResolver { ) { this.config.selectionEnabled = false; this.config.entityType = EntityType.MOBILE_APP; - this.config.addAsTextButton = true; this.config.entitiesDeleteEnabled = false; this.config.rowPointer = true; this.config.entityTranslations = entityTypeTranslations.get(EntityType.MOBILE_APP); diff --git a/ui-ngx/src/app/modules/home/pages/mobile/bundes/mobile-bundle-table-config.resolve.ts b/ui-ngx/src/app/modules/home/pages/mobile/bundes/mobile-bundle-table-config.resolve.ts index 69be801980..ba20624852 100644 --- a/ui-ngx/src/app/modules/home/pages/mobile/bundes/mobile-bundle-table-config.resolve.ts +++ b/ui-ngx/src/app/modules/home/pages/mobile/bundes/mobile-bundle-table-config.resolve.ts @@ -63,7 +63,6 @@ export class MobileBundleTableConfigResolver { ) { this.config.selectionEnabled = false; this.config.entityType = EntityType.MOBILE_APP_BUNDLE; - this.config.addAsTextButton = true; this.config.rowPointer = true; this.config.detailsPanelEnabled = false; this.config.entityTranslations = entityTypeTranslations.get(EntityType.MOBILE_APP_BUNDLE); diff --git a/ui-ngx/src/app/modules/home/pages/notification/recipient/recipient-table-config.resolver.ts b/ui-ngx/src/app/modules/home/pages/notification/recipient/recipient-table-config.resolver.ts index c2fc08fb7c..8e729e94ef 100644 --- a/ui-ngx/src/app/modules/home/pages/notification/recipient/recipient-table-config.resolver.ts +++ b/ui-ngx/src/app/modules/home/pages/notification/recipient/recipient-table-config.resolver.ts @@ -48,7 +48,6 @@ export class RecipientTableConfigResolver { this.config.entityType = EntityType.NOTIFICATION_TARGET; this.config.detailsPanelEnabled = false; - this.config.addAsTextButton = true; this.config.rowPointer = true; this.config.entityTranslations = entityTypeTranslations.get(EntityType.NOTIFICATION_TARGET); diff --git a/ui-ngx/src/app/modules/home/pages/notification/rule/rule-table-config.resolver.ts b/ui-ngx/src/app/modules/home/pages/notification/rule/rule-table-config.resolver.ts index 066db92c0a..f71197ae95 100644 --- a/ui-ngx/src/app/modules/home/pages/notification/rule/rule-table-config.resolver.ts +++ b/ui-ngx/src/app/modules/home/pages/notification/rule/rule-table-config.resolver.ts @@ -49,7 +49,6 @@ export class RuleTableConfigResolver { this.config.entityType = EntityType.NOTIFICATION_RULE; this.config.detailsPanelEnabled = false; - this.config.addAsTextButton = true; this.config.rowPointer = true; this.config.entityTranslations = entityTypeTranslations.get(EntityType.NOTIFICATION_RULE); diff --git a/ui-ngx/src/app/modules/home/pages/notification/template/template-table-config.resolver.ts b/ui-ngx/src/app/modules/home/pages/notification/template/template-table-config.resolver.ts index 0ee0b921f4..b1bc3eef3e 100644 --- a/ui-ngx/src/app/modules/home/pages/notification/template/template-table-config.resolver.ts +++ b/ui-ngx/src/app/modules/home/pages/notification/template/template-table-config.resolver.ts @@ -47,7 +47,6 @@ export class TemplateTableConfigResolver { this.config.entityType = EntityType.NOTIFICATION_TEMPLATE; this.config.detailsPanelEnabled = false; - this.config.addAsTextButton = true; this.config.rowPointer = true; this.config.entityTranslations = entityTypeTranslations.get(EntityType.NOTIFICATION_TEMPLATE); diff --git a/ui-ngx/src/app/shared/components/image/image-gallery.component.html b/ui-ngx/src/app/shared/components/image/image-gallery.component.html index f442556b18..b31dc16920 100644 --- a/ui-ngx/src/app/shared/components/image/image-gallery.component.html +++ b/ui-ngx/src/app/shared/components/image/image-gallery.component.html @@ -74,10 +74,10 @@ mdi:file-import