Browse Source

Merge remote-tracking branch 'upstream/lts-4.3' into bug/map-model-definition-missing-polylines

pull/15579/head
Ekaterina Chantsova 5 months ago
parent
commit
1f5ecf2ced
  1. 3
      application/src/main/data/json/system/widget_bundles/cards.json
  2. 3
      application/src/main/data/json/system/widget_bundles/html_widgets.json
  3. 52
      application/src/main/data/json/system/widget_types/html_container.json
  4. 8
      application/src/main/java/org/thingsboard/server/service/ai/AiChatModelServiceImpl.java
  5. 9
      application/src/main/java/org/thingsboard/server/service/ai/Langchain4jChatModelConfigurerImpl.java
  6. 1
      application/src/main/java/org/thingsboard/server/service/cf/ctx/state/alarm/AlarmCalculatedFieldState.java
  7. 12
      application/src/main/java/org/thingsboard/server/service/cf/ctx/state/alarm/AlarmRuleState.java
  8. 69
      application/src/test/java/org/thingsboard/server/controller/AiModelControllerTest.java
  9. 78
      application/src/test/java/org/thingsboard/server/service/ai/Langchain4jChatModelConfigurerImplTest.java
  10. 2
      common/data/src/main/java/org/thingsboard/server/common/data/notification/rule/NotificationRuleRecipientsConfig.java
  11. 2
      common/data/src/main/java/org/thingsboard/server/common/data/sync/ie/EntityExportData.java
  12. 2
      common/script/script-api/src/main/java/org/thingsboard/script/api/tbel/TbelCfTsRollingArg.java
  13. 13
      common/script/script-api/src/test/java/org/thingsboard/script/api/tbel/TbelCfTsRollingArgTest.java
  14. 51
      common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/MqttTransportService.java
  15. 111
      common/transport/mqtt/src/test/java/org/thingsboard/server/transport/mqtt/MqttTransportServiceTest.java
  16. 26
      dao/src/main/java/org/thingsboard/server/dao/service/validator/AiModelDataValidator.java
  17. 4
      dao/src/main/java/org/thingsboard/server/dao/sqlts/ts/TsKvRepository.java
  18. 2
      dao/src/main/java/org/thingsboard/server/dao/timeseries/AggregatePartitionsFunction.java
  19. 25
      dao/src/test/java/org/thingsboard/server/dao/service/timeseries/BaseTimeseriesServiceTest.java
  20. 25
      msa/js-executor/pom.xml
  21. 11
      msa/pom.xml
  22. 2
      msa/web-ui/pom.xml
  23. 47
      pom.xml
  24. 2
      ui-ngx/pom.xml
  25. 9
      ui-ngx/src/app/modules/home/components/widget/config/basic/basic-widget-config.module.ts
  26. 20
      ui-ngx/src/app/modules/home/components/widget/config/basic/html/html-container-basic-config.component.html
  27. 62
      ui-ngx/src/app/modules/home/components/widget/config/basic/html/html-container-basic-config.component.ts
  28. 318
      ui-ngx/src/app/modules/home/components/widget/lib/html/html-container-widget.component.ts
  29. 68
      ui-ngx/src/app/modules/home/components/widget/lib/html/html-container-widget.models.ts
  30. 128
      ui-ngx/src/app/modules/home/components/widget/lib/settings/common/html/html-container-settings.component.html
  31. 128
      ui-ngx/src/app/modules/home/components/widget/lib/settings/common/html/html-container-settings.component.scss
  32. 237
      ui-ngx/src/app/modules/home/components/widget/lib/settings/common/html/html-container-settings.component.ts
  33. 5
      ui-ngx/src/app/modules/home/components/widget/lib/settings/common/widget-settings-common.module.ts
  34. 2
      ui-ngx/src/app/modules/home/components/widget/lib/settings/common/widget/widget-settings.component.html
  35. 20
      ui-ngx/src/app/modules/home/components/widget/lib/settings/html/html-container-widget-settings.component.html
  36. 65
      ui-ngx/src/app/modules/home/components/widget/lib/settings/html/html-container-widget-settings.component.ts
  37. 5
      ui-ngx/src/app/modules/home/components/widget/lib/settings/widget-settings.module.ts
  38. 3
      ui-ngx/src/app/modules/home/components/widget/widget-components.module.ts
  39. 2
      ui-ngx/src/app/modules/home/components/widget/widget-config.component.html
  40. 1
      ui-ngx/src/app/modules/home/components/widget/widget-config.component.scss
  41. 12
      ui-ngx/src/assets/locale/locale.constant-en_US.json

3
application/src/main/data/json/system/widget_bundles/cards.json

@ -24,6 +24,7 @@
"cards.html_value_card", "cards.html_value_card",
"cards.markdown_card", "cards.markdown_card",
"cards.simple_card", "cards.simple_card",
"unread_notifications" "unread_notifications",
"html_container"
] ]
} }

3
application/src/main/data/json/system/widget_bundles/html_widgets.json

@ -11,6 +11,7 @@
"widgetTypeFqns": [ "widgetTypeFqns": [
"cards.html_card", "cards.html_card",
"cards.html_value_card", "cards.html_value_card",
"cards.markdown_card" "cards.markdown_card",
"html_container"
] ]
} }

52
application/src/main/data/json/system/widget_types/html_container.json

File diff suppressed because one or more lines are too long

