From 6f7554231515d2554ac8ca96f2ed8ca5113b6658 Mon Sep 17 00:00:00 2001 From: Volodymyr Babak Date: Tue, 12 May 2026 16:28:13 +0300 Subject: [PATCH 1/8] fix(cf): prevent integer overflow in calculated field SUM output SimpleCalculatedFieldState.formatResult cast the double result down to int via TbUtils.toInt when "Decimals by default" was 0 (the UI default). BigDecimal.intValue() returns only the low-order 32 bits, so a sum above ~2.1B wrapped to a negative number (e.g. 3,980,173,734 -> -314,793,562). Add TbUtils.toLong(double) alongside toInt (toInt is left untouched to preserve TBEL script behavior), switch the CF precision=0 path to it, and add a Long branch in createResultJson so the JSON node is emitted as a numeric long --- .../service/cf/ctx/state/SimpleCalculatedFieldState.java | 4 +++- .../java/org/thingsboard/script/api/tbel/TbUtils.java | 4 ++++ .../java/org/thingsboard/script/api/tbel/TbUtilsTest.java | 8 ++++++++ 3 files changed, 15 insertions(+), 1 deletion(-) diff --git a/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/SimpleCalculatedFieldState.java b/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/SimpleCalculatedFieldState.java index 0d018f5def..e3f1e94bf4 100644 --- a/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/SimpleCalculatedFieldState.java +++ b/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/SimpleCalculatedFieldState.java @@ -85,7 +85,7 @@ public class SimpleCalculatedFieldState extends BaseCalculatedFieldState { return expressionResult; } if (decimals.equals(0)) { - return TbUtils.toInt(expressionResult); + return TbUtils.toLong(expressionResult); } return TbUtils.toFixed(expressionResult, decimals); } @@ -96,6 +96,8 @@ public class SimpleCalculatedFieldState extends BaseCalculatedFieldState { ObjectNode valuesNode = JacksonUtil.newObjectNode(); if (result instanceof Double doubleValue) { valuesNode.put(outputName, doubleValue); + } else if (result instanceof Long longValue) { + valuesNode.put(outputName, longValue); } else if (result instanceof Integer integerValue) { valuesNode.put(outputName, integerValue); } else { diff --git a/common/script/script-api/src/main/java/org/thingsboard/script/api/tbel/TbUtils.java b/common/script/script-api/src/main/java/org/thingsboard/script/api/tbel/TbUtils.java index e206d2d78d..a66a1860cd 100644 --- a/common/script/script-api/src/main/java/org/thingsboard/script/api/tbel/TbUtils.java +++ b/common/script/script-api/src/main/java/org/thingsboard/script/api/tbel/TbUtils.java @@ -1186,6 +1186,10 @@ public class TbUtils { return BigDecimal.valueOf(value).setScale(0, RoundingMode.HALF_UP).intValue(); } + public static long toLong(double value) { + return BigDecimal.valueOf(value).setScale(0, RoundingMode.HALF_UP).longValue(); + } + public static boolean isNaN(double value) { return Double.isNaN(value); } diff --git a/common/script/script-api/src/test/java/org/thingsboard/script/api/tbel/TbUtilsTest.java b/common/script/script-api/src/test/java/org/thingsboard/script/api/tbel/TbUtilsTest.java index 622d298bbb..dbf56d5140 100644 --- a/common/script/script-api/src/test/java/org/thingsboard/script/api/tbel/TbUtilsTest.java +++ b/common/script/script-api/src/test/java/org/thingsboard/script/api/tbel/TbUtilsTest.java @@ -1249,6 +1249,14 @@ public class TbUtilsTest { Assertions.assertEquals(28, TbUtils.toInt(28.0)); } + @Test + public void toLong() { + Assertions.assertEquals(1729L, TbUtils.toLong(doubleVal)); + Assertions.assertEquals(13L, TbUtils.toLong(12.8)); + Assertions.assertEquals(28L, TbUtils.toLong(28.0)); + Assertions.assertEquals(3_980_173_734L, TbUtils.toLong(3_980_173_734.0)); + } + @Test public void isNaN() { assertFalse(TbUtils.isNaN(doubleVal)); From 6e0630adc12cdc45c5469d102e9d4b6bedb58633 Mon Sep 17 00:00:00 2001 From: Volodymyr Babak Date: Wed, 13 May 2026 08:06:39 +0300 Subject: [PATCH 2/8] Fixed SimpleCalculatedFieldStateTest --- .../service/cf/ctx/state/SimpleCalculatedFieldStateTest.java | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/application/src/test/java/org/thingsboard/server/service/cf/ctx/state/SimpleCalculatedFieldStateTest.java b/application/src/test/java/org/thingsboard/server/service/cf/ctx/state/SimpleCalculatedFieldStateTest.java index 0001566abd..723161ad87 100644 --- a/application/src/test/java/org/thingsboard/server/service/cf/ctx/state/SimpleCalculatedFieldStateTest.java +++ b/application/src/test/java/org/thingsboard/server/service/cf/ctx/state/SimpleCalculatedFieldStateTest.java @@ -140,7 +140,7 @@ public class SimpleCalculatedFieldStateTest { Output output = getCalculatedFieldConfig().getOutput(); assertThat(result.getType()).isEqualTo(output.getType()); assertThat(result.getScope()).isEqualTo(output.getScope()); - assertThat(result.getResult()).isEqualTo(JacksonUtil.valueToTree(Map.of("output", 49))); + assertThat(result.getResult()).isEqualTo(JacksonUtil.valueToTree(Map.of("output", 49L))); } @Test @@ -170,7 +170,7 @@ public class SimpleCalculatedFieldStateTest { Output output = getCalculatedFieldConfig().getOutput(); assertThat(result.getType()).isEqualTo(output.getType()); assertThat(result.getScope()).isEqualTo(output.getScope()); - assertThat(result.getResult()).isEqualTo(JacksonUtil.valueToTree(Map.of("output", 35))); + assertThat(result.getResult()).isEqualTo(JacksonUtil.valueToTree(Map.of("output", 35L))); } @Test From 497895b0917b3263d717bb2806a4521db7435ffb Mon Sep 17 00:00:00 2001 From: Volodymyr Babak Date: Wed, 13 May 2026 10:25:06 +0300 Subject: [PATCH 3/8] Revent toInt --- .../main/java/org/thingsboard/common/util/NumberUtils.java | 4 ++++ .../java/org/thingsboard/common/util/NumberUtilsTest.java | 7 +++++++ 2 files changed, 11 insertions(+) diff --git a/common/util/src/main/java/org/thingsboard/common/util/NumberUtils.java b/common/util/src/main/java/org/thingsboard/common/util/NumberUtils.java index 203aacfd06..293120954d 100644 --- a/common/util/src/main/java/org/thingsboard/common/util/NumberUtils.java +++ b/common/util/src/main/java/org/thingsboard/common/util/NumberUtils.java @@ -32,6 +32,10 @@ public class NumberUtils { return BigDecimal.valueOf(value).setScale(precision, RoundingMode.HALF_UP).floatValue(); } + public static int toInt(double value) { + return BigDecimal.valueOf(value).setScale(0, RoundingMode.HALF_UP).intValue(); + } + public static long toLong(double value) { return BigDecimal.valueOf(value).setScale(0, RoundingMode.HALF_UP).longValue(); } diff --git a/common/util/src/test/java/org/thingsboard/common/util/NumberUtilsTest.java b/common/util/src/test/java/org/thingsboard/common/util/NumberUtilsTest.java index 91b53da787..dabfe9cffc 100644 --- a/common/util/src/test/java/org/thingsboard/common/util/NumberUtilsTest.java +++ b/common/util/src/test/java/org/thingsboard/common/util/NumberUtilsTest.java @@ -44,6 +44,13 @@ public class NumberUtilsTest { assertThat(Double.compare(1729.173, actualD)).isEqualTo(0); } + @Test + public void toInt() { + assertThat(NumberUtils.toInt(doubleVal)).isEqualTo(1729); + assertThat(NumberUtils.toInt(12.8)).isEqualTo(13); + assertThat(NumberUtils.toInt(28.0)).isEqualTo(28); + } + @Test public void toLong() { assertThat(NumberUtils.toLong(doubleVal)).isEqualTo(1729L); From 55307c1e1de80dfcc4fd94d81abb42cfef133d66 Mon Sep 17 00:00:00 2001 From: Andrii Landiak Date: Wed, 20 May 2026 13:06:44 +0300 Subject: [PATCH 4/8] Fix: apply queue prefix when creating Kafka topics to prevent orphaned topics --- .../entitiy/queue/DefaultTbQueueService.java | 5 +- .../queue/DefaultTbQueueServiceTest.java | 141 ++++++++++++++++++ 2 files changed, 145 insertions(+), 1 deletion(-) create mode 100644 application/src/test/java/org/thingsboard/server/service/entitiy/queue/DefaultTbQueueServiceTest.java diff --git a/application/src/main/java/org/thingsboard/server/service/entitiy/queue/DefaultTbQueueService.java b/application/src/main/java/org/thingsboard/server/service/entitiy/queue/DefaultTbQueueService.java index 87c1aaa707..e161b9fce4 100644 --- a/application/src/main/java/org/thingsboard/server/service/entitiy/queue/DefaultTbQueueService.java +++ b/application/src/main/java/org/thingsboard/server/service/entitiy/queue/DefaultTbQueueService.java @@ -27,6 +27,7 @@ import org.thingsboard.server.common.data.tenant.profile.TenantProfileQueueConfi import org.thingsboard.server.common.msg.queue.TopicPartitionInfo; import org.thingsboard.server.dao.queue.QueueService; import org.thingsboard.server.queue.TbQueueAdmin; +import org.thingsboard.server.queue.discovery.TopicService; import org.thingsboard.server.queue.util.TbCoreComponent; import org.thingsboard.server.service.entitiy.AbstractTbEntityService; @@ -45,6 +46,7 @@ public class DefaultTbQueueService extends AbstractTbEntityService implements Tb private final QueueService queueService; private final TbClusterService tbClusterService; private final TbQueueAdmin tbQueueAdmin; + private final TopicService topicService; @Override public Queue saveQueue(Queue queue) { @@ -173,9 +175,10 @@ public class DefaultTbQueueService extends AbstractTbEntityService implements Tb private void createTopicsIfNeeded(Queue queue, Queue oldQueue) { int newPartitions = queue.getPartitions(); int oldPartitions = oldQueue != null ? oldQueue.getPartitions() : 0; + String prefixedTopic = topicService.buildTopicName(queue.getTopic()); for (int i = oldPartitions; i < newPartitions; i++) { tbQueueAdmin.createTopicIfNotExists( - new TopicPartitionInfo(queue.getTopic(), queue.getTenantId(), i, false).getFullTopicName(), + new TopicPartitionInfo(prefixedTopic, queue.getTenantId(), i, false).getFullTopicName(), queue.getCustomProperties(), true); // forcing topic creation because the topic may still be cached on some nodes } diff --git a/application/src/test/java/org/thingsboard/server/service/entitiy/queue/DefaultTbQueueServiceTest.java b/application/src/test/java/org/thingsboard/server/service/entitiy/queue/DefaultTbQueueServiceTest.java new file mode 100644 index 0000000000..81cc325bdb --- /dev/null +++ b/application/src/test/java/org/thingsboard/server/service/entitiy/queue/DefaultTbQueueServiceTest.java @@ -0,0 +1,141 @@ +/** + * 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. + * 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.entitiy.queue; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.test.util.ReflectionTestUtils; +import org.thingsboard.server.cluster.TbClusterService; +import org.thingsboard.server.common.data.id.QueueId; +import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.common.data.queue.Queue; +import org.thingsboard.server.dao.queue.QueueService; +import org.thingsboard.server.queue.TbQueueAdmin; +import org.thingsboard.server.queue.discovery.TopicService; + +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyBoolean; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +public class DefaultTbQueueServiceTest { + + @Mock + private QueueService queueServiceMock; + @Mock + private TbClusterService tbClusterServiceMock; + @Mock + private TbQueueAdmin tbQueueAdminMock; + + private TopicService topicService; + private DefaultTbQueueService tbQueueService; + + private final TenantId tenantId = TenantId.SYS_TENANT_ID; + + @BeforeEach + public void setUp() { + topicService = new TopicService(); + tbQueueService = new DefaultTbQueueService(queueServiceMock, tbClusterServiceMock, tbQueueAdminMock, topicService); + } + + private Queue newQueue(int partitions) { + Queue queue = new Queue(); + queue.setTenantId(tenantId); + queue.setName("testQueue"); + queue.setTopic("tb_rule_engine.testQueue"); + queue.setPartitions(partitions); + return queue; + } + + @Test + public void givenQueuePrefix_whenSaveQueue_thenCreatesPrefixedTopics() { + // queue.prefix = "thingsboard" (TB_QUEUE_PREFIX set) + ReflectionTestUtils.setField(topicService, "prefix", "thingsboard"); + + Queue queue = newQueue(2); + when(queueServiceMock.saveQueue(queue)).thenReturn(queue); + + tbQueueService.saveQueue(queue); + + ArgumentCaptor topicCaptor = ArgumentCaptor.forClass(String.class); + verify(tbQueueAdminMock, times(2)).createTopicIfNotExists(topicCaptor.capture(), any(), anyBoolean()); + + // All created topics must carry the prefix - this is the fix. + assertThat(topicCaptor.getAllValues()) + .containsExactlyInAnyOrder( + "thingsboard.tb_rule_engine.testQueue.0", + "thingsboard.tb_rule_engine.testQueue.1"); + // No unprefixed (orphan-prone) topic must ever be created. + assertThat(topicCaptor.getAllValues()) + .noneMatch(topic -> topic.equals("tb_rule_engine.testQueue.0") + || topic.equals("tb_rule_engine.testQueue.1")); + } + + @Test + public void givenNoQueuePrefix_whenSaveQueue_thenCreatesUnprefixedTopics() { + // queue.prefix blank (TB_QUEUE_PREFIX not set) - default behavior preserved + ReflectionTestUtils.setField(topicService, "prefix", ""); + + Queue queue = newQueue(2); + when(queueServiceMock.saveQueue(queue)).thenReturn(queue); + + tbQueueService.saveQueue(queue); + + ArgumentCaptor topicCaptor = ArgumentCaptor.forClass(String.class); + verify(tbQueueAdminMock, times(2)).createTopicIfNotExists(topicCaptor.capture(), any(), anyBoolean()); + + assertThat(topicCaptor.getAllValues()) + .containsExactlyInAnyOrder( + "tb_rule_engine.testQueue.0", + "tb_rule_engine.testQueue.1"); + } + + @Test + public void givenQueuePrefix_whenIncreasePartitions_thenOnlyNewPartitionsCreatedPrefixed() { + ReflectionTestUtils.setField(topicService, "prefix", "thingsboard"); + + Queue oldQueue = newQueue(2); + oldQueue.setId(new QueueId(UUID.randomUUID())); + Queue updatedQueue = newQueue(4); + updatedQueue.setId(oldQueue.getId()); + + when(queueServiceMock.findQueueById(tenantId, updatedQueue.getId())).thenReturn(oldQueue); + when(queueServiceMock.saveQueue(updatedQueue)).thenReturn(updatedQueue); + + tbQueueService.saveQueue(updatedQueue); + + ArgumentCaptor topicCaptor = ArgumentCaptor.forClass(String.class); + verify(tbQueueAdminMock, times(2)).createTopicIfNotExists(topicCaptor.capture(), any(), anyBoolean()); + + assertThat(topicCaptor.getAllValues()) + .containsExactlyInAnyOrder( + "thingsboard.tb_rule_engine.testQueue.2", + "thingsboard.tb_rule_engine.testQueue.3"); + verify(tbQueueAdminMock, never()).createTopicIfNotExists(eq("thingsboard.tb_rule_engine.testQueue.0"), any(), anyBoolean()); + } + +} From 42a14ec18ca229c41668a52d93c77d10ac6d2355 Mon Sep 17 00:00:00 2001 From: Andrii Landiak Date: Wed, 20 May 2026 13:06:44 +0300 Subject: [PATCH 5/8] Fix: apply queue prefix when creating Kafka topics to prevent orphaned topics --- .../entitiy/queue/DefaultTbQueueService.java | 5 +- .../queue/DefaultTbQueueServiceTest.java | 141 ++++++++++++++++++ 2 files changed, 145 insertions(+), 1 deletion(-) create mode 100644 application/src/test/java/org/thingsboard/server/service/entitiy/queue/DefaultTbQueueServiceTest.java diff --git a/application/src/main/java/org/thingsboard/server/service/entitiy/queue/DefaultTbQueueService.java b/application/src/main/java/org/thingsboard/server/service/entitiy/queue/DefaultTbQueueService.java index 87c1aaa707..e161b9fce4 100644 --- a/application/src/main/java/org/thingsboard/server/service/entitiy/queue/DefaultTbQueueService.java +++ b/application/src/main/java/org/thingsboard/server/service/entitiy/queue/DefaultTbQueueService.java @@ -27,6 +27,7 @@ import org.thingsboard.server.common.data.tenant.profile.TenantProfileQueueConfi import org.thingsboard.server.common.msg.queue.TopicPartitionInfo; import org.thingsboard.server.dao.queue.QueueService; import org.thingsboard.server.queue.TbQueueAdmin; +import org.thingsboard.server.queue.discovery.TopicService; import org.thingsboard.server.queue.util.TbCoreComponent; import org.thingsboard.server.service.entitiy.AbstractTbEntityService; @@ -45,6 +46,7 @@ public class DefaultTbQueueService extends AbstractTbEntityService implements Tb private final QueueService queueService; private final TbClusterService tbClusterService; private final TbQueueAdmin tbQueueAdmin; + private final TopicService topicService; @Override public Queue saveQueue(Queue queue) { @@ -173,9 +175,10 @@ public class DefaultTbQueueService extends AbstractTbEntityService implements Tb private void createTopicsIfNeeded(Queue queue, Queue oldQueue) { int newPartitions = queue.getPartitions(); int oldPartitions = oldQueue != null ? oldQueue.getPartitions() : 0; + String prefixedTopic = topicService.buildTopicName(queue.getTopic()); for (int i = oldPartitions; i < newPartitions; i++) { tbQueueAdmin.createTopicIfNotExists( - new TopicPartitionInfo(queue.getTopic(), queue.getTenantId(), i, false).getFullTopicName(), + new TopicPartitionInfo(prefixedTopic, queue.getTenantId(), i, false).getFullTopicName(), queue.getCustomProperties(), true); // forcing topic creation because the topic may still be cached on some nodes } diff --git a/application/src/test/java/org/thingsboard/server/service/entitiy/queue/DefaultTbQueueServiceTest.java b/application/src/test/java/org/thingsboard/server/service/entitiy/queue/DefaultTbQueueServiceTest.java new file mode 100644 index 0000000000..81cc325bdb --- /dev/null +++ b/application/src/test/java/org/thingsboard/server/service/entitiy/queue/DefaultTbQueueServiceTest.java @@ -0,0 +1,141 @@ +/** + * 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. + * 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.entitiy.queue; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.test.util.ReflectionTestUtils; +import org.thingsboard.server.cluster.TbClusterService; +import org.thingsboard.server.common.data.id.QueueId; +import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.common.data.queue.Queue; +import org.thingsboard.server.dao.queue.QueueService; +import org.thingsboard.server.queue.TbQueueAdmin; +import org.thingsboard.server.queue.discovery.TopicService; + +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyBoolean; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +public class DefaultTbQueueServiceTest { + + @Mock + private QueueService queueServiceMock; + @Mock + private TbClusterService tbClusterServiceMock; + @Mock + private TbQueueAdmin tbQueueAdminMock; + + private TopicService topicService; + private DefaultTbQueueService tbQueueService; + + private final TenantId tenantId = TenantId.SYS_TENANT_ID; + + @BeforeEach + public void setUp() { + topicService = new TopicService(); + tbQueueService = new DefaultTbQueueService(queueServiceMock, tbClusterServiceMock, tbQueueAdminMock, topicService); + } + + private Queue newQueue(int partitions) { + Queue queue = new Queue(); + queue.setTenantId(tenantId); + queue.setName("testQueue"); + queue.setTopic("tb_rule_engine.testQueue"); + queue.setPartitions(partitions); + return queue; + } + + @Test + public void givenQueuePrefix_whenSaveQueue_thenCreatesPrefixedTopics() { + // queue.prefix = "thingsboard" (TB_QUEUE_PREFIX set) + ReflectionTestUtils.setField(topicService, "prefix", "thingsboard"); + + Queue queue = newQueue(2); + when(queueServiceMock.saveQueue(queue)).thenReturn(queue); + + tbQueueService.saveQueue(queue); + + ArgumentCaptor topicCaptor = ArgumentCaptor.forClass(String.class); + verify(tbQueueAdminMock, times(2)).createTopicIfNotExists(topicCaptor.capture(), any(), anyBoolean()); + + // All created topics must carry the prefix - this is the fix. + assertThat(topicCaptor.getAllValues()) + .containsExactlyInAnyOrder( + "thingsboard.tb_rule_engine.testQueue.0", + "thingsboard.tb_rule_engine.testQueue.1"); + // No unprefixed (orphan-prone) topic must ever be created. + assertThat(topicCaptor.getAllValues()) + .noneMatch(topic -> topic.equals("tb_rule_engine.testQueue.0") + || topic.equals("tb_rule_engine.testQueue.1")); + } + + @Test + public void givenNoQueuePrefix_whenSaveQueue_thenCreatesUnprefixedTopics() { + // queue.prefix blank (TB_QUEUE_PREFIX not set) - default behavior preserved + ReflectionTestUtils.setField(topicService, "prefix", ""); + + Queue queue = newQueue(2); + when(queueServiceMock.saveQueue(queue)).thenReturn(queue); + + tbQueueService.saveQueue(queue); + + ArgumentCaptor topicCaptor = ArgumentCaptor.forClass(String.class); + verify(tbQueueAdminMock, times(2)).createTopicIfNotExists(topicCaptor.capture(), any(), anyBoolean()); + + assertThat(topicCaptor.getAllValues()) + .containsExactlyInAnyOrder( + "tb_rule_engine.testQueue.0", + "tb_rule_engine.testQueue.1"); + } + + @Test + public void givenQueuePrefix_whenIncreasePartitions_thenOnlyNewPartitionsCreatedPrefixed() { + ReflectionTestUtils.setField(topicService, "prefix", "thingsboard"); + + Queue oldQueue = newQueue(2); + oldQueue.setId(new QueueId(UUID.randomUUID())); + Queue updatedQueue = newQueue(4); + updatedQueue.setId(oldQueue.getId()); + + when(queueServiceMock.findQueueById(tenantId, updatedQueue.getId())).thenReturn(oldQueue); + when(queueServiceMock.saveQueue(updatedQueue)).thenReturn(updatedQueue); + + tbQueueService.saveQueue(updatedQueue); + + ArgumentCaptor topicCaptor = ArgumentCaptor.forClass(String.class); + verify(tbQueueAdminMock, times(2)).createTopicIfNotExists(topicCaptor.capture(), any(), anyBoolean()); + + assertThat(topicCaptor.getAllValues()) + .containsExactlyInAnyOrder( + "thingsboard.tb_rule_engine.testQueue.2", + "thingsboard.tb_rule_engine.testQueue.3"); + verify(tbQueueAdminMock, never()).createTopicIfNotExists(eq("thingsboard.tb_rule_engine.testQueue.0"), any(), anyBoolean()); + } + +} From b84f58a8af080cc5added74b74bd98dc85571194 Mon Sep 17 00:00:00 2001 From: Igor Kulikov Date: Wed, 3 Jun 2026 12:43:08 +0300 Subject: [PATCH 6/8] feat(html-container): scoped error handler, action support, completion polish - Provide a scoped Angular ErrorHandler on the dynamic component's injector in HtmlContainerWidgetComponent so template-runtime exceptions raised inside user-authored Angular templates flow through handleWidgetException instead of crashing the global Angular ErrorHandler. - Add `ctx.invokeAction($event, actionName, additionalParams?)` to WidgetActionsApi / WidgetComponent and a `WidgetDestroyCallback` + ctx.registerDestroyCallback() API on WidgetContext (callbacks run in registration order on destroy, errors per-callback are caught so one bad cleanup doesn't break the rest). - Surface widget actions on the HTML Container basic config: render tb-widget-actions-panel with the new strokedPanel input below the html-container settings, and register a default "JavaScript" multi-action source on the html_container widget JSON. - Expand the widget-completion docs for `registerDestroyCallback` (when it fires, what to use it for, lifecycle semantics, exact callback signature `() => void`) and for `invokeAction`'s `additionalParams` arg (forwarded to the configured JS action handler, common payload examples). --- .../system/widget_types/html_container.json | 2 +- ui-ngx/src/app/core/api/widget-api.models.ts | 1 + .../widget-actions-panel.component.html | 2 +- .../common/widget-actions-panel.component.ts | 5 +++ ...html-container-basic-config.component.html | 4 +++ .../html-container-basic-config.component.ts | 4 ++- .../html/html-container-widget.component.ts | 35 +++++++++++++++---- .../components/widget/widget.component.ts | 11 ++++++ .../home/models/widget-component.models.ts | 15 ++++++++ .../models/ace/widget-completion.models.ts | 33 +++++++++++++++++ 10 files changed, 102 insertions(+), 10 deletions(-) diff --git a/application/src/main/data/json/system/widget_types/html_container.json b/application/src/main/data/json/system/widget_types/html_container.json index 25b9e5ac5d..a27996593b 100644 --- a/application/src/main/data/json/system/widget_types/html_container.json +++ b/application/src/main/data/json/system/widget_types/html_container.json @@ -11,7 +11,7 @@ "resources": [], "templateHtml": "\n", "templateCss": "", - "controllerScript": "self.onInit = function() {\n \n}\n\nself.typeParameters = function() {\n return {\n previewWidth: '100%',\n previewHeight: '100%',\n overflowVisible: true\n };\n};\n", + "controllerScript": "self.onInit = function() {\n \n}\n\nself.typeParameters = function() {\n return {\n previewWidth: '100%',\n previewHeight: '100%',\n overflowVisible: true\n };\n};\n\nself.actionSources = function() {\n return {\n 'javaScript': {\n name: 'JavaScript',\n multiple: true\n }\n };\n}", "settingsDirective": "tb-html-container-widget-settings", "hasBasicMode": true, "basicModeDirective": "tb-html-container-basic-config", diff --git a/ui-ngx/src/app/core/api/widget-api.models.ts b/ui-ngx/src/app/core/api/widget-api.models.ts index 5572730923..9ef4a4461b 100644 --- a/ui-ngx/src/app/core/api/widget-api.models.ts +++ b/ui-ngx/src/app/core/api/widget-api.models.ts @@ -110,6 +110,7 @@ export interface WidgetActionsApi { elementClick: ($event: Event) => void; cardClick: ($event: Event) => void; click: ($event: Event) => void; + invokeAction: ($event: Event, actionName: string, additionalParams?: any) => void; getActiveEntityInfo: () => SubscriptionEntityInfo; openDashboardStateInSeparateDialog: (targetDashboardStateId: string, params?: StateParams, dialogTitle?: string, hideDashboardToolbar?: boolean, dialogWidth?: number, dialogHeight?: number) => MatDialogRef; diff --git a/ui-ngx/src/app/modules/home/components/widget/config/basic/common/widget-actions-panel.component.html b/ui-ngx/src/app/modules/home/components/widget/config/basic/common/widget-actions-panel.component.html index 72f4b0f1d8..500d974726 100644 --- a/ui-ngx/src/app/modules/home/components/widget/config/basic/common/widget-actions-panel.component.html +++ b/ui-ngx/src/app/modules/home/components/widget/config/basic/common/widget-actions-panel.component.html @@ -15,7 +15,7 @@ limitations under the License. --> -
+
widget-config.actions
diff --git a/ui-ngx/src/app/modules/home/components/widget/config/basic/common/widget-actions-panel.component.ts b/ui-ngx/src/app/modules/home/components/widget/config/basic/common/widget-actions-panel.component.ts index 0320dc7fd1..389c780e4c 100644 --- a/ui-ngx/src/app/modules/home/components/widget/config/basic/common/widget-actions-panel.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/config/basic/common/widget-actions-panel.component.ts @@ -26,6 +26,7 @@ import { import { deepClone } from '@core/utils'; import { MatDialog } from '@angular/material/dialog'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; +import { coerceBoolean } from '@shared/decorators/coercion'; @Component({ selector: 'tb-widget-actions-panel', @@ -45,6 +46,10 @@ export class WidgetActionsPanelComponent implements ControlValueAccessor, OnInit @Input() disabled: boolean; + @Input() + @coerceBoolean() + strokedPanel = false; + actionsFormGroup: UntypedFormGroup; private propagateChange = (_val: any) => {}; diff --git a/ui-ngx/src/app/modules/home/components/widget/config/basic/html/html-container-basic-config.component.html b/ui-ngx/src/app/modules/home/components/widget/config/basic/html/html-container-basic-config.component.html index 298bdb6e61..907da3e14b 100644 --- a/ui-ngx/src/app/modules/home/components/widget/config/basic/html/html-container-basic-config.component.html +++ b/ui-ngx/src/app/modules/home/components/widget/config/basic/html/html-container-basic-config.component.html @@ -17,4 +17,8 @@ --> + + diff --git a/ui-ngx/src/app/modules/home/components/widget/config/basic/html/html-container-basic-config.component.ts b/ui-ngx/src/app/modules/home/components/widget/config/basic/html/html-container-basic-config.component.ts index d057acf057..3872e07853 100644 --- a/ui-ngx/src/app/modules/home/components/widget/config/basic/html/html-container-basic-config.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/config/basic/html/html-container-basic-config.component.ts @@ -51,12 +51,14 @@ export class HtmlContainerBasicConfigComponent extends BasicWidgetConfigComponen protected onConfigSet(configData: WidgetConfigComponentData) { const settings: HtmlContainerWidgetSettings = {...htmlContainerDefaultSettings, ...(configData.config.settings || {})}; this.htmlContainerWidgetConfigForm = this.fb.group({ - settings: [settings, []] + settings: [settings, []], + actions: [configData.config.actions || {}, []] }); } protected prepareOutputConfig(config: any): WidgetConfigComponentData { this.widgetConfig.config.settings = {...(this.widgetConfig.config.settings || {}), ...config.settings}; + this.widgetConfig.config.actions = config.actions; return this.widgetConfig; } } diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/html/html-container-widget.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/html/html-container-widget.component.ts index f7bae926bc..51ee9871f8 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/html/html-container-widget.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/html/html-container-widget.component.ts @@ -15,8 +15,10 @@ /// import { - Component, + ChangeDetectorRef, + Component, ComponentRef, ElementRef, + inject, Inject, Injector, Input, @@ -96,6 +98,7 @@ export class HtmlContainerWidgetComponent implements OnInit { @Inject(HOME_COMPONENTS_MODULE_TOKEN) private homeComponentsModule: Type, private dynamicComponentFactoryService: DynamicComponentFactoryService, private utils: UtilsService, + private cdr: ChangeDetectorRef, private resources: ResourcesService) {} ngOnInit(): void { @@ -160,11 +163,13 @@ export class HtmlContainerWidgetComponent implements OnInit { this.compileAngularFunction().subscribe( { next: (containerFunction) => { - try { - this.initAngularComponent(imports, containerFunction); - } catch (e) { - this.handleWidgetException(e); - } + setTimeout(() => { + try { + this.initAngularComponent(imports, containerFunction); + } catch (e) { + this.handleWidgetException(e); + } + }); }, error: (e) => { this.handleWidgetException(e); @@ -200,9 +205,16 @@ export class HtmlContainerWidgetComponent implements OnInit { compileModules = compileModules.concat(imports); } const self = () => this; + + let containerRef: ComponentRef; + this.dynamicComponentFactoryService.createDynamicComponent( class TbContainerInstance { + + private cdr = inject(ChangeDetectorRef); + ngOnInit(): void { + this.cdr.detach(); if (containerFunction) { const instance = self(); try { @@ -212,6 +224,15 @@ export class HtmlContainerWidgetComponent implements OnInit { } } } + ngDoCheck(): void { + const instance = self(); + try { + this.cdr.detectChanges() + } catch (error) { + containerRef.destroy(); + instance.handleWidgetException(error) + } + } ngOnDestroy(): void { destroyContainerInstanceResources(); } @@ -224,7 +245,7 @@ export class HtmlContainerWidgetComponent implements OnInit { this.containerInstanceComponentType = componentType; const injector: Injector = Injector.create({providers: [], parent: this.angularContainer.viewContainerRef.injector}); try { - this.angularContainer.viewContainerRef.createComponent(this.containerInstanceComponentType, + containerRef = this.angularContainer.viewContainerRef.createComponent(this.containerInstanceComponentType, {index: 0, injector}); } catch (error) { diff --git a/ui-ngx/src/app/modules/home/components/widget/widget.component.ts b/ui-ngx/src/app/modules/home/components/widget/widget.component.ts index cb96a493a1..fc7ad9f64b 100644 --- a/ui-ngx/src/app/modules/home/components/widget/widget.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/widget.component.ts @@ -276,6 +276,7 @@ export class WidgetComponent extends PageComponent implements OnInit, OnChanges, elementClick: this.elementClick.bind(this), cardClick: this.cardClick.bind(this), click: this.click.bind(this), + invokeAction: this.invokeAction.bind(this), getActiveEntityInfo: this.getActiveEntityInfo.bind(this), openDashboardStateInSeparateDialog: this.openDashboardStateInSeparateDialog.bind(this), openDashboardStateInPopover: this.openDashboardStateInPopover.bind(this), @@ -1590,6 +1591,16 @@ export class WidgetComponent extends PageComponent implements OnInit, OnChanges, } } + private invokeAction($event: Event, actionName: string, additionalParams?: any) { + const descriptors = this.getActionDescriptors('javaScript'); + if (descriptors?.length) { + const found = descriptors.find(d => d.name === actionName); + if (found) { + this.handleWidgetAction($event, found, null, null, additionalParams); + } + } + } + private onWidgetAction($event: Event, action: WidgetAction) { if ($event) { $event.stopPropagation(); diff --git a/ui-ngx/src/app/modules/home/models/widget-component.models.ts b/ui-ngx/src/app/modules/home/models/widget-component.models.ts index 1840bcce9d..d72587812c 100644 --- a/ui-ngx/src/app/modules/home/models/widget-component.models.ts +++ b/ui-ngx/src/app/modules/home/models/widget-component.models.ts @@ -149,6 +149,8 @@ export interface IDashboardWidget { updateWidgetParams(): void; } +export type WidgetDestroyCallback = () => void; + export class WidgetContext { constructor(public dashboard: IDashboardComponent, @@ -343,6 +345,8 @@ export class WidgetContext { ...RxJSOperators }; + private destroyCallbacks: WidgetDestroyCallback[] = []; + registerPopoverComponent(popoverComponent: TbPopoverComponent) { this.popoverComponents.push(popoverComponent); popoverComponent.tbDestroy.subscribe(() => { @@ -382,6 +386,10 @@ export class WidgetContext { } } + registerDestroyCallback(destroyCallback: WidgetDestroyCallback) { + this.destroyCallbacks.push(destroyCallback); + } + showSuccessToast(message: string, duration: number = 1000, verticalPosition: NotificationVerticalPosition = 'bottom', horizontalPosition: NotificationHorizontalPosition = 'left', @@ -494,6 +502,13 @@ export class WidgetContext { labelPattern.destroy(); } this.labelPatterns.clear(); + this.destroyCallbacks.forEach((destroyCallback) => { + try { + destroyCallback() + } catch (_ignoredError) { /* empty */ } + } + ); + this.destroyCallbacks.length = 0; this.width = undefined; this.height = undefined; this.destroyed = true; diff --git a/ui-ngx/src/app/shared/models/ace/widget-completion.models.ts b/ui-ngx/src/app/shared/models/ace/widget-completion.models.ts index e6ce4b3ef7..757677fadd 100644 --- a/ui-ngx/src/app/shared/models/ace/widget-completion.models.ts +++ b/ui-ngx/src/app/shared/models/ace/widget-completion.models.ts @@ -632,6 +632,28 @@ export const widgetContextCompletionsWithSettings = (settingsCompletions?: TbEdi optional: true } ] + }, + invokeAction: { + description: 'Invoke action with JavaScript action source.', + meta: 'function', + args: [ + { + name: '$event', + description: 'DOM event object associated with action.', + type: 'Event' + }, + { + name: 'actionName', + description: 'Name of the configured action with JavaScript action source.', + type: 'string' + }, + { + name: 'additionalParams', + description: 'Optional payload merged into the action context and forwarded to the configured JavaScript action function as its additionalParams argument. Use it to pass row data, button state, or any extra values the action handler should react to.', + type: 'object', + optional: true + } + ] } } }, @@ -736,6 +758,17 @@ export const widgetContextCompletionsWithSettings = (settingsCompletions?: TbEdi } } } + }, + registerDestroyCallback: { + description: 'Registers a teardown callback that will be invoked exactly once when the widget is about to be destroyed (dashboard navigation, edit/view switch, layout change, etc.). Use it to release resources acquired during widget setup so they don\'t leak across widget reloads — e.g. unsubscribe RxJS subscriptions, detach DOM/window event listeners, clear setInterval / setTimeout timers, abort outstanding HTTP requests, destroy third-party plugin instances. Multiple callbacks may be registered; they are executed in registration order.', + meta: 'function', + args: [ + { + description: 'Zero-argument function executed when the widget is destroyed. Should be idempotent — synchronously dispose of one specific resource (e.g. one subscription or one listener) and avoid throwing; throwing here may prevent later cleanup callbacks from running.', + name: 'destroyCallback', + type: '() => void', + } + ] } }, ...serviceCompletions From 07464fe568e387736f49d263efc4d860c6b91987 Mon Sep 17 00:00:00 2001 From: Andrii Landiak Date: Thu, 4 Jun 2026 12:16:40 +0300 Subject: [PATCH 7/8] Renaming --- .../server/service/entitiy/queue/DefaultTbQueueService.java | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/application/src/main/java/org/thingsboard/server/service/entitiy/queue/DefaultTbQueueService.java b/application/src/main/java/org/thingsboard/server/service/entitiy/queue/DefaultTbQueueService.java index e161b9fce4..4e42b41dd2 100644 --- a/application/src/main/java/org/thingsboard/server/service/entitiy/queue/DefaultTbQueueService.java +++ b/application/src/main/java/org/thingsboard/server/service/entitiy/queue/DefaultTbQueueService.java @@ -175,10 +175,10 @@ public class DefaultTbQueueService extends AbstractTbEntityService implements Tb private void createTopicsIfNeeded(Queue queue, Queue oldQueue) { int newPartitions = queue.getPartitions(); int oldPartitions = oldQueue != null ? oldQueue.getPartitions() : 0; - String prefixedTopic = topicService.buildTopicName(queue.getTopic()); + String topic = topicService.buildTopicName(queue.getTopic()); for (int i = oldPartitions; i < newPartitions; i++) { tbQueueAdmin.createTopicIfNotExists( - new TopicPartitionInfo(prefixedTopic, queue.getTenantId(), i, false).getFullTopicName(), + new TopicPartitionInfo(topic, queue.getTenantId(), i, false).getFullTopicName(), queue.getCustomProperties(), true); // forcing topic creation because the topic may still be cached on some nodes } From 721a2f0058130c50caa49df4d48cc0215c74ce04 Mon Sep 17 00:00:00 2001 From: Andrii Landiak Date: Thu, 4 Jun 2026 12:16:40 +0300 Subject: [PATCH 8/8] Renaming --- .../server/service/entitiy/queue/DefaultTbQueueService.java | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/application/src/main/java/org/thingsboard/server/service/entitiy/queue/DefaultTbQueueService.java b/application/src/main/java/org/thingsboard/server/service/entitiy/queue/DefaultTbQueueService.java index e161b9fce4..4e42b41dd2 100644 --- a/application/src/main/java/org/thingsboard/server/service/entitiy/queue/DefaultTbQueueService.java +++ b/application/src/main/java/org/thingsboard/server/service/entitiy/queue/DefaultTbQueueService.java @@ -175,10 +175,10 @@ public class DefaultTbQueueService extends AbstractTbEntityService implements Tb private void createTopicsIfNeeded(Queue queue, Queue oldQueue) { int newPartitions = queue.getPartitions(); int oldPartitions = oldQueue != null ? oldQueue.getPartitions() : 0; - String prefixedTopic = topicService.buildTopicName(queue.getTopic()); + String topic = topicService.buildTopicName(queue.getTopic()); for (int i = oldPartitions; i < newPartitions; i++) { tbQueueAdmin.createTopicIfNotExists( - new TopicPartitionInfo(prefixedTopic, queue.getTenantId(), i, false).getFullTopicName(), + new TopicPartitionInfo(topic, queue.getTenantId(), i, false).getFullTopicName(), queue.getCustomProperties(), true); // forcing topic creation because the topic may still be cached on some nodes }