From c908d04afb8d452468b8fa38251812ce163e6ca3 Mon Sep 17 00:00:00 2001 From: Oleksandra Matviienko Date: Mon, 13 Apr 2026 11:15:15 +0200 Subject: [PATCH 01/13] fix: add SSRF protection to AI model provider URLs Apply SsrfProtectionValidator.validateUri() to AI providers with user-supplied URLs (OpenAI baseUrl, Azure OpenAI endpoint, Ollama baseUrl). Validation at two layers: - execution time in Langchain4jChatModelConfigurerImpl - save time in AiModelDataValidator Controlled by the existing SSRF_PROTECTION_ENABLED flag. --- .../Langchain4jChatModelConfigurerImpl.java | 9 ++ .../controller/AiModelControllerTest.java | 32 ++++++ ...angchain4jChatModelConfigurerImplTest.java | 103 ++++++++++++++++++ .../validator/AiModelDataValidator.java | 26 +++++ 4 files changed, 170 insertions(+) create mode 100644 application/src/test/java/org/thingsboard/server/service/ai/Langchain4jChatModelConfigurerImplTest.java diff --git a/application/src/main/java/org/thingsboard/server/service/ai/Langchain4jChatModelConfigurerImpl.java b/application/src/main/java/org/thingsboard/server/service/ai/Langchain4jChatModelConfigurerImpl.java index 28d696468c..8e14eff009 100644 --- a/application/src/main/java/org/thingsboard/server/service/ai/Langchain4jChatModelConfigurerImpl.java +++ b/application/src/main/java/org/thingsboard/server/service/ai/Langchain4jChatModelConfigurerImpl.java @@ -37,6 +37,7 @@ import dev.langchain4j.model.openai.OpenAiChatModel; import dev.langchain4j.model.vertexai.gemini.VertexAiGeminiChatModel; import org.springframework.http.HttpHeaders; import org.springframework.stereotype.Component; +import org.thingsboard.common.util.SsrfProtectionValidator; import org.thingsboard.server.common.data.ai.model.chat.AmazonBedrockChatModelConfig; import org.thingsboard.server.common.data.ai.model.chat.AnthropicChatModelConfig; import org.thingsboard.server.common.data.ai.model.chat.AzureOpenAiChatModelConfig; @@ -58,6 +59,7 @@ import software.amazon.awssdk.services.bedrockruntime.BedrockRuntimeClient; import java.io.ByteArrayInputStream; import java.io.IOException; +import java.net.URI; import java.nio.charset.StandardCharsets; import java.time.Duration; import java.util.Base64; @@ -69,6 +71,7 @@ class Langchain4jChatModelConfigurerImpl implements Langchain4jChatModelConfigur @Override public ChatModel configureChatModel(OpenAiChatModelConfig chatModelConfig) { + validateBaseUrl(chatModelConfig.providerConfig().baseUrl()); return OpenAiChatModel.builder() .baseUrl(chatModelConfig.providerConfig().baseUrl()) .apiKey(chatModelConfig.providerConfig().apiKey()) @@ -86,6 +89,7 @@ class Langchain4jChatModelConfigurerImpl implements Langchain4jChatModelConfigur @Override public ChatModel configureChatModel(AzureOpenAiChatModelConfig chatModelConfig) { AzureOpenAiProviderConfig providerConfig = chatModelConfig.providerConfig(); + validateBaseUrl(providerConfig.endpoint()); return AzureOpenAiChatModel.builder() .endpoint(providerConfig.endpoint()) .serviceVersion(providerConfig.serviceVersion()) @@ -273,6 +277,7 @@ class Langchain4jChatModelConfigurerImpl implements Langchain4jChatModelConfigur @Override public ChatModel configureChatModel(OllamaChatModelConfig chatModelConfig) { + validateBaseUrl(chatModelConfig.providerConfig().baseUrl()); var builder = OllamaChatModel.builder() .baseUrl(chatModelConfig.providerConfig().baseUrl()) .modelName(chatModelConfig.modelId()) @@ -300,6 +305,10 @@ class Langchain4jChatModelConfigurerImpl implements Langchain4jChatModelConfigur return builder.build(); } + private static void validateBaseUrl(String url) { + SsrfProtectionValidator.validateUri(URI.create(url)); + } + private static Duration toDuration(Integer timeoutSeconds) { return timeoutSeconds != null ? Duration.ofSeconds(timeoutSeconds) : null; } diff --git a/application/src/test/java/org/thingsboard/server/controller/AiModelControllerTest.java b/application/src/test/java/org/thingsboard/server/controller/AiModelControllerTest.java index 099ef05752..84c5b31d00 100644 --- a/application/src/test/java/org/thingsboard/server/controller/AiModelControllerTest.java +++ b/application/src/test/java/org/thingsboard/server/controller/AiModelControllerTest.java @@ -19,6 +19,7 @@ import com.datastax.oss.driver.api.core.uuid.Uuids; import com.fasterxml.jackson.core.type.TypeReference; import org.junit.Test; import org.springframework.test.web.servlet.ResultActions; +import org.thingsboard.common.util.SsrfProtectionValidator; import org.thingsboard.server.common.data.EntityType; import org.thingsboard.server.common.data.ai.AiModel; import org.thingsboard.server.common.data.ai.model.chat.AnthropicChatModelConfig; @@ -136,6 +137,37 @@ public class AiModelControllerTest extends AbstractControllerTest { assertThat(updatedModel.getExternalId()).isNull(); } + @Test + public void saveAiModel_whenBaseUrlIsPrivateIp_shouldReturnBadRequest() throws Exception { + // GIVEN + loginTenantAdmin(); + SsrfProtectionValidator.setEnabled(true); + + try { + var modelConfig = OpenAiChatModelConfig.builder() + .providerConfig(OpenAiProviderConfig.builder() + .baseUrl("http://172.17.0.1:22/") + .apiKey("test-api-key") + .build()) + .modelId("gpt-4o") + .build(); + + AiModel model = AiModel.builder() + .tenantId(tenantId) + .name("SSRF test model") + .configuration(modelConfig) + .build(); + + // WHEN + ResultActions result = doPost("/api/ai/model", model); + + // THEN + result.andExpect(status().isBadRequest()); + } finally { + SsrfProtectionValidator.setEnabled(false); + } + } + /* --- Get by ID API tests --- */ @Test diff --git a/application/src/test/java/org/thingsboard/server/service/ai/Langchain4jChatModelConfigurerImplTest.java b/application/src/test/java/org/thingsboard/server/service/ai/Langchain4jChatModelConfigurerImplTest.java new file mode 100644 index 0000000000..61509cf1a3 --- /dev/null +++ b/application/src/test/java/org/thingsboard/server/service/ai/Langchain4jChatModelConfigurerImplTest.java @@ -0,0 +1,103 @@ +/** + * 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.ai; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.parallel.ResourceLock; +import org.thingsboard.common.util.SsrfProtectionValidator; +import org.thingsboard.server.common.data.ai.model.chat.AzureOpenAiChatModelConfig; +import org.thingsboard.server.common.data.ai.model.chat.OllamaChatModelConfig; +import org.thingsboard.server.common.data.ai.model.chat.OpenAiChatModelConfig; +import org.thingsboard.server.common.data.ai.provider.AzureOpenAiProviderConfig; +import org.thingsboard.server.common.data.ai.provider.OllamaProviderConfig; +import org.thingsboard.server.common.data.ai.provider.OpenAiProviderConfig; + +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +@ResourceLock("SsrfProtectionValidator") +class Langchain4jChatModelConfigurerImplTest { + + private final Langchain4jChatModelConfigurerImpl configurer = new Langchain4jChatModelConfigurerImpl(); + + @BeforeEach + void enableSsrfProtection() { + SsrfProtectionValidator.setEnabled(true); + } + + @AfterEach + void disableSsrfProtection() { + SsrfProtectionValidator.setEnabled(false); + } + + @Test + void configureChatModel_openAi_withPrivateIp_shouldThrow() { + var config = OpenAiChatModelConfig.builder() + .providerConfig(OpenAiProviderConfig.builder() + .baseUrl("http://172.17.0.1:8080/") + .apiKey("test") + .build()) + .modelId("gpt-4o") + .build(); + + assertThatThrownBy(() -> configurer.configureChatModel(config)) + .isInstanceOf(RuntimeException.class) + .hasMessageContaining("URI is invalid"); + } + + @Test + void configureChatModel_openAi_withLocalhostUrl_shouldThrow() { + var config = OpenAiChatModelConfig.builder() + .providerConfig(OpenAiProviderConfig.builder() + .baseUrl("http://localhost:22/") + .apiKey("test") + .build()) + .modelId("gpt-4o") + .build(); + + assertThatThrownBy(() -> configurer.configureChatModel(config)) + .isInstanceOf(RuntimeException.class) + .hasMessageContaining("URI is invalid"); + } + + @Test + void configureChatModel_azureOpenAi_withPrivateIp_shouldThrow() { + var config = AzureOpenAiChatModelConfig.builder() + .providerConfig(new AzureOpenAiProviderConfig( + "http://10.0.0.1:8080/", null, "test-key")) + .modelId("gpt-4o") + .build(); + + assertThatThrownBy(() -> configurer.configureChatModel(config)) + .isInstanceOf(RuntimeException.class) + .hasMessageContaining("URI is invalid"); + } + + @Test + void configureChatModel_ollama_withPrivateIp_shouldThrow() { + var config = OllamaChatModelConfig.builder() + .providerConfig(new OllamaProviderConfig( + "http://192.168.1.100:11434/", new OllamaProviderConfig.OllamaAuth.None())) + .modelId("llama3") + .build(); + + assertThatThrownBy(() -> configurer.configureChatModel(config)) + .isInstanceOf(RuntimeException.class) + .hasMessageContaining("URI is invalid"); + } + +} diff --git a/dao/src/main/java/org/thingsboard/server/dao/service/validator/AiModelDataValidator.java b/dao/src/main/java/org/thingsboard/server/dao/service/validator/AiModelDataValidator.java index 9b138fe279..3d6c420e4c 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/service/validator/AiModelDataValidator.java +++ b/dao/src/main/java/org/thingsboard/server/dao/service/validator/AiModelDataValidator.java @@ -17,13 +17,19 @@ package org.thingsboard.server.dao.service.validator; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Component; +import org.thingsboard.common.util.SsrfProtectionValidator; import org.thingsboard.server.common.data.ai.AiModel; +import org.thingsboard.server.common.data.ai.provider.AiProviderConfig; +import org.thingsboard.server.common.data.ai.provider.AzureOpenAiProviderConfig; +import org.thingsboard.server.common.data.ai.provider.OllamaProviderConfig; +import org.thingsboard.server.common.data.ai.provider.OpenAiProviderConfig; import org.thingsboard.server.common.data.id.TenantId; import org.thingsboard.server.dao.ai.AiModelDao; import org.thingsboard.server.dao.exception.DataValidationException; import org.thingsboard.server.dao.service.DataValidator; import org.thingsboard.server.dao.tenant.TenantService; +import java.net.URI; import java.util.Optional; @Component @@ -64,6 +70,26 @@ class AiModelDataValidator extends DataValidator { if (!tenantService.tenantExists(tenantId)) { throw new DataValidationException("AI model reference a non-existent tenant!"); } + + // provider URL SSRF validation + if (model.getConfiguration() != null) { + AiProviderConfig providerConfig = model.getConfiguration().providerConfig(); + String url = null; + if (providerConfig instanceof OpenAiProviderConfig c) { + url = c.baseUrl(); + } else if (providerConfig instanceof AzureOpenAiProviderConfig c) { + url = c.endpoint(); + } else if (providerConfig instanceof OllamaProviderConfig c) { + url = c.baseUrl(); + } + if (url != null) { + try { + SsrfProtectionValidator.validateUri(URI.create(url)); + } catch (Exception e) { + throw new DataValidationException("AI model provider URL is not allowed: " + e.getMessage()); + } + } + } } } From d8c1a9172106b4bdf5e8f65d1232ac0d6d3d7a49 Mon Sep 17 00:00:00 2001 From: Oleksandra Matviienko Date: Wed, 15 Apr 2026 11:11:19 +0200 Subject: [PATCH 02/13] fix: return Failure envelope when SSRF blocks AI chat URL at runtime Wrap configure() call in AiChatModelServiceImpl.sendChatRequestAsync so a synchronous exception (e.g. runtime SSRF block) is converted to a failed future and caught by the controller's .catching() chain, returning TbChatResponse.Failure instead of a raw 500. --- .../service/ai/AiChatModelServiceImpl.java | 8 +++- .../controller/AiModelControllerTest.java | 37 +++++++++++++++++++ 2 files changed, 44 insertions(+), 1 deletion(-) diff --git a/application/src/main/java/org/thingsboard/server/service/ai/AiChatModelServiceImpl.java b/application/src/main/java/org/thingsboard/server/service/ai/AiChatModelServiceImpl.java index 212d363280..15be6f3734 100644 --- a/application/src/main/java/org/thingsboard/server/service/ai/AiChatModelServiceImpl.java +++ b/application/src/main/java/org/thingsboard/server/service/ai/AiChatModelServiceImpl.java @@ -17,6 +17,7 @@ package org.thingsboard.server.service.ai; import com.fasterxml.jackson.core.io.JsonStringEncoder; import com.google.common.util.concurrent.FluentFuture; +import com.google.common.util.concurrent.Futures; import dev.langchain4j.data.message.ChatMessage; import dev.langchain4j.data.message.Content; import dev.langchain4j.data.message.TextContent; @@ -42,7 +43,12 @@ class AiChatModelServiceImpl implements AiChatModelService { @Override public > FluentFuture sendChatRequestAsync(AiChatModelConfig chatModelConfig, ChatRequest chatRequest) { - ChatModel langChainChatModel = chatModelConfig.configure(chatModelConfigurer); + ChatModel langChainChatModel; + try { + langChainChatModel = chatModelConfig.configure(chatModelConfigurer); + } catch (Throwable t) { + return FluentFuture.from(Futures.immediateFailedFuture(t)); + } if (langChainChatModel.provider() == ModelProvider.GITHUB_MODELS) { chatRequest = prepareGithubChatRequest(chatRequest); } diff --git a/application/src/test/java/org/thingsboard/server/controller/AiModelControllerTest.java b/application/src/test/java/org/thingsboard/server/controller/AiModelControllerTest.java index 84c5b31d00..8e2dddf80f 100644 --- a/application/src/test/java/org/thingsboard/server/controller/AiModelControllerTest.java +++ b/application/src/test/java/org/thingsboard/server/controller/AiModelControllerTest.java @@ -22,6 +22,10 @@ import org.springframework.test.web.servlet.ResultActions; import org.thingsboard.common.util.SsrfProtectionValidator; import org.thingsboard.server.common.data.EntityType; import org.thingsboard.server.common.data.ai.AiModel; +import org.thingsboard.server.common.data.ai.dto.TbChatRequest; +import org.thingsboard.server.common.data.ai.dto.TbChatResponse; +import org.thingsboard.server.common.data.ai.dto.TbContent; +import org.thingsboard.server.common.data.ai.dto.TbUserMessage; import org.thingsboard.server.common.data.ai.model.chat.AnthropicChatModelConfig; import org.thingsboard.server.common.data.ai.model.chat.GoogleAiGeminiChatModelConfig; import org.thingsboard.server.common.data.ai.model.chat.OpenAiChatModelConfig; @@ -35,6 +39,8 @@ import org.thingsboard.server.common.data.page.PageLink; import org.thingsboard.server.common.data.page.SortOrder; import org.thingsboard.server.dao.service.DaoSqlTest; +import java.util.List; + import static org.assertj.core.api.Assertions.assertThat; import static org.hamcrest.Matchers.equalTo; import static org.hamcrest.Matchers.is; @@ -168,6 +174,37 @@ public class AiModelControllerTest extends AbstractControllerTest { } } + @Test + public void sendChatRequest_whenBaseUrlBlockedAtRuntime_shouldReturnFailureEnvelope() throws Exception { + // GIVEN + loginTenantAdmin(); + SsrfProtectionValidator.setEnabled(true); + + try { + var modelConfig = OpenAiChatModelConfig.builder() + .providerConfig(OpenAiProviderConfig.builder() + .baseUrl("http://10.0.0.1:8080/") + .apiKey("test-api-key") + .build()) + .modelId("gpt-4o") + .build(); + + var chatRequest = new TbChatRequest( + null, + new TbUserMessage(List.of(new TbContent.TbTextContent("hi"))), + modelConfig); + + // WHEN + TbChatResponse response = doPost("/api/ai/chat", chatRequest, TbChatResponse.class); + + // THEN + assertThat(response).isInstanceOf(TbChatResponse.Failure.class); + assertThat(((TbChatResponse.Failure) response).errorDetails()).contains("URI is invalid"); + } finally { + SsrfProtectionValidator.setEnabled(false); + } + } + /* --- Get by ID API tests --- */ @Test From cbdabec29e9bae2ece8ea28f7ddba14ba4a26d42 Mon Sep 17 00:00:00 2001 From: Oleksandra Matviienko Date: Wed, 15 Apr 2026 15:06:44 +0200 Subject: [PATCH 03/13] fix: correct chat endpoint URL in AiModelControllerTest The runtime SSRF test called /api/ai/chat, but the actual endpoint is /api/ai/model/chat (mapped via @RequestMapping("/api/ai/model") + @PostMapping("/chat")). --- .../thingsboard/server/controller/AiModelControllerTest.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/application/src/test/java/org/thingsboard/server/controller/AiModelControllerTest.java b/application/src/test/java/org/thingsboard/server/controller/AiModelControllerTest.java index 8e2dddf80f..d2464601e4 100644 --- a/application/src/test/java/org/thingsboard/server/controller/AiModelControllerTest.java +++ b/application/src/test/java/org/thingsboard/server/controller/AiModelControllerTest.java @@ -195,7 +195,7 @@ public class AiModelControllerTest extends AbstractControllerTest { modelConfig); // WHEN - TbChatResponse response = doPost("/api/ai/chat", chatRequest, TbChatResponse.class); + TbChatResponse response = doPost("/api/ai/model/chat", chatRequest, TbChatResponse.class); // THEN assertThat(response).isInstanceOf(TbChatResponse.Failure.class); From 2552e4810f7006b4a25590b43d698407379a3d9c Mon Sep 17 00:00:00 2001 From: Oleksandra Matviienko Date: Thu, 16 Apr 2026 08:38:32 +0200 Subject: [PATCH 04/13] fix: use doPostAsync for DeferredResult chat endpoint in test The /api/ai/model/chat endpoint returns DeferredResult (async), so MockMvc needs asyncDispatch() to capture the resolved response body. Without it the test sees an empty body and fails on deserialization. --- .../thingsboard/server/controller/AiModelControllerTest.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/application/src/test/java/org/thingsboard/server/controller/AiModelControllerTest.java b/application/src/test/java/org/thingsboard/server/controller/AiModelControllerTest.java index d2464601e4..100f44d4b7 100644 --- a/application/src/test/java/org/thingsboard/server/controller/AiModelControllerTest.java +++ b/application/src/test/java/org/thingsboard/server/controller/AiModelControllerTest.java @@ -195,7 +195,7 @@ public class AiModelControllerTest extends AbstractControllerTest { modelConfig); // WHEN - TbChatResponse response = doPost("/api/ai/model/chat", chatRequest, TbChatResponse.class); + TbChatResponse response = doPostAsync("/api/ai/model/chat", chatRequest, TbChatResponse.class, status().isOk()); // THEN assertThat(response).isInstanceOf(TbChatResponse.Failure.class); From 0f42746e9cfc3481466c29fc35de0bd5444d55ca Mon Sep 17 00:00:00 2001 From: Oleksandra Matviienko Date: Fri, 17 Apr 2026 14:56:36 +0200 Subject: [PATCH 05/13] Release Netty resources when MQTT transport init fails --- .../transport/mqtt/MqttTransportService.java | 35 +++-- .../mqtt/MqttTransportServiceTest.java | 121 ++++++++++++++++++ 2 files changed, 145 insertions(+), 11 deletions(-) create mode 100644 common/transport/mqtt/src/test/java/org/thingsboard/server/transport/mqtt/MqttTransportServiceTest.java diff --git a/common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/MqttTransportService.java b/common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/MqttTransportService.java index 8b780c6ab4..5975e6ec97 100644 --- a/common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/MqttTransportService.java +++ b/common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/MqttTransportService.java @@ -80,20 +80,33 @@ public class MqttTransportService implements TbTransportService { log.info("Starting MQTT transport..."); bossGroup = new NioEventLoopGroup(bossGroupThreadCount); workerGroup = new NioEventLoopGroup(workerGroupThreadCount); - ServerBootstrap b = new ServerBootstrap(); - b.group(bossGroup, workerGroup) - .channel(NioServerSocketChannel.class) - .childHandler(new MqttTransportServerInitializer(context, false)) - .childOption(ChannelOption.SO_KEEPALIVE, keepAlive); - - serverChannel = b.bind(host, port).sync().channel(); - if (sslEnabled) { - b = new ServerBootstrap(); + try { + ServerBootstrap b = new ServerBootstrap(); b.group(bossGroup, workerGroup) .channel(NioServerSocketChannel.class) - .childHandler(new MqttTransportServerInitializer(context, true)) + .childHandler(new MqttTransportServerInitializer(context, false)) .childOption(ChannelOption.SO_KEEPALIVE, keepAlive); - sslServerChannel = b.bind(sslHost, sslPort).sync().channel(); + + serverChannel = b.bind(host, port).sync().channel(); + if (sslEnabled) { + b = new ServerBootstrap(); + b.group(bossGroup, workerGroup) + .channel(NioServerSocketChannel.class) + .childHandler(new MqttTransportServerInitializer(context, true)) + .childOption(ChannelOption.SO_KEEPALIVE, keepAlive); + sslServerChannel = b.bind(sslHost, sslPort).sync().channel(); + } + } catch (Exception e) { + log.error("Failed to start MQTT transport, releasing resources", e); + if (serverChannel != null) { + serverChannel.close(); + } + if (sslServerChannel != null) { + sslServerChannel.close(); + } + workerGroup.shutdownGracefully(); + bossGroup.shutdownGracefully(); + throw e; } log.info("Mqtt transport started!"); } diff --git a/common/transport/mqtt/src/test/java/org/thingsboard/server/transport/mqtt/MqttTransportServiceTest.java b/common/transport/mqtt/src/test/java/org/thingsboard/server/transport/mqtt/MqttTransportServiceTest.java new file mode 100644 index 0000000000..e8c5b35651 --- /dev/null +++ b/common/transport/mqtt/src/test/java/org/thingsboard/server/transport/mqtt/MqttTransportServiceTest.java @@ -0,0 +1,121 @@ +/** + * 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.transport.mqtt; + +import io.netty.channel.Channel; +import io.netty.channel.EventLoopGroup; +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.springframework.test.util.ReflectionTestUtils; + +import java.net.BindException; +import java.net.InetAddress; +import java.net.ServerSocket; +import java.util.concurrent.TimeUnit; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.awaitility.Awaitility.await; + +@ExtendWith(MockitoExtension.class) +public class MqttTransportServiceTest { + + private static final String HOST = "127.0.0.1"; + + @Mock + private MqttTransportContext context; + + private MqttTransportService service; + private ServerSocket occupiedSocket; + private int occupiedPort; + + @BeforeEach + public void setUp() throws Exception { + occupiedSocket = new ServerSocket(0, 50, InetAddress.getByName(HOST)); + occupiedPort = occupiedSocket.getLocalPort(); + + service = new MqttTransportService(); + ReflectionTestUtils.setField(service, "host", HOST); + ReflectionTestUtils.setField(service, "port", occupiedPort); + ReflectionTestUtils.setField(service, "sslEnabled", false); + ReflectionTestUtils.setField(service, "sslHost", HOST); + ReflectionTestUtils.setField(service, "sslPort", 0); + ReflectionTestUtils.setField(service, "leakDetectorLevel", "DISABLED"); + ReflectionTestUtils.setField(service, "bossGroupThreadCount", 1); + ReflectionTestUtils.setField(service, "workerGroupThreadCount", 1); + ReflectionTestUtils.setField(service, "keepAlive", true); + ReflectionTestUtils.setField(service, "context", context); + } + + @AfterEach + public void tearDown() throws Exception { + if (occupiedSocket != null && !occupiedSocket.isClosed()) { + occupiedSocket.close(); + } + } + + @Test + public void whenPlainBindFails_thenInitThrowsAndReleasesNettyResources() { + assertThatThrownBy(() -> service.init()) + .isInstanceOf(BindException.class); + + Channel serverChannel = (Channel) ReflectionTestUtils.getField(service, "serverChannel"); + Channel sslServerChannel = (Channel) ReflectionTestUtils.getField(service, "sslServerChannel"); + EventLoopGroup boss = (EventLoopGroup) ReflectionTestUtils.getField(service, "bossGroup"); + EventLoopGroup worker = (EventLoopGroup) ReflectionTestUtils.getField(service, "workerGroup"); + + assertThat(serverChannel).isNull(); + assertThat(sslServerChannel).isNull(); + assertThat(boss).isNotNull(); + assertThat(worker).isNotNull(); + assertThat(boss.isShuttingDown()).isTrue(); + assertThat(worker.isShuttingDown()).isTrue(); + + await().atMost(30, TimeUnit.SECONDS).until(boss::isTerminated); + await().atMost(30, TimeUnit.SECONDS).until(worker::isTerminated); + } + + @Test + public void whenSslBindFailsAfterPlainBound_thenInitThrowsAndClosesPlainChannelAndReleasesNettyResources() { + ReflectionTestUtils.setField(service, "port", 0); + ReflectionTestUtils.setField(service, "sslEnabled", true); + ReflectionTestUtils.setField(service, "sslPort", occupiedPort); + + assertThatThrownBy(() -> service.init()) + .isInstanceOf(BindException.class); + + Channel serverChannel = (Channel) ReflectionTestUtils.getField(service, "serverChannel"); + Channel sslServerChannel = (Channel) ReflectionTestUtils.getField(service, "sslServerChannel"); + EventLoopGroup boss = (EventLoopGroup) ReflectionTestUtils.getField(service, "bossGroup"); + EventLoopGroup worker = (EventLoopGroup) ReflectionTestUtils.getField(service, "workerGroup"); + + assertThat(serverChannel).isNotNull(); + assertThat(sslServerChannel).isNull(); + assertThat(boss).isNotNull(); + assertThat(worker).isNotNull(); + + await().atMost(10, TimeUnit.SECONDS).until(() -> !serverChannel.isOpen()); + + assertThat(boss.isShuttingDown()).isTrue(); + assertThat(worker.isShuttingDown()).isTrue(); + await().atMost(30, TimeUnit.SECONDS).until(boss::isTerminated); + await().atMost(30, TimeUnit.SECONDS).until(worker::isTerminated); + } +} From b4fecbdf2f9a5eada9cb51b42a3675a0187d5210 Mon Sep 17 00:00:00 2001 From: Oleksandra Matviienko Date: Fri, 17 Apr 2026 15:19:11 +0200 Subject: [PATCH 06/13] fix --- .../transport/mqtt/MqttTransportService.java | 27 +++++++++++++------ .../mqtt/MqttTransportServiceTest.java | 14 ++-------- 2 files changed, 21 insertions(+), 20 deletions(-) diff --git a/common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/MqttTransportService.java b/common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/MqttTransportService.java index 5975e6ec97..5e52863791 100644 --- a/common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/MqttTransportService.java +++ b/common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/MqttTransportService.java @@ -96,17 +96,28 @@ public class MqttTransportService implements TbTransportService { .childOption(ChannelOption.SO_KEEPALIVE, keepAlive); sslServerChannel = b.bind(sslHost, sslPort).sync().channel(); } - } catch (Exception e) { + } catch (Throwable e) { log.error("Failed to start MQTT transport, releasing resources", e); - if (serverChannel != null) { - serverChannel.close(); + if (e instanceof InterruptedException) { + Thread.currentThread().interrupt(); } - if (sslServerChannel != null) { - sslServerChannel.close(); + try { + if (serverChannel != null) { + serverChannel.close().sync(); + } + if (sslServerChannel != null) { + sslServerChannel.close().sync(); + } + } catch (InterruptedException ie) { + Thread.currentThread().interrupt(); + } finally { + workerGroup.shutdownGracefully(); + bossGroup.shutdownGracefully(); } - workerGroup.shutdownGracefully(); - bossGroup.shutdownGracefully(); - throw e; + if (e instanceof Exception) { + throw (Exception) e; + } + throw (Error) e; } log.info("Mqtt transport started!"); } diff --git a/common/transport/mqtt/src/test/java/org/thingsboard/server/transport/mqtt/MqttTransportServiceTest.java b/common/transport/mqtt/src/test/java/org/thingsboard/server/transport/mqtt/MqttTransportServiceTest.java index e8c5b35651..ab21209a48 100644 --- a/common/transport/mqtt/src/test/java/org/thingsboard/server/transport/mqtt/MqttTransportServiceTest.java +++ b/common/transport/mqtt/src/test/java/org/thingsboard/server/transport/mqtt/MqttTransportServiceTest.java @@ -20,9 +20,6 @@ import io.netty.channel.EventLoopGroup; 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.springframework.test.util.ReflectionTestUtils; import java.net.BindException; @@ -33,15 +30,12 @@ import java.util.concurrent.TimeUnit; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.awaitility.Awaitility.await; +import static org.mockito.Mockito.mock; -@ExtendWith(MockitoExtension.class) public class MqttTransportServiceTest { private static final String HOST = "127.0.0.1"; - @Mock - private MqttTransportContext context; - private MqttTransportService service; private ServerSocket occupiedSocket; private int occupiedPort; @@ -61,7 +55,7 @@ public class MqttTransportServiceTest { ReflectionTestUtils.setField(service, "bossGroupThreadCount", 1); ReflectionTestUtils.setField(service, "workerGroupThreadCount", 1); ReflectionTestUtils.setField(service, "keepAlive", true); - ReflectionTestUtils.setField(service, "context", context); + ReflectionTestUtils.setField(service, "context", mock(MqttTransportContext.class)); } @AfterEach @@ -76,13 +70,9 @@ public class MqttTransportServiceTest { assertThatThrownBy(() -> service.init()) .isInstanceOf(BindException.class); - Channel serverChannel = (Channel) ReflectionTestUtils.getField(service, "serverChannel"); - Channel sslServerChannel = (Channel) ReflectionTestUtils.getField(service, "sslServerChannel"); EventLoopGroup boss = (EventLoopGroup) ReflectionTestUtils.getField(service, "bossGroup"); EventLoopGroup worker = (EventLoopGroup) ReflectionTestUtils.getField(service, "workerGroup"); - assertThat(serverChannel).isNull(); - assertThat(sslServerChannel).isNull(); assertThat(boss).isNotNull(); assertThat(worker).isNotNull(); assertThat(boss.isShuttingDown()).isTrue(); From 5b4d4270084d8d4f8abe75c180bfd3a0c626f3ad Mon Sep 17 00:00:00 2001 From: Oleksandra Matviienko Date: Thu, 30 Apr 2026 13:36:15 +0200 Subject: [PATCH 07/13] Hardened MQTT transport init failure handling Moved NioEventLoopGroup allocations into the try block so that a constructor failure for the second group no longer leaks the first. Channel close failures during cleanup now attach via addSuppressed instead of replacing the original BindException. Narrowed the outer catch from Throwable to Exception, removing the brittle (Error) cast that would have masked any direct Throwable subclass. --- .../transport/mqtt/MqttTransportService.java | 23 ++++++++++--------- 1 file changed, 12 insertions(+), 11 deletions(-) diff --git a/common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/MqttTransportService.java b/common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/MqttTransportService.java index 5e52863791..4e41fa7eca 100644 --- a/common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/MqttTransportService.java +++ b/common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/MqttTransportService.java @@ -78,9 +78,9 @@ public class MqttTransportService implements TbTransportService { ResourceLeakDetector.setLevel(ResourceLeakDetector.Level.valueOf(leakDetectorLevel.toUpperCase())); log.info("Starting MQTT transport..."); - bossGroup = new NioEventLoopGroup(bossGroupThreadCount); - workerGroup = new NioEventLoopGroup(workerGroupThreadCount); try { + bossGroup = new NioEventLoopGroup(bossGroupThreadCount); + workerGroup = new NioEventLoopGroup(workerGroupThreadCount); ServerBootstrap b = new ServerBootstrap(); b.group(bossGroup, workerGroup) .channel(NioServerSocketChannel.class) @@ -96,7 +96,7 @@ public class MqttTransportService implements TbTransportService { .childOption(ChannelOption.SO_KEEPALIVE, keepAlive); sslServerChannel = b.bind(sslHost, sslPort).sync().channel(); } - } catch (Throwable e) { + } catch (Exception e) { log.error("Failed to start MQTT transport, releasing resources", e); if (e instanceof InterruptedException) { Thread.currentThread().interrupt(); @@ -108,16 +108,17 @@ public class MqttTransportService implements TbTransportService { if (sslServerChannel != null) { sslServerChannel.close().sync(); } - } catch (InterruptedException ie) { - Thread.currentThread().interrupt(); + } catch (Exception suppressed) { + e.addSuppressed(suppressed); } finally { - workerGroup.shutdownGracefully(); - bossGroup.shutdownGracefully(); - } - if (e instanceof Exception) { - throw (Exception) e; + if (workerGroup != null) { + workerGroup.shutdownGracefully(); + } + if (bossGroup != null) { + bossGroup.shutdownGracefully(); + } } - throw (Error) e; + throw e; } log.info("Mqtt transport started!"); } From 59c907d91c7127dee59a69f3756ade118c38815a Mon Sep 17 00:00:00 2001 From: Oleksandra Matviienko Date: Mon, 4 May 2026 11:24:33 +0200 Subject: [PATCH 08/13] Fixed CVE-2026-40975, CVE-2026-40973, CVE-2026-22740, CVE-2026-42198 --- pom.xml | 30 ++++++++---------------------- 1 file changed, 8 insertions(+), 22 deletions(-) diff --git a/pom.xml b/pom.xml index 905d22c522..fae3c88426 100755 --- a/pom.xml +++ b/pom.xml @@ -62,7 +62,7 @@ ${project.name} /var/log/${pkg.name} /usr/share/${pkg.name} - 3.5.13 + 3.5.14 2.4.0-b180830.0359 0.12.5 0.10 @@ -70,8 +70,8 @@ 4.2.25 5.0.4 33.1.0-jre - 10.1.54 3.18.0 + 42.7.11 2.16.1 1.3.1 1.10.0 @@ -89,7 +89,7 @@ 3.25.5 1.76.0 1.2.9 - 1.18.44 + 1.18.46 1.2.5 1.2.5 1.7.1 @@ -1002,25 +1002,6 @@ - - - org.apache.tomcat.embed - tomcat-embed-core - ${tomcat.version} - - - org.apache.tomcat.embed - tomcat-embed-el - ${tomcat.version} - - - org.apache.tomcat.embed - tomcat-embed-websocket - ${tomcat.version} - - org.springframework.boot spring-boot-dependencies @@ -1356,6 +1337,11 @@ commons-lang3 ${commons-lang3.version} + + org.postgresql + postgresql + ${postgresql.version} + commons-io commons-io From 1b8a25d568f1a7e213663e0e71dd92e46761807d Mon Sep 17 00:00:00 2001 From: dashevchenko Date: Mon, 4 May 2026 15:55:10 +0300 Subject: [PATCH 09/13] fixed MAX aggregation for negative double values --- .../script/api/tbel/TbelCfTsRollingArg.java | 2 +- .../script/api/tbel/TbelCfTsRollingArgTest.java | 13 +++++++++++++ .../server/dao/sqlts/ts/TsKvRepository.java | 4 +++- .../dao/timeseries/AggregatePartitionsFunction.java | 2 +- .../timeseries/BaseTimeseriesServiceTest.java | 12 ++++++++++++ 5 files changed, 30 insertions(+), 3 deletions(-) diff --git a/common/script/script-api/src/main/java/org/thingsboard/script/api/tbel/TbelCfTsRollingArg.java b/common/script/script-api/src/main/java/org/thingsboard/script/api/tbel/TbelCfTsRollingArg.java index b03a460c31..2452bd8d61 100644 --- a/common/script/script-api/src/main/java/org/thingsboard/script/api/tbel/TbelCfTsRollingArg.java +++ b/common/script/script-api/src/main/java/org/thingsboard/script/api/tbel/TbelCfTsRollingArg.java @@ -73,7 +73,7 @@ public class TbelCfTsRollingArg implements TbelCfArg, Iterable processMinOrMaxResult(AggregationResult aggResult) { if (aggResult.dataType == DataType.DOUBLE || aggResult.dataType == DataType.LONG) { if (aggResult.hasDouble) { - double currentD = aggregation == Aggregation.MIN ? Optional.ofNullable(aggResult.dValue).orElse(Double.MAX_VALUE) : Optional.ofNullable(aggResult.dValue).orElse(Double.MIN_VALUE); + double currentD = aggregation == Aggregation.MIN ? Optional.ofNullable(aggResult.dValue).orElse(Double.MAX_VALUE) : Optional.ofNullable(aggResult.dValue).orElse(-Double.MAX_VALUE); double currentL = aggregation == Aggregation.MIN ? Optional.ofNullable(aggResult.lValue).orElse(Long.MAX_VALUE) : Optional.ofNullable(aggResult.lValue).orElse(Long.MIN_VALUE); return Optional.of(new BasicTsKvEntry(ts, new DoubleDataEntry(key, aggregation == Aggregation.MIN ? Math.min(currentD, currentL) : Math.max(currentD, currentL)))); } else { diff --git a/dao/src/test/java/org/thingsboard/server/dao/service/timeseries/BaseTimeseriesServiceTest.java b/dao/src/test/java/org/thingsboard/server/dao/service/timeseries/BaseTimeseriesServiceTest.java index 221684c10c..8eed3b7321 100644 --- a/dao/src/test/java/org/thingsboard/server/dao/service/timeseries/BaseTimeseriesServiceTest.java +++ b/dao/src/test/java/org/thingsboard/server/dao/service/timeseries/BaseTimeseriesServiceTest.java @@ -710,6 +710,18 @@ public abstract class BaseTimeseriesServiceTest extends AbstractServiceTest { assertEquals(java.util.Optional.of(2L), list.get(2).getLongValue()); } + @Test + public void testFindDeviceMaxAggregationOverNegativeMixedLongAndDoubleTsData() throws Exception { + save(deviceId, 5000, -100L); + save(deviceId, 15000, -50.0); + + List list = tsService.findAll(tenantId, deviceId, Collections.singletonList(new BaseReadTsKvQuery(LONG_KEY, 0, + 60000, 60000, 1, Aggregation.MAX))).get(MAX_TIMEOUT, TimeUnit.SECONDS); + + assertEquals(1, list.size()); + assertEquals(java.util.Optional.of(-50.0), list.get(0).getDoubleValue()); + } + @Test public void testSaveTs_RemoveTs_AndSaveTsAgain() throws Exception { save(deviceId, 2000000L, 95); From fd4a5b36f64247b624997d0882772d4899f91793 Mon Sep 17 00:00:00 2001 From: Sergey Matvienko Date: Mon, 4 May 2026 15:18:49 +0200 Subject: [PATCH 10/13] Serialize msa yarn modules to fix intermittent `tsc: not found` under -T Under `mvn -T` with the three yarn-using modules (ui-ngx, msa/web-ui, msa/js-executor), concurrent yarn 1.x processes contend on the shared `~/.cache/yarn`. The `--mutex network` flag was applied only to `yarn install`, so `yarn run pkg` could overlap with another module's install. Intermittent failures observed on CI: `/bin/sh: 1: tsc: not found` during `yarn run pkg`, caused by incomplete typescript extraction into per-module node_modules. Fix at two layers: 1. Maven reactor chain (primary): add reactor-only pom entries (type=pom, scope=provided, wildcard exclusions) to form ui-ngx -> msa/web-ui -> msa/js-executor so the MultiThreadedBuilder schedules them strictly serial, regardless of -T thread count. msa/web-ui already had a real dependency on ui-ngx; only one new fake link was needed. 2. Yarn-level mutex (defense in depth): add `--mutex network` to `yarn run pkg` (msa/web-ui, msa/js-executor) and `yarn run build:prod` (ui-ngx), so single-module builds outside the reactor chain (`mvn -pl msa/`) still serialize against any other yarn process on the agent. Comment in msa/pom.xml updated: the previous "Modules order is important..." note was misleading - module order in the reactor does not enforce serialization under -T; the dependency edges do. --- msa/js-executor/pom.xml | 25 ++++++++++++++++++++++++- msa/pom.xml | 11 ++++++++++- msa/web-ui/pom.xml | 2 +- ui-ngx/pom.xml | 2 +- 4 files changed, 36 insertions(+), 4 deletions(-) diff --git a/msa/js-executor/pom.xml b/msa/js-executor/pom.xml index f5ea04fac3..58404c0152 100644 --- a/msa/js-executor/pom.xml +++ b/msa/js-executor/pom.xml @@ -52,6 +52,29 @@ exe provided + + + org.thingsboard.msa + web-ui + ${project.version} + pom + provided + + + * + * + + + @@ -90,7 +113,7 @@ compile - run pkg + --mutex network run pkg diff --git a/msa/pom.xml b/msa/pom.xml index ac7b7b70c3..84c98308cd 100644 --- a/msa/pom.xml +++ b/msa/pom.xml @@ -44,7 +44,16 @@ - + tb web-ui vc-executor diff --git a/msa/web-ui/pom.xml b/msa/web-ui/pom.xml index 00c1703dcd..196311f880 100644 --- a/msa/web-ui/pom.xml +++ b/msa/web-ui/pom.xml @@ -99,7 +99,7 @@ compile - run pkg + --mutex network run pkg diff --git a/ui-ngx/pom.xml b/ui-ngx/pom.xml index cc58f728a0..4bc4bfd6cd 100644 --- a/ui-ngx/pom.xml +++ b/ui-ngx/pom.xml @@ -106,7 +106,7 @@ yarn - run build:prod + --mutex network run build:prod From 8d5cbb4db89f70bd37f832fc8be35b0b5981fc0a Mon Sep 17 00:00:00 2001 From: dashevchenko Date: Mon, 4 May 2026 16:29:13 +0300 Subject: [PATCH 11/13] added test --- .../timeseries/BaseTimeseriesServiceTest.java | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/dao/src/test/java/org/thingsboard/server/dao/service/timeseries/BaseTimeseriesServiceTest.java b/dao/src/test/java/org/thingsboard/server/dao/service/timeseries/BaseTimeseriesServiceTest.java index 8eed3b7321..e7d2d6a8fc 100644 --- a/dao/src/test/java/org/thingsboard/server/dao/service/timeseries/BaseTimeseriesServiceTest.java +++ b/dao/src/test/java/org/thingsboard/server/dao/service/timeseries/BaseTimeseriesServiceTest.java @@ -722,6 +722,19 @@ public abstract class BaseTimeseriesServiceTest extends AbstractServiceTest { assertEquals(java.util.Optional.of(-50.0), list.get(0).getDoubleValue()); } + @Test + public void testFindDeviceMaxAggregationOverAllNegativeDoubleTsData() throws Exception { + save(deviceId, 5000, -50.0); + save(deviceId, 15000, -100.0); + save(deviceId, 25000, -75.0); + + List list = tsService.findAll(tenantId, deviceId, Collections.singletonList(new BaseReadTsKvQuery(LONG_KEY, 0, + 60000, 60000, 1, Aggregation.MAX))).get(MAX_TIMEOUT, TimeUnit.SECONDS); + + assertEquals(1, list.size()); + assertEquals(java.util.Optional.of(-50.0), list.get(0).getDoubleValue()); + } + @Test public void testSaveTs_RemoveTs_AndSaveTsAgain() throws Exception { save(deviceId, 2000000L, 95); From 6e79124c5c1a23dff2ec9465c98a0fe0ee22af84 Mon Sep 17 00:00:00 2001 From: Oleksandra Matviienko Date: Mon, 4 May 2026 17:08:49 +0200 Subject: [PATCH 12/13] Pinned spring-boot-test/spring-boot-test-autoconfigure to 3.5.13 Spring Boot 3.5.14 ships an ImportsContextCustomizer change that double-registers legacy @SpyBean fields, causing "Duplicate spy definition" failures during ApplicationContext load in tests that mix @SpyBean and @MockitoSpyBean across the test class hierarchy. Pin the test artifacts to 3.5.13 until 3.5.15+ is released with a fix; runtime stays on 3.5.14 so the CVE fixes remain in effect. --- pom.xml | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/pom.xml b/pom.xml index fae3c88426..acf16759f6 100755 --- a/pom.xml +++ b/pom.xml @@ -63,6 +63,10 @@ /var/log/${pkg.name} /usr/share/${pkg.name} 3.5.14 + + 3.5.13 2.4.0-b180830.0359 0.12.5 0.10 @@ -1248,6 +1252,19 @@ + + + org.springframework.boot + spring-boot-test + ${spring-boot-test.version} + + + org.springframework.boot + spring-boot-test-autoconfigure + ${spring-boot-test.version} + org.apache.kafka kafka-clients From 13ae00a6cde483f4455bd111b72620af37bf109a Mon Sep 17 00:00:00 2001 From: Igor Kulikov Date: Tue, 5 May 2026 16:49:09 +0300 Subject: [PATCH 13/13] feat(html-container): split-pane settings layout, fullscreen, mode-aware completer - Split the settings dialog into a resizable two-pane layout (left: resources/HTML/CSS/JS tabs; right: live preview) using split.js, with a fullscreen toggle that resets the tab animation duration to avoid jank during expand. - Split ContainerFunctionEditorCompleter into HTML- and Angular-mode variants so the autocomplete suggests `container` only in HTML mode (Angular mode has no container argument). - Mark the widget with previewWidth/previewHeight 100% and overflowVisible: true in its controllerScript typeParameters so the basic config preview fills its slot. --- .../system/widget_types/html_container.json | 2 +- .../lib/html/html-container-widget.models.ts | 15 +- .../html-container-settings.component.html | 162 +++++++++++------- .../html-container-settings.component.scss | 114 +++++++++++- .../html/html-container-settings.component.ts | 63 ++++++- 5 files changed, 271 insertions(+), 85 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 70818154af..25b9e5ac5d 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", + "controllerScript": "self.onInit = function() {\n \n}\n\nself.typeParameters = function() {\n return {\n previewWidth: '100%',\n previewHeight: '100%',\n overflowVisible: 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/modules/home/components/widget/lib/html/html-container-widget.models.ts b/ui-ngx/src/app/modules/home/components/widget/lib/html/html-container-widget.models.ts index 87e5edbe41..b974e15fb2 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/html/html-container-widget.models.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/html/html-container-widget.models.ts @@ -51,13 +51,18 @@ const containerFunctionCompletions: TbEditorCompletions = { type: widgetContextCompletions.ctx.type, description: widgetContextCompletions.ctx.description, children: widgetContextCompletions.ctx.children - }, + } + } +}; + +export const AngularContainerFunctionEditorCompleter = new TbEditorCompleter(containerFunctionCompletions); + +export const HTMLContainerFunctionEditorCompleter = new TbEditorCompleter( + {...containerFunctionCompletions, container: { meta: 'argument', type: 'HTMLElement', description: 'Container element of the widget' - }, - } -}; + }} +); -export const ContainerFunctionEditorCompleter = new TbEditorCompleter(containerFunctionCompletions); diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/html/html-container-settings.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/html/html-container-settings.component.html index 4ccf475dc9..a72f28e70e 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/html/html-container-settings.component.html +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/html/html-container-settings.component.html @@ -17,82 +17,112 @@ -->
-
-
widgets.html-container.container-type
+
{{ 'widgets.html-container.type-plain' | translate }} {{ 'widgets.html-container.type-angular' | translate }}
- - - -
{{ 'widgets.html-container.resources' | translate }}
-
-
- @if (resourcesFormArray.length) { - @for (resourceControl of resourcesControls; track resourceControl; let i = $index) { -
- - - @if (htmlContainerSettingsForm.get('type').value === HtmlContainerWidgetType.ANGULAR) { - - {{ 'widget.resource-is-extension' | translate }} - +
+
+ +
+
+ + + +
{{ 'widgets.html-container.resources' | translate }}
+
+
+ @if (resourcesFormArray.length) { + @for (resourceControl of resourcesControls; track resourceControl; let i = $index) { +
+ + + @if (htmlContainerSettingsForm.get('type').value === HtmlContainerWidgetType.ANGULAR) { + + {{ 'widget.resource-is-extension' | translate }} + + } + +
} -
- } - } @else { - widgets.html-container.no-resources +
+ + + + + + + + + + @if (!fullscreen) { + + + + } + +
+ @if (fullscreen) { + } -
- -
- - - - - - - - - - - - - - +
+
+ + + + + + diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/html/html-container-settings.component.scss b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/html/html-container-settings.component.scss index d2385459e0..c2db82139d 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/html/html-container-settings.component.scss +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/html/html-container-settings.component.scss @@ -14,15 +14,115 @@ * limitations under the License. */ - :host { - &.tb-html-container-settings { +.tb-html-container-settings { + height: 100%; +} + +.tb-html-container-settings .tb-html-container-settings-panel, .tb-html-container-settings-panel { + position: relative; + background: #fff; + .mat-mdc-tab-body-wrapper { + position: relative; + top: 0; + flex: 1; + } + .tb-action-expand-button { + position: absolute; + top: 4px; + right: 0; + z-index: 2; + } + .gutter { + display: none; + background-color: #eee; + background-repeat: no-repeat; + background-position: 50%; + &.gutter-horizontal { + cursor: col-resize; + background-image: url("../../../../../../../../../assets/split.js/grips/vertical.png"); + } + } + .tb-js-func { + &:not(.tb-fullscreen) { + &.tb-hide-brackets { + padding-bottom: 0; + } + } + } + .tb-html { + position: relative; + &:not(.tb-fullscreen) { + padding-bottom: 0; + } + .tb-html-toolbar { + position: absolute; + top: 0; + right: 8px; + z-index: 8; + .tb-title { + display: none; + } + } + .tb-html-content-panel { + border-top: none; + height: 100%; + } + } + .tb-css { + position: relative; + &:not(.tb-fullscreen) { + .tb-css-content-panel { + margin: 0; + } + } + .tb-css-toolbar { + position: absolute; + top: 0; + right: 8px; + z-index: 8; + .tb-title { + display: none; + } + } + .tb-css-content-panel { + border-top: none; height: 100%; - ::ng-deep { - .mat-mdc-tab-body-wrapper { - position: relative; - top: 0; - flex: 1; + } + } + &.tb-fullscreen { + padding: 8px; + gap: 8px; + .tb-action-expand-button { + position: relative; + top: 0; + right: 0; + } + .gutter { + display: block; + } + .tb-content { + border: 1px solid #c0c0c0; + .tb-html { + .tb-html-content-panel { + border: none; + } + } + .tb-css { + .tb-css-content-panel { + border: none; + } + } + .tb-js-func { + padding-top: 8px; + .tb-js-func-toolbar { + padding: 0 5px; + } + .tb-js-func-panel { + border-left: none; + border-right: none; + border-bottom: none; } } } + } } diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/html/html-container-settings.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/html/html-container-settings.component.ts index 116d69d468..121362ef57 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/html/html-container-settings.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/html/html-container-settings.component.ts @@ -14,7 +14,18 @@ /// limitations under the License. /// -import { Component, DestroyRef, forwardRef, HostBinding, Input, OnInit } from '@angular/core'; +import { + AfterViewInit, + Component, + DestroyRef, + ElementRef, + forwardRef, + HostBinding, + Input, + OnInit, + ViewChild, + ViewEncapsulation +} from '@angular/core'; import { WidgetResource } from '@shared/models/widget.models'; import { ControlValueAccessor, @@ -28,7 +39,8 @@ import { Validators } from '@angular/forms'; import { - ContainerFunctionEditorCompleter, + AngularContainerFunctionEditorCompleter, + HTMLContainerFunctionEditorCompleter, HtmlContainerWidgetSettings, HtmlContainerWidgetType } from '@home/components/widget/lib/html/html-container-widget.models'; @@ -52,23 +64,39 @@ import { WidgetService } from '@core/http/widget.service'; multi: true, } ], + encapsulation: ViewEncapsulation.None, standalone: false }) -export class HtmlContainerSettingsComponent implements OnInit, ControlValueAccessor, Validator { +export class HtmlContainerSettingsComponent implements OnInit, AfterViewInit, ControlValueAccessor, Validator { HtmlContainerWidgetType = HtmlContainerWidgetType; functionScopeVariables = this.widgetService.getWidgetScopeVariables(); - containerFunctionEditorCompleter = ContainerFunctionEditorCompleter; + get containerFunctionEditorCompleter() { + return this.htmlContainerSettingsForm.get('type').value === HtmlContainerWidgetType.ANGULAR + ? AngularContainerFunctionEditorCompleter + : HTMLContainerFunctionEditorCompleter; + } @HostBinding('class') hostClass = 'tb-html-container-settings'; + @ViewChild('leftPanel', { read: ElementRef }) + leftPanelElmRef!: ElementRef; + + @ViewChild('rightPanel', { read: ElementRef }) + rightPanelElmRef!: ElementRef; + @Input() disabled: boolean; + fullscreen = false; + + tabsAnimationDuration = '500ms'; + htmlContainerSettingsForm: UntypedFormGroup; + private modelValue: HtmlContainerWidgetSettings; constructor(private fb: UntypedFormBuilder, @@ -102,11 +130,26 @@ export class HtmlContainerSettingsComponent implements OnInit, ControlValueAcces }); } + ngAfterViewInit(): void { + if (this.leftPanelElmRef && this.rightPanelElmRef) { + this.initSplitLayout(this.leftPanelElmRef.nativeElement, + this.rightPanelElmRef.nativeElement); + } + } + + private initSplitLayout(leftPanel: any, rightPanel: any) { + Split([leftPanel, rightPanel], { + sizes: [50, 50], + gutterSize: 8, + cursor: 'col-resize' + }); + } + registerOnChange(fn: any): void { this.propagateChange = fn; } - registerOnTouched(fn: any): void { + registerOnTouched(_fn: any): void { } setDisabledState(isDisabled: boolean): void { @@ -150,7 +193,15 @@ export class HtmlContainerSettingsComponent implements OnInit, ControlValueAcces this.resourcesFormArray.removeAt(index); } - private propagateChange = (v: any) => { }; + toggleFullScreen(): void { + this.fullscreen = !this.fullscreen; + this.tabsAnimationDuration = '0ms'; + setTimeout(() => { + this.tabsAnimationDuration = '500ms'; + }); + } + + private propagateChange = (_v: any) => { }; private updateModel() { this.modelValue = this.htmlContainerSettingsForm.value;