8
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.fasterxml.jackson.core.io.JsonStringEncoder;
import com.google.common.util.concurrent.FluentFuture; 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.ChatMessage;
import dev.langchain4j.data.message.Content; import dev.langchain4j.data.message.Content;
import dev.langchain4j.data.message.TextContent; import dev.langchain4j.data.message.TextContent;
@ -42,7 +43,12 @@ class AiChatModelServiceImpl implements AiChatModelService {
@Override @Override
public <C extends AiChatModelConfig<C>> FluentFuture<ChatResponse> sendChatRequestAsync(AiChatModelConfig<C> chatModelConfig, ChatRequest chatRequest) { public <C extends AiChatModelConfig<C>> FluentFuture<ChatResponse> sendChatRequestAsync(AiChatModelConfig<C> 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) { if (langChainChatModel.provider() == ModelProvider.GITHUB_MODELS) {
chatRequest = prepareGithubChatRequest(chatRequest); chatRequest = prepareGithubChatRequest(chatRequest);
} }

9
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 dev.langchain4j.model.vertexai.gemini.VertexAiGeminiChatModel;
import org.springframework.http.HttpHeaders; import org.springframework.http.HttpHeaders;
import org.springframework.stereotype.Component; 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.AmazonBedrockChatModelConfig;
import org.thingsboard.server.common.data.ai.model.chat.AnthropicChatModelConfig; import org.thingsboard.server.common.data.ai.model.chat.AnthropicChatModelConfig;
import org.thingsboard.server.common.data.ai.model.chat.AzureOpenAiChatModelConfig; 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.ByteArrayInputStream;
import java.io.IOException; import java.io.IOException;
import java.net.URI;
import java.nio.charset.StandardCharsets; import java.nio.charset.StandardCharsets;
import java.time.Duration; import java.time.Duration;
import java.util.Base64; import java.util.Base64;
@ -69,6 +71,7 @@ class Langchain4jChatModelConfigurerImpl implements Langchain4jChatModelConfigur
@Override @Override
public ChatModel configureChatModel(OpenAiChatModelConfig chatModelConfig) { public ChatModel configureChatModel(OpenAiChatModelConfig chatModelConfig) {
validateBaseUrl(chatModelConfig.providerConfig().baseUrl());
return OpenAiChatModel.builder() return OpenAiChatModel.builder()
.baseUrl(chatModelConfig.providerConfig().baseUrl()) .baseUrl(chatModelConfig.providerConfig().baseUrl())
.apiKey(chatModelConfig.providerConfig().apiKey()) .apiKey(chatModelConfig.providerConfig().apiKey())
@ -86,6 +89,7 @@ class Langchain4jChatModelConfigurerImpl implements Langchain4jChatModelConfigur
@Override @Override
public ChatModel configureChatModel(AzureOpenAiChatModelConfig chatModelConfig) { public ChatModel configureChatModel(AzureOpenAiChatModelConfig chatModelConfig) {
AzureOpenAiProviderConfig providerConfig = chatModelConfig.providerConfig(); AzureOpenAiProviderConfig providerConfig = chatModelConfig.providerConfig();
validateBaseUrl(providerConfig.endpoint());
return AzureOpenAiChatModel.builder() return AzureOpenAiChatModel.builder()
.endpoint(providerConfig.endpoint()) .endpoint(providerConfig.endpoint())
.serviceVersion(providerConfig.serviceVersion()) .serviceVersion(providerConfig.serviceVersion())
@ -273,6 +277,7 @@ class Langchain4jChatModelConfigurerImpl implements Langchain4jChatModelConfigur
@Override @Override
public ChatModel configureChatModel(OllamaChatModelConfig chatModelConfig) { public ChatModel configureChatModel(OllamaChatModelConfig chatModelConfig) {
validateBaseUrl(chatModelConfig.providerConfig().baseUrl());
var builder = OllamaChatModel.builder() var builder = OllamaChatModel.builder()
.baseUrl(chatModelConfig.providerConfig().baseUrl()) .baseUrl(chatModelConfig.providerConfig().baseUrl())
.modelName(chatModelConfig.modelId()) .modelName(chatModelConfig.modelId())
@ -300,6 +305,10 @@ class Langchain4jChatModelConfigurerImpl implements Langchain4jChatModelConfigur
return builder.build(); return builder.build();
} }
private static void validateBaseUrl(String url) {
SsrfProtectionValidator.validateUri(URI.create(url));
}
private static Duration toDuration(Integer timeoutSeconds) { private static Duration toDuration(Integer timeoutSeconds) {
return timeoutSeconds != null ? Duration.ofSeconds(timeoutSeconds) : null; return timeoutSeconds != null ? Duration.ofSeconds(timeoutSeconds) : null;
} }

1
application/src/main/java/org/thingsboard/server/service/cf/ctx/state/alarm/AlarmCalculatedFieldState.java

@ -179,6 +179,7 @@ public class AlarmCalculatedFieldState extends BaseCalculatedFieldState {
ruleState.setActive(null); ruleState.setActive(null);
AlarmCondition condition = rule.getCondition(); AlarmCondition condition = rule.getCondition();
if (condition.hasSchedule() || (condition.getType() == AlarmConditionType.DURATION && !ruleState.isEmpty())) { if (condition.hasSchedule() || (condition.getType() == AlarmConditionType.DURATION && !ruleState.isEmpty())) {
ruleState.cancelDurationCheckFuture();
reevalNeeded.set(true); reevalNeeded.set(true);
} }
} }

12
application/src/main/java/org/thingsboard/server/service/cf/ctx/state/alarm/AlarmRuleState.java

@ -256,10 +256,7 @@ public class AlarmRuleState {
firstEventTs = 0L; firstEventTs = 0L;
lastCheckTs = 0L; lastCheckTs = 0L;
duration = 0L; duration = 0L;
if (durationCheckFuture != null) { cancelDurationCheckFuture();
durationCheckFuture.cancel(true);
durationCheckFuture = null;
}
} }
public void setDurationCheckFuture(ScheduledFuture<?> durationCheckFuture) { public void setDurationCheckFuture(ScheduledFuture<?> durationCheckFuture) {
@ -270,6 +267,13 @@ public class AlarmRuleState {
this.durationCheckFuture = durationCheckFuture; this.durationCheckFuture = durationCheckFuture;
} }
public void cancelDurationCheckFuture() {
if (durationCheckFuture != null) {
durationCheckFuture.cancel(true);
durationCheckFuture = null;
}
}
public boolean isEmpty() { public boolean isEmpty() {
return eventCount == 0L && firstEventTs == 0L && lastCheckTs == 0L && durationCheckFuture == null; return eventCount == 0L && firstEventTs == 0L && lastCheckTs == 0L && durationCheckFuture == null;
} }

69
application/src/test/java/org/thingsboard/server/controller/AiModelControllerTest.java

@ -19,8 +19,13 @@ import com.datastax.oss.driver.api.core.uuid.Uuids;
import com.fasterxml.jackson.core.type.TypeReference; import com.fasterxml.jackson.core.type.TypeReference;
import org.junit.Test; import org.junit.Test;
import org.springframework.test.web.servlet.ResultActions; 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.EntityType;
import org.thingsboard.server.common.data.ai.AiModel; 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.AnthropicChatModelConfig;
import org.thingsboard.server.common.data.ai.model.chat.GoogleAiGeminiChatModelConfig; import org.thingsboard.server.common.data.ai.model.chat.GoogleAiGeminiChatModelConfig;
import org.thingsboard.server.common.data.ai.model.chat.OpenAiChatModelConfig; import org.thingsboard.server.common.data.ai.model.chat.OpenAiChatModelConfig;
@ -34,6 +39,8 @@ import org.thingsboard.server.common.data.page.PageLink;
import org.thingsboard.server.common.data.page.SortOrder; import org.thingsboard.server.common.data.page.SortOrder;
import org.thingsboard.server.dao.service.DaoSqlTest; import org.thingsboard.server.dao.service.DaoSqlTest;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThat;
import static org.hamcrest.Matchers.equalTo; import static org.hamcrest.Matchers.equalTo;
import static org.hamcrest.Matchers.is; import static org.hamcrest.Matchers.is;
@ -136,6 +143,68 @@ public class AiModelControllerTest extends AbstractControllerTest {
assertThat(updatedModel.getExternalId()).isNull(); 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);
}
}
@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 = doPostAsync("/api/ai/model/chat", chatRequest, TbChatResponse.class, status().isOk());
// 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 --- */ /* --- Get by ID API tests --- */
@Test @Test

78
application/src/test/java/org/thingsboard/server/service/ai/Langchain4jChatModelConfigurerImplTest.java

@ -17,13 +17,25 @@ package org.thingsboard.server.service.ai;
import com.google.cloud.vertexai.api.GenerationConfig; import com.google.cloud.vertexai.api.GenerationConfig;
import dev.langchain4j.model.chat.ChatModel; import dev.langchain4j.model.chat.ChatModel;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.parallel.ResourceLock;
import org.springframework.test.util.ReflectionTestUtils; import org.springframework.test.util.ReflectionTestUtils;
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.GoogleVertexAiGeminiChatModelConfig; import org.thingsboard.server.common.data.ai.model.chat.GoogleVertexAiGeminiChatModelConfig;
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.GoogleVertexAiGeminiProviderConfig; import org.thingsboard.server.common.data.ai.provider.GoogleVertexAiGeminiProviderConfig;
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.assertThat; import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
@ResourceLock("SsrfProtectionValidator")
class Langchain4jChatModelConfigurerImplTest { class Langchain4jChatModelConfigurerImplTest {
private static final String TEST_SERVICE_ACCOUNT_KEY = """ private static final String TEST_SERVICE_ACCOUNT_KEY = """
@ -41,6 +53,72 @@ class Langchain4jChatModelConfigurerImplTest {
private final Langchain4jChatModelConfigurerImpl configurer = new Langchain4jChatModelConfigurerImpl(); 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");
}
@Test @Test
void configureChatModel_vertexAi_setsFrequencyAndPresencePenaltyFromCorrectConfigFields() { void configureChatModel_vertexAi_setsFrequencyAndPresencePenaltyFromCorrectConfigFields() {
// GIVEN // GIVEN

2
common/data/src/main/java/org/thingsboard/server/common/data/notification/rule/NotificationRuleRecipientsConfig.java

@ -48,7 +48,7 @@ import java.util.UUID;
@DiscriminatorMapping(value = "RESOURCES_SHORTAGE", schema = DefaultNotificationRuleRecipientsConfig.ResourceShortageRecipientsConfig.class) @DiscriminatorMapping(value = "RESOURCES_SHORTAGE", schema = DefaultNotificationRuleRecipientsConfig.ResourceShortageRecipientsConfig.class)
}) })
@JsonIgnoreProperties(ignoreUnknown = true) @JsonIgnoreProperties(ignoreUnknown = true)
@JsonTypeInfo(use = JsonTypeInfo.Id.NAME, property = "triggerType", include = JsonTypeInfo.As.EXISTING_PROPERTY) @JsonTypeInfo(use = JsonTypeInfo.Id.NAME, property = "triggerType", include = JsonTypeInfo.As.EXISTING_PROPERTY, defaultImpl = DefaultNotificationRuleRecipientsConfig.class)
@JsonSubTypes({ @JsonSubTypes({
@Type(name = "ALARM", value = EscalatedNotificationRuleRecipientsConfig.class), @Type(name = "ALARM", value = EscalatedNotificationRuleRecipientsConfig.class),
@Type(name = "ENTITY_ACTION", value = DefaultNotificationRuleRecipientsConfig.EntityActionRecipientsConfig.class), @Type(name = "ENTITY_ACTION", value = DefaultNotificationRuleRecipientsConfig.EntityActionRecipientsConfig.class),

2
common/data/src/main/java/org/thingsboard/server/common/data/sync/ie/EntityExportData.java

@ -50,7 +50,7 @@ import java.util.List;
import java.util.Map; import java.util.Map;
@JsonIgnoreProperties(ignoreUnknown = true) @JsonIgnoreProperties(ignoreUnknown = true)
@JsonTypeInfo(use = JsonTypeInfo.Id.NAME, property = "entityType", include = As.EXISTING_PROPERTY, visible = true) @JsonTypeInfo(use = JsonTypeInfo.Id.NAME, property = "entityType", include = As.EXISTING_PROPERTY, visible = true, defaultImpl = EntityExportData.class)
@JsonSubTypes({ @JsonSubTypes({
@Type(name = "DEVICE", value = DeviceExportData.class), @Type(name = "DEVICE", value = DeviceExportData.class),
@Type(name = "RULE_CHAIN", value = RuleChainExportData.class), @Type(name = "RULE_CHAIN", value = RuleChainExportData.class),

2
common/script/script-api/src/main/java/org/thingsboard/script/api/tbel/TbelCfTsRollingArg.java

@ -73,7 +73,7 @@ public class TbelCfTsRollingArg implements TbelCfArg, Iterable<TbelCfTsDoubleVal
throw new IllegalArgumentException("Rolling argument values are empty."); throw new IllegalArgumentException("Rolling argument values are empty.");
} }
double max = Double.MIN_VALUE; double max = -Double.MAX_VALUE;
for (TbelCfTsDoubleVal value : values) { for (TbelCfTsDoubleVal value : values) {
double val = value.getValue(); double val = value.getValue();
if (!ignoreNaN && Double.isNaN(val)) { if (!ignoreNaN && Double.isNaN(val)) {

13
common/script/script-api/src/test/java/org/thingsboard/script/api/tbel/TbelCfTsRollingArgTest.java

@ -57,6 +57,19 @@ public class TbelCfTsRollingArgTest {
assertThat(rollingArg.max(false)).isNaN(); assertThat(rollingArg.max(false)).isNaN();
} }
@Test
void testMaxOverAllNegativeValues() {
TbelCfTsRollingArg negativeArg = new TbelCfTsRollingArg(
new TbTimeWindow(ts - 30000, ts - 10),
List.of(
new TbelCfTsDoubleVal(ts - 10, -50.0),
new TbelCfTsDoubleVal(ts - 20, -100.0),
new TbelCfTsDoubleVal(ts - 30, -75.0)
)
);
assertThat(negativeArg.max()).isEqualTo(-50.0);
}
@Test @Test
void testMin() { void testMin() {
assertThat(rollingArg.min()).isEqualTo(2.0); assertThat(rollingArg.min()).isEqualTo(2.0);

51
common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/MqttTransportService.java

@ -78,22 +78,47 @@ public class MqttTransportService implements TbTransportService {
ResourceLeakDetector.setLevel(ResourceLeakDetector.Level.valueOf(leakDetectorLevel.toUpperCase())); ResourceLeakDetector.setLevel(ResourceLeakDetector.Level.valueOf(leakDetectorLevel.toUpperCase()));
log.info("Starting MQTT transport..."); log.info("Starting MQTT transport...");
bossGroup = new NioEventLoopGroup(bossGroupThreadCount); try {
workerGroup = new NioEventLoopGroup(workerGroupThreadCount); bossGroup = new NioEventLoopGroup(bossGroupThreadCount);
ServerBootstrap b = new ServerBootstrap(); workerGroup = new NioEventLoopGroup(workerGroupThreadCount);
b.group(bossGroup, workerGroup) ServerBootstrap b = new ServerBootstrap();
.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();
b.group(bossGroup, workerGroup) b.group(bossGroup, workerGroup)
.channel(NioServerSocketChannel.class) .channel(NioServerSocketChannel.class)
.childHandler(new MqttTransportServerInitializer(context, true)) .childHandler(new MqttTransportServerInitializer(context, false))
.childOption(ChannelOption.SO_KEEPALIVE, keepAlive); .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 (e instanceof InterruptedException) {
Thread.currentThread().interrupt();
}
try {
if (serverChannel != null) {
serverChannel.close().sync();
}
if (sslServerChannel != null) {
sslServerChannel.close().sync();
}
} catch (Exception suppressed) {
e.addSuppressed(suppressed);
} finally {
if (workerGroup != null) {
workerGroup.shutdownGracefully();
}
if (bossGroup != null) {
bossGroup.shutdownGracefully();
}
}
throw e;
} }
log.info("Mqtt transport started!"); log.info("Mqtt transport started!");
} }

111
common/transport/mqtt/src/test/java/org/thingsboard/server/transport/mqtt/MqttTransportServiceTest.java

@ -0,0 +1,111 @@
/**
* 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.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;
import static org.mockito.Mockito.mock;
public class MqttTransportServiceTest {
private static final String HOST = "127.0.0.1";
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", mock(MqttTransportContext.class));
}
@AfterEach
public void tearDown() throws Exception {
if (occupiedSocket != null && !occupiedSocket.isClosed()) {
occupiedSocket.close();
}
}
@Test
public void whenPlainBindFails_thenInitThrowsAndReleasesNettyResources() {
assertThatThrownBy(() -> service.init())
.isInstanceOf(BindException.class);
EventLoopGroup boss = (EventLoopGroup) ReflectionTestUtils.getField(service, "bossGroup");
EventLoopGroup worker = (EventLoopGroup) ReflectionTestUtils.getField(service, "workerGroup");
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);
}
}

26
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 lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Component; 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.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.common.data.id.TenantId;
import org.thingsboard.server.dao.ai.AiModelDao; import org.thingsboard.server.dao.ai.AiModelDao;
import org.thingsboard.server.exception.DataValidationException; import org.thingsboard.server.exception.DataValidationException;
import org.thingsboard.server.dao.service.DataValidator; import org.thingsboard.server.dao.service.DataValidator;
import org.thingsboard.server.dao.tenant.TenantService; import org.thingsboard.server.dao.tenant.TenantService;
import java.net.URI;
import java.util.Optional; import java.util.Optional;
@Component @Component
@ -64,6 +70,26 @@ class AiModelDataValidator extends DataValidator<AiModel> {
if (!tenantService.tenantExists(tenantId)) { if (!tenantService.tenantExists(tenantId)) {
throw new DataValidationException("AI model reference a non-existent tenant!"); 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());
}
}
}
} }
} }

4
dao/src/main/java/org/thingsboard/server/dao/sqlts/ts/TsKvRepository.java

@ -61,8 +61,10 @@ public interface TsKvRepository extends JpaRepository<TsKvEntity, TsKvCompositeK
@Param("startTs") long startTs, @Param("startTs") long startTs,
@Param("endTs") long endTs); @Param("endTs") long endTs);
// -1.7976931348623157E308 = -Double.MAX_VALUE — the most negative finite double, used as a "less than any value" sentinel for MAX.
// Double.MIN_VALUE is +4.9E-324 (smallest positive), which would beat any negative real value and corrupt MAX.
@Query("SELECT new TsKvEntity(MAX(COALESCE(tskv.longValue, -9223372036854775807)), " + @Query("SELECT new TsKvEntity(MAX(COALESCE(tskv.longValue, -9223372036854775807)), " +
"MAX(COALESCE(tskv.doubleValue, java.lang.Double.MIN_VALUE)), " + "MAX(COALESCE(tskv.doubleValue, -1.7976931348623157E308)), " +
"SUM(CASE WHEN tskv.longValue IS NULL THEN 0 ELSE 1 END), " + "SUM(CASE WHEN tskv.longValue IS NULL THEN 0 ELSE 1 END), " +
"SUM(CASE WHEN tskv.doubleValue IS NULL THEN 0 ELSE 1 END), " + "SUM(CASE WHEN tskv.doubleValue IS NULL THEN 0 ELSE 1 END), " +
"'MAX', MAX(tskv.ts)) FROM TsKvEntity tskv " + "'MAX', MAX(tskv.ts)) FROM TsKvEntity tskv " +

2
dao/src/main/java/org/thingsboard/server/dao/timeseries/AggregatePartitionsFunction.java

@ -277,7 +277,7 @@ public class AggregatePartitionsFunction implements com.google.common.util.concu
private Optional<TsKvEntry> processMinOrMaxResult(AggregationResult aggResult) { private Optional<TsKvEntry> processMinOrMaxResult(AggregationResult aggResult) {
if (aggResult.dataType == DataType.DOUBLE || aggResult.dataType == DataType.LONG) { if (aggResult.dataType == DataType.DOUBLE || aggResult.dataType == DataType.LONG) {
if (aggResult.hasDouble) { 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); 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)))); return Optional.of(new BasicTsKvEntry(ts, new DoubleDataEntry(key, aggregation == Aggregation.MIN ? Math.min(currentD, currentL) : Math.max(currentD, currentL))));
} else { } else {

25
dao/src/test/java/org/thingsboard/server/dao/service/timeseries/BaseTimeseriesServiceTest.java

@ -710,6 +710,31 @@ public abstract class BaseTimeseriesServiceTest extends AbstractServiceTest {
assertEquals(java.util.Optional.of(2L), list.get(2).getLongValue()); 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<TsKvEntry> 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 testFindDeviceMaxAggregationOverAllNegativeDoubleTsData() throws Exception {
save(deviceId, 5000, -50.0);
save(deviceId, 15000, -100.0);
save(deviceId, 25000, -75.0);
List<TsKvEntry> 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 @Test
public void testSaveTs_RemoveTs_AndSaveTsAgain() throws Exception { public void testSaveTs_RemoveTs_AndSaveTsAgain() throws Exception {
save(deviceId, 2000000L, 95); save(deviceId, 2000000L, 95);

25
msa/js-executor/pom.xml

@ -52,6 +52,29 @@
<type>exe</type> <type>exe</type>
<scope>provided</scope> <scope>provided</scope>
</dependency> </dependency>
<!--
Reactor-only ordering dep (NOT a real classpath dependency).
Forces `mvn -T<n>` to serialize this module after web-ui so that
no two `yarn install` / `yarn run pkg` invocations can overlap on
the same agent. Concurrent yarn 1.x processes share ~/.cache/yarn
and have intermittently produced `tsc: not found` failures during
yarn pkg (incomplete typescript extraction in node_modules).
The chain is: ui-ngx -> msa/web-ui -> msa/js-executor.
type=pom + provided + wildcard exclusions keep nothing on the classpath.
-->
<dependency>
<groupId>org.thingsboard.msa</groupId>
<artifactId>web-ui</artifactId>
<version>${project.version}</version>
<type>pom</type>
<scope>provided</scope>
<exclusions>
<exclusion>
<groupId>*</groupId>
<artifactId>*</artifactId>
</exclusion>
</exclusions>
</dependency>
</dependencies> </dependencies>
<build> <build>
@ -90,7 +113,7 @@
</goals> </goals>
<phase>compile</phase> <phase>compile</phase>
<configuration> <configuration>
<arguments>run pkg</arguments> <arguments>--mutex network run pkg</arguments>
</configuration> </configuration>
</execution> </execution>
</executions> </executions>

11
msa/pom.xml

@ -44,7 +44,16 @@
</properties> </properties>
<modules> <modules>
<!--Modules order is important to speedup parallel build and avoid yarn pgk parallel execution--> <!--
Module order below is informational only. Yarn-using modules
(web-ui, js-executor) are serialized via reactor-only
<dependency> entries in their own poms, forming the chain
ui-ngx -> web-ui -> js-executor. This prevents
concurrent yarn install / yarn run pkg invocations under `mvn -T<n>`,
which previously caused intermittent `tsc: not found` failures
(incomplete typescript extraction in node_modules from racing
yarn 1.x processes against the shared ~/.cache/yarn).
-->
<module>tb</module> <module>tb</module>
<module>web-ui</module> <module>web-ui</module>
<module>vc-executor</module> <module>vc-executor</module>

2
msa/web-ui/pom.xml

@ -99,7 +99,7 @@
</goals> </goals>
<phase>compile</phase> <phase>compile</phase>
<configuration> <configuration>
<arguments>run pkg</arguments> <arguments>--mutex network run pkg</arguments>
</configuration> </configuration>
</execution> </execution>
</executions> </executions>

47
pom.xml

@ -63,9 +63,13 @@
<pkg.unixLogFolder>/var/log/${pkg.name}</pkg.unixLogFolder> <pkg.unixLogFolder>/var/log/${pkg.name}</pkg.unixLogFolder>
<pkg.installFolder>/usr/share/${pkg.name}</pkg.installFolder> <pkg.installFolder>/usr/share/${pkg.name}</pkg.installFolder>
<thingsboard.client.version>4.3.1.2</thingsboard.client.version> <thingsboard.client.version>4.3.1.2</thingsboard.client.version>
<spring-boot.version>3.5.13</spring-boot.version> <spring-boot.version>3.5.14</spring-boot.version>
<tomcat.version>10.1.54</tomcat.version> <!-- to fix CVE-2026-34487, CVE-2026-34486, CVE-2026-34483. TODO: remove when fixed in spring-boot-dependencies --> <!-- TODO: remove spring-boot-test.version override and the matching dependencyManagement entries below
once Spring Boot 3.5.15+ is released with a fix for the ImportsContextCustomizer regression in 3.5.14
that causes "Duplicate spy definition" failures on legacy @SpyBean fields (see PR #15557). -->
<spring-boot-test.version>3.5.13</spring-boot-test.version>
<commons-lang3.version>3.18.0</commons-lang3.version> <!-- to fix CVE-2025-48924. TODO: remove when fixed in spring-boot-dependencies --> <commons-lang3.version>3.18.0</commons-lang3.version> <!-- to fix CVE-2025-48924. TODO: remove when fixed in spring-boot-dependencies -->
<postgresql.version>42.7.11</postgresql.version> <!-- to fix CVE-2026-42198. TODO: remove when fixed in spring-boot-dependencies -->
<javax.xml.bind-api.version>2.4.0-b180830.0359</javax.xml.bind-api.version> <javax.xml.bind-api.version>2.4.0-b180830.0359</javax.xml.bind-api.version>
<jjwt.version>0.12.5</jjwt.version> <jjwt.version>0.12.5</jjwt.version>
<rat.version>0.10</rat.version> <!-- unused --> <rat.version>0.10</rat.version> <!-- unused -->
@ -90,7 +94,7 @@
<protobuf.version>3.25.5</protobuf.version> <!-- A Major v4 does not support by the pubsub yet--> <protobuf.version>3.25.5</protobuf.version> <!-- A Major v4 does not support by the pubsub yet-->
<grpc.version>1.76.0</grpc.version> <grpc.version>1.76.0</grpc.version>
<tbel.version>1.2.9</tbel.version> <tbel.version>1.2.9</tbel.version>
<lombok.version>1.18.44</lombok.version> <!-- must be in sync with spring-boot-dependencies; needed for maven-compiler-plugin annotationProcessorPaths --> <lombok.version>1.18.46</lombok.version> <!-- must be in sync with spring-boot-dependencies; needed for maven-compiler-plugin annotationProcessorPaths -->
<paho.client.version>1.2.5</paho.client.version> <paho.client.version>1.2.5</paho.client.version>
<paho.mqttv5.client.version>1.2.5</paho.mqttv5.client.version> <paho.mqttv5.client.version>1.2.5</paho.mqttv5.client.version>
<os-maven-plugin.version>1.7.1</os-maven-plugin.version> <os-maven-plugin.version>1.7.1</os-maven-plugin.version>
@ -1002,25 +1006,6 @@
<dependencyManagement> <dependencyManagement>
<dependencies> <dependencies>
<!-- Temporary tomcat version override to fix CVE-2026-34487, CVE-2026-34486, CVE-2026-34483.
Must be declared before the spring-boot-dependencies BOM import to take precedence.
TODO: remove when fixed in spring-boot-dependencies -->
<dependency>
<groupId>org.apache.tomcat.embed</groupId>
<artifactId>tomcat-embed-core</artifactId>
<version>${tomcat.version}</version>
</dependency>
<dependency>
<groupId>org.apache.tomcat.embed</groupId>
<artifactId>tomcat-embed-el</artifactId>
<version>${tomcat.version}</version>
</dependency>
<dependency>
<groupId>org.apache.tomcat.embed</groupId>
<artifactId>tomcat-embed-websocket</artifactId>
<version>${tomcat.version}</version>
</dependency>
<!-- End of tomcat version override -->
<dependency> <dependency>
<groupId>org.springframework.boot</groupId> <groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-dependencies</artifactId> <artifactId>spring-boot-dependencies</artifactId>
@ -1273,6 +1258,19 @@
</exclusion> </exclusion>
</exclusions> </exclusions>
</dependency> </dependency>
<!-- TODO: remove these two pins once Spring Boot 3.5.15+ ships the fix for the
ImportsContextCustomizer regression in 3.5.14 (see PR #15557). Test artifacts are not
packaged in the runtime image, so pinning them does not affect the CVE fixes. -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-test</artifactId>
<version>${spring-boot-test.version}</version>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-test-autoconfigure</artifactId>
<version>${spring-boot-test.version}</version>
</dependency>
<dependency> <dependency>
<groupId>org.apache.kafka</groupId> <groupId>org.apache.kafka</groupId>
<artifactId>kafka-clients</artifactId> <artifactId>kafka-clients</artifactId>
@ -1351,6 +1349,11 @@
<artifactId>commons-lang3</artifactId> <artifactId>commons-lang3</artifactId>
<version>${commons-lang3.version}</version> <version>${commons-lang3.version}</version>
</dependency> </dependency>
<dependency>
<groupId>org.postgresql</groupId>
<artifactId>postgresql</artifactId>
<version>${postgresql.version}</version>
</dependency>
<dependency> <dependency>
<groupId>commons-io</groupId> <groupId>commons-io</groupId>
<artifactId>commons-io</artifactId> <artifactId>commons-io</artifactId>

2
ui-ngx/pom.xml

@ -106,7 +106,7 @@
<goal>yarn</goal> <goal>yarn</goal>
</goals> </goals>
<configuration> <configuration>
<arguments>run build:prod</arguments> <arguments>--mutex network run build:prod</arguments>
</configuration> </configuration>
</execution> </execution>
</executions> </executions>

9
ui-ngx/src/app/modules/home/components/widget/config/basic/basic-widget-config.module.ts

@ -150,6 +150,9 @@ import {
ValueStepperBasicConfigComponent ValueStepperBasicConfigComponent
} from '@home/components/widget/config/basic/rpc/value-stepper-basic-config.component'; } from '@home/components/widget/config/basic/rpc/value-stepper-basic-config.component';
import { MapBasicConfigComponent } from '@home/components/widget/config/basic/map/map-basic-config.component'; import { MapBasicConfigComponent } from '@home/components/widget/config/basic/map/map-basic-config.component';
import {
HtmlContainerBasicConfigComponent
} from '@home/components/widget/config/basic/html/html-container-basic-config.component';
@NgModule({ @NgModule({
declarations: [ declarations: [
@ -201,7 +204,8 @@ import { MapBasicConfigComponent } from '@home/components/widget/config/basic/ma
LabelValueCardBasicConfigComponent, LabelValueCardBasicConfigComponent,
UnreadNotificationBasicConfigComponent, UnreadNotificationBasicConfigComponent,
ScadaSymbolBasicConfigComponent, ScadaSymbolBasicConfigComponent,
MapBasicConfigComponent MapBasicConfigComponent,
HtmlContainerBasicConfigComponent
], ],
imports: [ imports: [
CommonModule, CommonModule,
@ -255,7 +259,8 @@ import { MapBasicConfigComponent } from '@home/components/widget/config/basic/ma
LabelCardBasicConfigComponent, LabelCardBasicConfigComponent,
LabelValueCardBasicConfigComponent, LabelValueCardBasicConfigComponent,
UnreadNotificationBasicConfigComponent, UnreadNotificationBasicConfigComponent,
MapBasicConfigComponent MapBasicConfigComponent,
HtmlContainerBasicConfigComponent
] ]
}) })
export class BasicWidgetConfigModule { export class BasicWidgetConfigModule {

20
ui-ngx/src/app/modules/home/components/widget/config/basic/html/html-container-basic-config.component.html

@ -0,0 +1,20 @@
<!--
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.
-->
<ng-container [formGroup]="htmlContainerWidgetConfigForm">
<tb-html-container-settings formControlName="settings"></tb-html-container-settings>
</ng-container>

62
ui-ngx/src/app/modules/home/components/widget/config/basic/html/html-container-basic-config.component.ts

@ -0,0 +1,62 @@
///
/// 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.
///
import { Component, HostBinding } from '@angular/core';
import { UntypedFormBuilder, UntypedFormGroup } from '@angular/forms';
import { Store } from '@ngrx/store';
import { AppState } from '@core/core.state';
import { BasicWidgetConfigComponent } from '@home/components/widget/config/widget-config.component.models';
import { WidgetConfigComponentData } from '@home/models/widget-component.models';
import { WidgetConfigComponent } from '@home/components/widget/widget-config.component';
import {
htmlContainerDefaultSettings,
HtmlContainerWidgetSettings
} from '@home/components/widget/lib/html/html-container-widget.models';
@Component({
selector: 'tb-html-container-basic-config',
templateUrl: './html-container-basic-config.component.html',
styleUrls: ['../basic-config.scss'],
standalone: false
})
export class HtmlContainerBasicConfigComponent extends BasicWidgetConfigComponent {
@HostBinding('style.height') height = '100%';
htmlContainerWidgetConfigForm: UntypedFormGroup;
constructor(protected store: Store<AppState>,
protected widgetConfigComponent: WidgetConfigComponent,
private fb: UntypedFormBuilder) {
super(store, widgetConfigComponent);
}
protected configForm(): UntypedFormGroup {
return this.htmlContainerWidgetConfigForm;
}
protected onConfigSet(configData: WidgetConfigComponentData) {
const settings: HtmlContainerWidgetSettings = {...htmlContainerDefaultSettings, ...(configData.config.settings || {})};
this.htmlContainerWidgetConfigForm = this.fb.group({
settings: [settings, []]
});
}
protected prepareOutputConfig(config: any): WidgetConfigComponentData {
this.widgetConfig.config.settings = {...(this.widgetConfig.config.settings || {}), ...config.settings};
return this.widgetConfig;
}
}

318
ui-ngx/src/app/modules/home/components/widget/lib/html/html-container-widget.component.ts

@ -0,0 +1,318 @@
///
/// 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.
///
import {
Component,
ElementRef,
Inject,
Injector,
Input,
OnInit,
Optional,
Type,
ViewChild,
ViewEncapsulation
} from '@angular/core';
import { WidgetContext } from '@home/models/widget-component.models';
import {
htmlContainerDefaultSettings,
HtmlContainerWidgetSettings,
HtmlContainerWidgetType,
WidgetContainerAngularFunction,
WidgetContainerPlainFunction
} from '@home/components/widget/lib/html/html-container-widget.models';
import { hashCode, isNotEmptyStr, parseTbFunction } from '@core/utils';
import { CompiledTbFunction, isNotEmptyTbFunction } from '@shared/models/js-function.models';
import { catchError, forkJoin, map, Observable, of, switchMap, throwError } from 'rxjs';
import cssjs from '@core/css/css';
import { SHARED_MODULE_TOKEN } from '@shared/components/tokens';
import { DynamicComponentFactoryService } from '@core/services/dynamic-component-factory.service';
import { HOME_COMPONENTS_MODULE_TOKEN, WIDGET_COMPONENTS_MODULE_TOKEN } from '@home/components/tokens';
import { ExceptionData } from '@shared/models/error.models';
import { UtilsService } from '@core/services/utils.service';
import {
flatModulesWithComponents,
ModulesWithComponents,
modulesWithComponentsToTypes,
ResourcesService
} from '@core/services/resources.service';
import { MODULES_MAP } from '@shared/models/constants';
import { IModulesMap } from '@modules/common/modules-map.models';
import { TbAnchorComponent } from '@shared/components/tb-anchor.component';
@Component({
selector: 'tb-html-container-widget',
template: '<div #container class="tb-absolute-fill"><tb-anchor #angularContainer></tb-anchor></div>' +
'@if (widgetErrorData) { <div class="tb-absolute-fill tb-widget-error">\n' +
' <span [innerHtml]="(\'Widget Error:<br/><br/>\' + widgetErrorData.message) | safe:\'html\'"></span>\n' +
'</div> }',
styles: '.tb-widget-error {\n' +
' display: flex;\n' +
' align-items: center;\n' +
' justify-content: center;\n' +
' background: rgba(255, 255, 255, .5);\n' +
'\n' +
' span {\n' +
' color: #f00;\n' +
' }\n' +
' }',
encapsulation: ViewEncapsulation.None,
standalone: false
})
export class HtmlContainerWidgetComponent implements OnInit {
@ViewChild('container', {static: true})
containerElmRef: ElementRef<HTMLElement>;
@ViewChild('angularContainer', {static: true})
angularContainer: TbAnchorComponent;
@Input()
ctx: WidgetContext;
private containerInstanceComponentType: Type<any>;
private settings: HtmlContainerWidgetSettings;
widgetErrorData: ExceptionData;
constructor(private elementRef: ElementRef<HTMLElement>,
@Optional() @Inject(MODULES_MAP) private modulesMap: IModulesMap,
@Inject(SHARED_MODULE_TOKEN) private sharedModule: Type<any>,
@Inject(WIDGET_COMPONENTS_MODULE_TOKEN) private widgetComponentsModule: Type<any>,
@Inject(HOME_COMPONENTS_MODULE_TOKEN) private homeComponentsModule: Type<any>,
private dynamicComponentFactoryService: DynamicComponentFactoryService,
private utils: UtilsService,
private resources: ResourcesService) {}
ngOnInit(): void {
this.settings = {...htmlContainerDefaultSettings, ...(this.ctx.settings || {})};
this.loadWidgetResources().subscribe(
{
next: () => {
if (this.settings.type === HtmlContainerWidgetType.PLAIN) {
this.initPlain();
} else if (this.settings.type === HtmlContainerWidgetType.ANGULAR) {
this.initAngular();
}
},
error: (e) => {
this.handleWidgetException(e);
}
}
);
}
private initPlain(): void {
try {
if (isNotEmptyStr(this.settings.css)) {
const cssParser = new cssjs();
cssParser.testMode = false;
const namespace = 'html-container-' + hashCode(this.settings.css);
cssParser.cssPreviewNamespace = namespace;
cssParser.createStyleElement(namespace, this.settings.css);
$(this.elementRef.nativeElement).addClass(namespace);
}
if (isNotEmptyStr(this.settings.html)) {
$(this.containerElmRef.nativeElement).html(this.settings.html);
}
this.compileAndExecutePlainFunction();
} catch (e) {
this.handleWidgetException(e);
}
}
private compileAndExecutePlainFunction(): void {
if (isNotEmptyTbFunction(this.settings.js)) {
const jsFunction: Observable<CompiledTbFunction<WidgetContainerPlainFunction>> = parseTbFunction(this.ctx.http, this.settings.js, ['ctx', 'container']);
jsFunction.subscribe({
next: (containerFunction) => {
try {
containerFunction.execute(this.ctx, this.containerElmRef.nativeElement);
} catch (e) {
this.handleWidgetException(e);
}
},
error: (e) => {
this.handleWidgetException(e);
}
});
}
}
private initAngular(): void {
this.loadAngularModules().subscribe(
{
next: (imports) => {
this.compileAngularFunction().subscribe(
{
next: (containerFunction) => {
try {
this.initAngularComponent(imports, containerFunction);
} catch (e) {
this.handleWidgetException(e);
}
},
error: (e) => {
this.handleWidgetException(e);
}
}
);
},
error: (e) => {
this.handleWidgetException(e);
}
}
);
}
private compileAngularFunction(): Observable<CompiledTbFunction<WidgetContainerAngularFunction>> {
if (isNotEmptyTbFunction(this.settings.js)) {
return parseTbFunction(this.ctx.http, this.settings.js, ['ctx']);
} else {
return of(null);
}
}
private initAngularComponent(imports?: Type<any>[], containerFunction?: CompiledTbFunction<WidgetContainerAngularFunction>): void {
this.angularContainer.viewContainerRef.clear();
const destroyContainerInstanceResources = this.destroyContainerInstanceResources.bind(this);
const template = this.settings.html || '';
const styles: string[] = [];
if (isNotEmptyStr(this.settings.css)) {
styles.push(this.settings.css);
}
let compileModules = [this.sharedModule, this.widgetComponentsModule, this.homeComponentsModule];
if (imports && imports.length) {
compileModules = compileModules.concat(imports);
}
const self = () => this;
this.dynamicComponentFactoryService.createDynamicComponent(
class TbContainerInstance {
ngOnInit(): void {
if (containerFunction) {
const instance = self();
try {
containerFunction.apply(this, [instance.ctx]);
} catch (e) {
instance.handleWidgetException(e);
}
}
}
ngOnDestroy(): void {
destroyContainerInstanceResources();
}
},
template,
compileModules,
true, styles
).subscribe({
next: (componentType) => {
this.containerInstanceComponentType = componentType;
const injector: Injector = Injector.create({providers: [], parent: this.angularContainer.viewContainerRef.injector});
try {
this.angularContainer.viewContainerRef.createComponent(this.containerInstanceComponentType,
{index: 0, injector});
} catch (error) {
this.handleWidgetException(error);
}
},
error: (e) => {
this.handleWidgetException(e);
}
});
}
private destroyContainerInstanceResources() {
if (this.containerInstanceComponentType) {
this.dynamicComponentFactoryService.destroyDynamicComponent(this.containerInstanceComponentType);
this.containerInstanceComponentType = null;
}
}
private handleWidgetException(e: any) {
console.error(e);
this.widgetErrorData = this.utils.processWidgetException(e);
this.ctx.detectChanges();
}
private loadWidgetResources(): Observable<any> {
const resourceTasks: Observable<string>[] = [];
this.settings.resources.filter(r => !r.isModule).forEach(
(resource) => {
resourceTasks.push(
this.resources.loadResource(resource.url).pipe(
catchError(() => of(`Failed to load widget resource: '${resource.url}'`))
)
);
}
);
if (resourceTasks.length) {
return forkJoin(resourceTasks).pipe(
switchMap(msgs => {
let errors: string[];
if (msgs && msgs.length) {
errors = msgs.filter(msg => msg && msg.length > 0);
}
if (errors && errors.length) {
return throwError(() => new Error(errors.join('<br/>')));
} else {
return of(null);
}
}
));
} else {
return of(null);
}
}
private loadAngularModules(): Observable<Type<any>[]> {
const modulesTasks: Observable<ModulesWithComponents | string>[] = [];
this.settings.resources.filter(r => r.isModule).forEach(
(resource) => {
modulesTasks.push(
this.resources.loadModulesWithComponents(resource.url, this.modulesMap).pipe(
catchError((e: Error) => of(e?.message ? e.message : `Failed to load widget resource module: '${resource.url}'`))
)
);
}
);
if (modulesTasks.length) {
return forkJoin(modulesTasks).pipe(
map(res => {
const msg = res.find(r => typeof r === 'string');
if (msg) {
return msg as string;
} else {
const modulesWithComponentsList = res as ModulesWithComponents[];
return flatModulesWithComponents(modulesWithComponentsList);
}
}),
switchMap(modulesWithComponentsList => {
if (typeof modulesWithComponentsList === 'string') {
return throwError(() => new Error(modulesWithComponentsList));
} else {
const modules = modulesWithComponentsToTypes(modulesWithComponentsList);
return of(modules);
}
})
);
} else {
return of(null);
}
}
}

68
ui-ngx/src/app/modules/home/components/widget/lib/html/html-container-widget.models.ts

@ -0,0 +1,68 @@
///
/// 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.
///
import { TbFunction } from '@shared/models/js-function.models';
import { WidgetContext } from '@home/models/widget-component.models';
import { TbEditorCompleter, TbEditorCompletions } from '@shared/models/ace/completion.models';
import { widgetContextCompletions } from '@shared/models/ace/widget-completion.models';
import { WidgetResource } from '@shared/models/widget.models';
export enum HtmlContainerWidgetType {
PLAIN = 'PLAIN',
ANGULAR = 'ANGULAR'
}
export interface HtmlContainerWidgetSettings {
type: HtmlContainerWidgetType;
html: string;
css: string;
js: TbFunction;
resources: WidgetResource[];
}
export const htmlContainerDefaultSettings: HtmlContainerWidgetSettings = {
type: HtmlContainerWidgetType.PLAIN,
html: '',
css: '',
js: '',
resources: [],
};
export type WidgetContainerPlainFunction = (ctx: WidgetContext, container: HTMLElement) => void;
export type WidgetContainerAngularFunction = (ctx: WidgetContext) => void;
const containerFunctionCompletions: TbEditorCompletions = {
...{
ctx: {
meta: 'argument',
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'
}}
);

128
ui-ngx/src/app/modules/home/components/widget/lib/settings/common/html/html-container-settings.component.html

@ -0,0 +1,128 @@
<!--
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.
-->
<ng-container [formGroup]="htmlContainerSettingsForm">
<div class="tb-form-panel no-padding no-border relative h-full">
<div class="flex flex-row items-center">
<tb-toggle-select formControlName="type">
<tb-toggle-option [value]="HtmlContainerWidgetType.PLAIN">{{ 'widgets.html-container.type-plain' | translate }}</tb-toggle-option>
<tb-toggle-option [value]="HtmlContainerWidgetType.ANGULAR">{{ 'widgets.html-container.type-angular' | translate }}</tb-toggle-option>
</tb-toggle-select>
</div>
<div class="tb-html-container-settings-panel flex flex-1 flex-col" tb-fullscreen [fullscreen]="fullscreen">
<div class="tb-action-expand-button flex flex-row items-center justify-end">
<button mat-stroked-button
matTooltip="{{ 'widget.toggle-fullscreen' | translate }}"
matTooltipPosition="above"
(click)="toggleFullScreen()">
<mat-icon>{{ fullscreen ? 'fullscreen_exit' : 'fullscreen' }}</mat-icon>
<span>{{ (fullscreen ? 'fullscreen.exit' : 'fullscreen.fullscreen') | translate }}</span>
</button>
</div>
<div class="flex flex-1">
<mat-tab-group #leftPanel
[mat-stretch-tabs]="fullscreen"
[selectedIndex]="fullscreen ? 2 : 3"
[animationDuration]="tabsAnimationDuration"
[disablePagination]="fullscreen"
class="tb-content"
[class.flex-1]="!fullscreen">
<mat-tab #resourceTab="matTab">
<ng-template mat-tab-label>
<div [matBadge]="resourcesFormArray.length" [matBadgeHidden]="resourceTab.isActive || !resourcesFormArray.length"
matBadgeSize="small">{{ 'widgets.html-container.resources' | translate }}</div>
</ng-template>
<div class="flex flex-col gap-2 pt-4" [class.px-2]="fullscreen">
@if (resourcesFormArray.length) {
@for (resourceControl of resourcesControls; track resourceControl; let i = $index) {
<div class="tb-form-row no-border no-padding" [formGroup]="resourceControl">
<tb-resource-autocomplete class="flex-1"
formControlName="url"
inlineField
hideRequiredMarker required
[allowAutocomplete]="resourceControl.get('isModule').value && htmlContainerSettingsForm.get('type').value === HtmlContainerWidgetType.ANGULAR"
placeholder="{{ 'widget.resource-url' | translate }}">
</tb-resource-autocomplete>
@if (htmlContainerSettingsForm.get('type').value === HtmlContainerWidgetType.ANGULAR) {
<mat-checkbox formControlName="isModule">
{{ 'widget.resource-is-extension' | translate }}
</mat-checkbox>
}
<button mat-icon-button color="primary"
(click)="removeResource(i)"
matTooltip="{{'widget.remove-resource' | translate}}"
matTooltipPosition="above">
<mat-icon>delete</mat-icon>
</button>
</div>
}
} @else {
<span translate
class="tb-prompt flex items-center justify-center">widgets.html-container.no-resources</span>
}
<div>
<button mat-raised-button color="primary"
(click)="addResource()"
matTooltip="{{'widget.add-resource' | translate}}"
matTooltipPosition="above">
<span translate>action.add</span>
</button>
</div>
</div>
</mat-tab>
<mat-tab label="{{ 'widgets.html-container.css' | translate }}">
<tb-css class="flex-1"
[fillHeight]="true"
formControlName="css"
label="{{ 'widgets.html-container.css' | translate }}">
</tb-css>
</mat-tab>
<mat-tab label="{{ (htmlContainerSettingsForm.get('type').value === HtmlContainerWidgetType.ANGULAR ? 'widgets.html-container.angular-html-template' : 'widgets.html-container.html') | translate }}">
<tb-html class="flex-1"
[fillHeight]="true"
formControlName="html"
label="{{ (htmlContainerSettingsForm.get('type').value === HtmlContainerWidgetType.ANGULAR ? 'widgets.html-container.angular-html-template' : 'widgets.html-container.html') | translate }}">
</tb-html>
</mat-tab>
@if (!fullscreen) {
<mat-tab label="{{ 'widgets.html-container.java-script' | translate }}">
<ng-container *ngTemplateOutlet="javascript"></ng-container>
</mat-tab>
}
</mat-tab-group>
<div #rightPanel class="tb-content flex" [class.!hidden]="!fullscreen">
@if (fullscreen) {
<ng-container *ngTemplateOutlet="javascript"></ng-container>
}
</div>
</div>
</div>
</div>
</ng-container>
<ng-template #javascript>
<ng-container [formGroup]="htmlContainerSettingsForm">
<tb-js-func class="flex-1"
[fillHeight]="true"
formControlName="js"
[globalVariables]="functionScopeVariables"
[editorCompleter]="containerFunctionEditorCompleter"
[functionArgs]="htmlContainerSettingsForm.get('type').value === HtmlContainerWidgetType.ANGULAR ? ['ctx'] : ['ctx', 'container']"
withModules
functionTitle="{{ 'widgets.html-container.js-function' | translate }}">
</tb-js-func>
</ng-container>
</ng-template>

128
ui-ngx/src/app/modules/home/components/widget/lib/settings/common/html/html-container-settings.component.scss

@ -0,0 +1,128 @@
/**
* 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.
*/
.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%;
}
}
&.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;
}
}
}
}
}

237
ui-ngx/src/app/modules/home/components/widget/lib/settings/common/html/html-container-settings.component.ts

@ -0,0 +1,237 @@
///
/// 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.
///
import {
AfterViewInit,
Component,
DestroyRef,
ElementRef,
forwardRef,
HostBinding,
Input,
OnInit,
ViewChild,
ViewEncapsulation
} from '@angular/core';
import { WidgetResource } from '@shared/models/widget.models';
import {
ControlValueAccessor,
NG_VALIDATORS,
NG_VALUE_ACCESSOR,
UntypedFormArray,
UntypedFormBuilder,
UntypedFormControl,
UntypedFormGroup,
Validator,
Validators
} from '@angular/forms';
import {
AngularContainerFunctionEditorCompleter,
HTMLContainerFunctionEditorCompleter,
HtmlContainerWidgetSettings,
HtmlContainerWidgetType
} from '@home/components/widget/lib/html/html-container-widget.models';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { isJSResource } from '@shared/models/resource.models';
import { WidgetService } from '@core/http/widget.service';
@Component({
selector: 'tb-html-container-settings',
templateUrl: './html-container-settings.component.html',
styleUrls: ['./html-container-settings.component.scss'],
providers: [
{
provide: NG_VALUE_ACCESSOR,
useExisting: forwardRef(() => HtmlContainerSettingsComponent),
multi: true
},
{
provide: NG_VALIDATORS,
useExisting: forwardRef(() => HtmlContainerSettingsComponent),
multi: true,
}
],
encapsulation: ViewEncapsulation.None,
standalone: false
})
export class HtmlContainerSettingsComponent implements OnInit, AfterViewInit, ControlValueAccessor, Validator {
HtmlContainerWidgetType = HtmlContainerWidgetType;
functionScopeVariables = this.widgetService.getWidgetScopeVariables();
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,
private widgetService: WidgetService,
private destroyRef: DestroyRef) {
}
get resourcesFormArray(): UntypedFormArray {
return this.htmlContainerSettingsForm.get('resources') as UntypedFormArray;
}
get resourcesControls(): UntypedFormGroup[] {
return this.resourcesFormArray.controls as UntypedFormGroup[];
}
ngOnInit(): void {
this.htmlContainerSettingsForm = this.fb.group({
type: [null, []],
html: [null, []],
css: [null, []],
js: [null, []],
resources: this.fb.array([])
});
this.htmlContainerSettingsForm.get('type').valueChanges.pipe(
takeUntilDestroyed(this.destroyRef)
).subscribe(() => this.updateResources());
this.htmlContainerSettingsForm.valueChanges.pipe(
takeUntilDestroyed(this.destroyRef)
).subscribe(() => {
this.updateModel();
});
}
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 {
}
setDisabledState(isDisabled: boolean): void {
this.disabled = isDisabled;
if (isDisabled) {
this.htmlContainerSettingsForm.disable({emitEvent: false});
} else {
this.htmlContainerSettingsForm.enable({emitEvent: false});
}
}
writeValue(value: HtmlContainerWidgetSettings): void {
this.modelValue = value;
this.htmlContainerSettingsForm.get('type').patchValue(value.type, {emitEvent: false});
this.htmlContainerSettingsForm.get('html').patchValue(value.html, {emitEvent: false});
this.htmlContainerSettingsForm.get('css').patchValue(value.css, {emitEvent: false});
this.htmlContainerSettingsForm.get('js').patchValue(value.js, {emitEvent: false});
this.resourcesFormArray.clear({emitEvent: false});
value.resources.forEach(r => {
this.resourcesFormArray.push(this.buildResourceFormGroup(r), {emitEvent: false});
});
}
validate(_c: UntypedFormControl) {
return this.htmlContainerSettingsForm.valid ? null : {
htmlContainerSettings: {
valid: false,
}
};
}
addResource() {
const newResource: WidgetResource = {
url: '',
isModule: false
};
this.resourcesFormArray.push(this.buildResourceFormGroup(newResource));
}
removeResource(index: number) {
this.resourcesFormArray.removeAt(index);
}
toggleFullScreen(): void {
this.fullscreen = !this.fullscreen;
this.tabsAnimationDuration = '0ms';
setTimeout(() => {
this.tabsAnimationDuration = '500ms';
});
}
private propagateChange = (_v: any) => { };
private updateModel() {
this.modelValue = this.htmlContainerSettingsForm.value;
this.propagateChange(this.modelValue);
}
private updateResources() {
if (this.htmlContainerSettingsForm.get('type').value === HtmlContainerWidgetType.PLAIN) {
const resources: WidgetResource[] = this.resourcesFormArray.value;
const filtered = resources.filter(r => !isJSResource(r.url));
let updated = filtered.length !== resources.length;
filtered.forEach((r) => {
if (r.isModule) {
r.isModule = false;
updated = true;
}
});
if (updated) {
this.resourcesFormArray.clear();
filtered.forEach(r => {
this.resourcesFormArray.push(this.buildResourceFormGroup(r));
});
}
}
}
private buildResourceFormGroup(resource: WidgetResource): UntypedFormGroup {
return this.fb.group({
url: [resource.url, [Validators.required]],
isModule: [resource.isModule]
});
}
}

5
ui-ngx/src/app/modules/home/components/widget/lib/settings/common/widget-settings-common.module.ts

@ -267,6 +267,9 @@ import {
import { import {
ShapeFillStripeSettingsPanelComponent ShapeFillStripeSettingsPanelComponent
} from '@home/components/widget/lib/settings/common/map/shape-fill-stripe-settings-panel.component'; } from '@home/components/widget/lib/settings/common/map/shape-fill-stripe-settings-panel.component';
import {
HtmlContainerSettingsComponent
} from '@home/components/widget/lib/settings/common/html/html-container-settings.component';
import { AxisScaleRowComponent } from './axis-scale-row.component'; import { AxisScaleRowComponent } from './axis-scale-row.component';
@NgModule({ @NgModule({
@ -374,6 +377,7 @@ import { AxisScaleRowComponent } from './axis-scale-row.component';
DataKeyConfigDialogComponent, DataKeyConfigDialogComponent,
DataKeyConfigComponent, DataKeyConfigComponent,
WidgetSettingsComponent, WidgetSettingsComponent,
HtmlContainerSettingsComponent,
AxisScaleRowComponent AxisScaleRowComponent
], ],
imports: [ imports: [
@ -456,6 +460,7 @@ import { AxisScaleRowComponent } from './axis-scale-row.component';
DataKeyConfigDialogComponent, DataKeyConfigDialogComponent,
DataKeyConfigComponent, DataKeyConfigComponent,
WidgetSettingsComponent, WidgetSettingsComponent,
HtmlContainerSettingsComponent,
AxisScaleRowComponent AxisScaleRowComponent
], ],
providers: [ providers: [

2
ui-ngx/src/app/modules/home/components/widget/lib/settings/common/widget/widget-settings.component.html

@ -15,7 +15,7 @@
limitations under the License. limitations under the License.
--> -->
<div [formGroup]="widgetSettingsFormGroup"> <div [formGroup]="widgetSettingsFormGroup" class="h-full">
<ng-container #definedSettingsContent></ng-container> <ng-container #definedSettingsContent></ng-container>
<div class="tb-settings-directive-error" *ngIf="definedDirectiveError">{{definedDirectiveError}}</div> <div class="tb-settings-directive-error" *ngIf="definedDirectiveError">{{definedDirectiveError}}</div>
<tb-dynamic-form *ngIf="useDynamicForm()" <tb-dynamic-form *ngIf="useDynamicForm()"

20
ui-ngx/src/app/modules/home/components/widget/lib/settings/html/html-container-widget-settings.component.html

@ -0,0 +1,20 @@
<!--
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.
-->
<ng-container [formGroup]="htmlContainerWidgetSettingsForm">
<tb-html-container-settings formControlName="htmlContainerSettings"></tb-html-container-settings>
</ng-container>

65
ui-ngx/src/app/modules/home/components/widget/lib/settings/html/html-container-widget-settings.component.ts

@ -0,0 +1,65 @@
///
/// 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.
///
import { Component, HostBinding } from '@angular/core';
import { WidgetSettings, WidgetSettingsComponent } from '@shared/models/widget.models';
import { UntypedFormBuilder, UntypedFormGroup } from '@angular/forms';
import { Store } from '@ngrx/store';
import { AppState } from '@core/core.state';
import { htmlContainerDefaultSettings } from '@home/components/widget/lib/html/html-container-widget.models';
@Component({
selector: 'tb-html-container-widget-settings',
templateUrl: './html-container-widget-settings.component.html',
styleUrls: [],
standalone: false
})
export class HtmlContainerWidgetSettingsComponent extends WidgetSettingsComponent {
@HostBinding('height')
hostHeight = '100%';
htmlContainerWidgetSettingsForm: UntypedFormGroup;
constructor(protected store: Store<AppState>,
private fb: UntypedFormBuilder) {
super(store);
}
protected settingsForm(): UntypedFormGroup {
return this.htmlContainerWidgetSettingsForm;
}
protected defaultSettings(): WidgetSettings {
return htmlContainerDefaultSettings;
}
protected onSettingsSet(settings: WidgetSettings) {
this.htmlContainerWidgetSettingsForm = this.fb.group({
htmlContainerSettings: [settings.htmlContainerSettings, []]
});
}
protected prepareInputSettings(settings: WidgetSettings): WidgetSettings {
return {
htmlContainerSettings: settings
};
}
protected prepareOutputSettings(settings: any): WidgetSettings {
return settings.htmlContainerSettings;
}
}

5
ui-ngx/src/app/modules/home/components/widget/lib/settings/widget-settings.module.ts

@ -375,6 +375,9 @@ import {
ValueStepperWidgetSettingsComponent ValueStepperWidgetSettingsComponent
} from '@home/components/widget/lib/settings/control/value-stepper-widget-settings.component'; } from '@home/components/widget/lib/settings/control/value-stepper-widget-settings.component';
import { MapWidgetSettingsComponent } from '@home/components/widget/lib/settings/map/map-widget-settings.component'; import { MapWidgetSettingsComponent } from '@home/components/widget/lib/settings/map/map-widget-settings.component';
import {
HtmlContainerWidgetSettingsComponent
} from '@home/components/widget/lib/settings/html/html-container-widget-settings.component';
import { import {
ApiUsageWidgetSettingsComponent ApiUsageWidgetSettingsComponent
} from "@home/components/widget/lib/settings/cards/api-usage-widget-settings.component"; } from "@home/components/widget/lib/settings/cards/api-usage-widget-settings.component";
@ -515,6 +518,7 @@ import {
UnreadNotificationWidgetSettingsComponent, UnreadNotificationWidgetSettingsComponent,
ScadaSymbolWidgetSettingsComponent, ScadaSymbolWidgetSettingsComponent,
MapWidgetSettingsComponent, MapWidgetSettingsComponent,
HtmlContainerWidgetSettingsComponent,
ApiUsageWidgetSettingsComponent, ApiUsageWidgetSettingsComponent,
ApiUsageDataKeyRowComponent ApiUsageDataKeyRowComponent
], ],
@ -656,6 +660,7 @@ import {
UnreadNotificationWidgetSettingsComponent, UnreadNotificationWidgetSettingsComponent,
ScadaSymbolWidgetSettingsComponent, ScadaSymbolWidgetSettingsComponent,
MapWidgetSettingsComponent, MapWidgetSettingsComponent,
HtmlContainerWidgetSettingsComponent,
ApiUsageWidgetSettingsComponent ApiUsageWidgetSettingsComponent
] ]
}) })

3
ui-ngx/src/app/modules/home/components/widget/widget-components.module.ts

@ -94,6 +94,7 @@ import {
SelectMapEntityPanelComponent SelectMapEntityPanelComponent
} from '@home/components/widget/lib/maps/panels/select-map-entity-panel.component'; } from '@home/components/widget/lib/maps/panels/select-map-entity-panel.component';
import { MapTimelinePanelComponent } from '@home/components/widget/lib/maps/panels/map-timeline-panel.component'; import { MapTimelinePanelComponent } from '@home/components/widget/lib/maps/panels/map-timeline-panel.component';
import { HtmlContainerWidgetComponent } from '@home/components/widget/lib/html/html-container-widget.component';
import { ApiUsageWidgetComponent } from "@home/components/widget/lib/cards/api-usage-widget.component"; import { ApiUsageWidgetComponent } from "@home/components/widget/lib/cards/api-usage-widget.component";
@NgModule({ @NgModule({
@ -153,6 +154,7 @@ import { ApiUsageWidgetComponent } from "@home/components/widget/lib/cards/api-u
SelectMapEntityPanelComponent, SelectMapEntityPanelComponent,
MapTimelinePanelComponent, MapTimelinePanelComponent,
MapWidgetComponent, MapWidgetComponent,
HtmlContainerWidgetComponent,
ApiUsageWidgetComponent ApiUsageWidgetComponent
], ],
imports: [ imports: [
@ -217,6 +219,7 @@ import { ApiUsageWidgetComponent } from "@home/components/widget/lib/cards/api-u
NotificationTypeFilterPanelComponent, NotificationTypeFilterPanelComponent,
ScadaSymbolWidgetComponent, ScadaSymbolWidgetComponent,
MapWidgetComponent, MapWidgetComponent,
HtmlContainerWidgetComponent,
ApiUsageWidgetComponent ApiUsageWidgetComponent
], ],
providers: [ providers: [

2
ui-ngx/src/app/modules/home/components/widget/widget-config.component.html

@ -322,7 +322,7 @@
</mat-form-field> </mat-form-field>
</div> </div>
</div> </div>
<div *ngIf="displayAdvancedAppearance" style="height: 100%;" [formGroup]="advancedSettings"> <div *ngIf="displayAdvancedAppearance" class="flex-1" [formGroup]="advancedSettings">
<tb-widget-settings <tb-widget-settings
[aliasController]="aliasController" [aliasController]="aliasController"
[callbacks]="widgetConfigCallbacks" [callbacks]="widgetConfigCallbacks"

1
ui-ngx/src/app/modules/home/components/widget/widget-config.component.scss

@ -43,6 +43,7 @@
} }
.tb-widget-config-content { .tb-widget-config-content {
& > .mat-content { & > .mat-content {
height: 100%;
padding-top: 8px; padding-top: 8px;
@media #{$mat-xs} { @media #{$mat-xs} {
padding-left: 8px; padding-left: 8px;

12
ui-ngx/src/assets/locale/locale.constant-en_US.json

@ -10140,6 +10140,18 @@
} }
} }
}, },
"html-container": {
"java-script": "JavaScript",
"js-function": "JavaScript function",
"html": "HTML",
"angular-html-template": "Angular HTML template",
"css": "CSS",
"container-type": "Container type",
"type-plain": "Plain HTML",
"type-angular": "Angular",
"resources": "Resources",
"no-resources": "No resources configured"
},
"api-usage": { "api-usage": {
"api-usage": "API usage", "api-usage": "API usage",
"label": "Label", "label": "Label",

Loading…
Cancel
Save