Browse Source

Merge remote-tracking branch 'upstream/lts-4.2' into feature/js-executor-lz4-compression

pull/15565/head
Volodymyr Babak 5 months ago
parent
commit
ff2b7d8333
  1. 2
      application/src/main/data/json/system/widget_types/html_container.json
  2. 8
      application/src/main/java/org/thingsboard/server/service/ai/AiChatModelServiceImpl.java
  3. 9
      application/src/main/java/org/thingsboard/server/service/ai/Langchain4jChatModelConfigurerImpl.java
  4. 6
      application/src/main/java/org/thingsboard/server/service/install/InstallScripts.java
  5. 131
      application/src/main/java/org/thingsboard/server/service/system/SystemPatchApplier.java
  6. 1
      application/src/main/resources/thingsboard.yml
  7. 69
      application/src/test/java/org/thingsboard/server/controller/AiModelControllerTest.java
  8. 78
      application/src/test/java/org/thingsboard/server/service/ai/Langchain4jChatModelConfigurerImplTest.java
  9. 105
      application/src/test/java/org/thingsboard/server/service/entitiy/EdqsEntityServiceTest.java
  10. 126
      application/src/test/java/org/thingsboard/server/service/entitiy/EntityServiceTest.java
  11. 84
      application/src/test/java/org/thingsboard/server/service/script/TbelInvokeServiceTest.java
  12. 296
      application/src/test/java/org/thingsboard/server/system/SystemPatchApplierTest.java
  13. 2
      common/script/script-api/src/main/java/org/thingsboard/script/api/tbel/TbelCfTsRollingArg.java
  14. 13
      common/script/script-api/src/test/java/org/thingsboard/script/api/tbel/TbelCfTsRollingArgTest.java
  15. 51
      common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/MqttTransportService.java
  16. 111
      common/transport/mqtt/src/test/java/org/thingsboard/server/transport/mqtt/MqttTransportServiceTest.java
  17. 26
      dao/src/main/java/org/thingsboard/server/dao/service/validator/AiModelDataValidator.java
  18. 35
      dao/src/main/java/org/thingsboard/server/dao/sql/query/DefaultEntityQueryRepository.java
  19. 4
      dao/src/main/java/org/thingsboard/server/dao/sql/query/EntityKeyMapping.java
  20. 4
      dao/src/main/java/org/thingsboard/server/dao/sqlts/ts/TsKvRepository.java
  21. 2
      dao/src/main/java/org/thingsboard/server/dao/timeseries/AggregatePartitionsFunction.java
  22. 25
      dao/src/test/java/org/thingsboard/server/dao/service/timeseries/BaseTimeseriesServiceTest.java
  23. 25
      msa/js-executor/pom.xml
  24. 11
      msa/pom.xml
  25. 2
      msa/web-ui/pom.xml
  26. 49
      pom.xml
  27. 2
      ui-ngx/pom.xml
  28. 15
      ui-ngx/src/app/modules/home/components/widget/lib/html/html-container-widget.models.ts
  29. 162
      ui-ngx/src/app/modules/home/components/widget/lib/settings/common/html/html-container-settings.component.html
  30. 114
      ui-ngx/src/app/modules/home/components/widget/lib/settings/common/html/html-container-settings.component.scss
  31. 63
      ui-ngx/src/app/modules/home/components/widget/lib/settings/common/html/html-container-settings.component.ts
  32. 2
      ui-ngx/src/app/shared/import-export/import-export.models.ts
  33. 20
      ui-ngx/src/app/shared/models/page/page-link.ts

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

@ -11,7 +11,7 @@
"resources": [], "resources": [],
"templateHtml": "<tb-html-container-widget \n [ctx]=\"ctx\">\n</tb-html-container-widget>", "templateHtml": "<tb-html-container-widget \n [ctx]=\"ctx\">\n</tb-html-container-widget>",
"templateCss": "", "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", "settingsDirective": "tb-html-container-widget-settings",
"hasBasicMode": true, "hasBasicMode": true,
"basicModeDirective": "tb-html-container-basic-config", "basicModeDirective": "tb-html-container-basic-config",

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;
} }

6
application/src/main/java/org/thingsboard/server/service/install/InstallScripts.java

@ -135,6 +135,10 @@ public class InstallScripts {
return Paths.get(getDataDir(), JSON_DIR, SYSTEM_DIR, WIDGET_TYPES_DIR); return Paths.get(getDataDir(), JSON_DIR, SYSTEM_DIR, WIDGET_TYPES_DIR);
} }
public Path getWidgetBundlesDir() {
return Paths.get(getDataDir(), JSON_DIR, SYSTEM_DIR, WIDGET_BUNDLES_DIR);
}
public String getDataDir() { public String getDataDir() {
if (!StringUtils.isEmpty(dataDir)) { if (!StringUtils.isEmpty(dataDir)) {
if (!Paths.get(this.dataDir).toFile().isDirectory()) { if (!Paths.get(this.dataDir).toFile().isDirectory()) {
@ -207,7 +211,7 @@ public class InstallScripts {
public void loadSystemWidgets() { public void loadSystemWidgets() {
log.info("Loading system widgets"); log.info("Loading system widgets");
Map<Path, JsonNode> widgetsBundlesMap = new HashMap<>(); Map<Path, JsonNode> widgetsBundlesMap = new HashMap<>();
Path widgetBundlesDir = Paths.get(getDataDir(), JSON_DIR, SYSTEM_DIR, WIDGET_BUNDLES_DIR); Path widgetBundlesDir = getWidgetBundlesDir();
try (Stream<Path> dirStream = listDir(widgetBundlesDir).filter(path -> path.toString().endsWith(JSON_EXT))) { try (Stream<Path> dirStream = listDir(widgetBundlesDir).filter(path -> path.toString().endsWith(JSON_EXT))) {
dirStream.forEach( dirStream.forEach(
path -> { path -> {

131
application/src/main/java/org/thingsboard/server/service/system/SystemPatchApplier.java

@ -28,8 +28,10 @@ import org.thingsboard.common.util.JacksonUtil;
import org.thingsboard.common.util.ThingsBoardThreadFactory; import org.thingsboard.common.util.ThingsBoardThreadFactory;
import org.thingsboard.server.common.data.id.TenantId; import org.thingsboard.server.common.data.id.TenantId;
import org.thingsboard.server.common.data.widget.WidgetTypeDetails; import org.thingsboard.server.common.data.widget.WidgetTypeDetails;
import org.thingsboard.server.common.data.widget.WidgetsBundle;
import org.thingsboard.server.dao.resource.ImageService; import org.thingsboard.server.dao.resource.ImageService;
import org.thingsboard.server.dao.widget.WidgetTypeService; import org.thingsboard.server.dao.widget.WidgetTypeService;
import org.thingsboard.server.dao.widget.WidgetsBundleService;
import org.thingsboard.server.queue.util.TbCoreComponent; import org.thingsboard.server.queue.util.TbCoreComponent;
import org.thingsboard.server.service.install.DatabaseSchemaSettingsService; import org.thingsboard.server.service.install.DatabaseSchemaSettingsService;
import org.thingsboard.server.service.install.InstallScripts; import org.thingsboard.server.service.install.InstallScripts;
@ -42,6 +44,9 @@ import java.nio.file.Files;
import java.nio.file.NoSuchFileException; import java.nio.file.NoSuchFileException;
import java.nio.file.Path; import java.nio.file.Path;
import java.nio.file.Paths; import java.nio.file.Paths;
import java.util.ArrayList;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Objects; import java.util.Objects;
import java.util.Set; import java.util.Set;
import java.util.concurrent.ExecutorService; import java.util.concurrent.ExecutorService;
@ -67,6 +72,7 @@ public class SystemPatchApplier {
private final InstallScripts installScripts; private final InstallScripts installScripts;
private final DatabaseSchemaSettingsService schemaSettingsService; private final DatabaseSchemaSettingsService schemaSettingsService;
private final WidgetTypeService widgetTypeService; private final WidgetTypeService widgetTypeService;
private final WidgetsBundleService widgetsBundleService;
private final ImageService imageService; private final ImageService imageService;
@PostConstruct @PostConstruct
@ -100,8 +106,11 @@ public class SystemPatchApplier {
updateSqlViews(); updateSqlViews();
log.info("Updated sql database views"); log.info("Updated sql database views");
int updated = updateWidgetTypes(); WidgetTypeStats widgetStats = updateWidgetTypes();
log.info("Updated {} widget types", updated); log.info("System widget types: {} created, {} updated", widgetStats.created(), widgetStats.updated());
int updatedBundles = updateWidgetBundles();
log.info("System widget bundles: {} updated", updatedBundles);
int createdImages = createMissingSystemImages(); int createdImages = createMissingSystemImages();
log.info("Created {} new system images", createdImages); log.info("Created {} new system images", createdImages);
@ -171,20 +180,24 @@ public class SystemPatchApplier {
} }
} }
private int updateWidgetTypes() { private WidgetTypeStats updateWidgetTypes() {
AtomicInteger created = new AtomicInteger();
AtomicInteger updated = new AtomicInteger(); AtomicInteger updated = new AtomicInteger();
Path widgetTypesDir = installScripts.getWidgetTypesDir(); Path widgetTypesDir = installScripts.getWidgetTypesDir();
if (!Files.exists(widgetTypesDir)) { if (!Files.exists(widgetTypesDir)) {
log.trace("Widget types directory does not exist: {}", widgetTypesDir); log.trace("Widget types directory does not exist: {}", widgetTypesDir);
return 0; return new WidgetTypeStats(0, 0);
} }
try (Stream<Path> dirStream = listDir(widgetTypesDir).filter(path -> path.toString().endsWith(InstallScripts.JSON_EXT))) { try (Stream<Path> dirStream = listDir(widgetTypesDir).filter(path -> path.toString().endsWith(InstallScripts.JSON_EXT))) {
dirStream.forEach( dirStream.forEach(
path -> { path -> {
try { try {
if (updateWidgetTypeFromFile(path)) { WidgetTypeChange change = updateWidgetTypeFromFile(path);
if (change == WidgetTypeChange.CREATED) {
created.incrementAndGet();
} else if (change == WidgetTypeChange.UPDATED) {
updated.incrementAndGet(); updated.incrementAndGet();
} }
} catch (Exception e) { } catch (Exception e) {
@ -195,18 +208,26 @@ public class SystemPatchApplier {
); );
} }
return updated.get(); return new WidgetTypeStats(created.get(), updated.get());
} }
private boolean updateWidgetTypeFromFile(Path filePath) { private WidgetTypeChange updateWidgetTypeFromFile(Path filePath) {
JsonNode json = JacksonUtil.toJsonNode(filePath.toFile()); JsonNode json = JacksonUtil.toJsonNode(filePath.toFile());
WidgetTypeDetails fileWidgetType = JacksonUtil.treeToValue(json, WidgetTypeDetails.class); WidgetTypeDetails fileWidgetType = JacksonUtil.treeToValue(json, WidgetTypeDetails.class);
return saveOrUpdateSystemWidgetType(fileWidgetType);
}
private WidgetTypeChange saveOrUpdateSystemWidgetType(WidgetTypeDetails fileWidgetType) {
String fqn = fileWidgetType.getFqn(); String fqn = fileWidgetType.getFqn();
if (fqn == null || fqn.isBlank()) {
throw new RuntimeException("Widget type fqn is missing or blank: " + fileWidgetType.getName());
}
WidgetTypeDetails existingWidgetType = widgetTypeService.findWidgetTypeDetailsByTenantIdAndFqn(TenantId.SYS_TENANT_ID, fqn); WidgetTypeDetails existingWidgetType = widgetTypeService.findWidgetTypeDetailsByTenantIdAndFqn(TenantId.SYS_TENANT_ID, fqn);
if (existingWidgetType == null) { if (existingWidgetType == null) {
// We expect only update here, so it's probably never happening, but for test purpose leave it like this: widgetTypeService.saveWidgetType(fileWidgetType);
throw new RuntimeException("Widget type not found: " + fqn); log.trace("Created widget type: {}", fqn);
return WidgetTypeChange.CREATED;
} }
if (isWidgetTypeChanged(existingWidgetType, fileWidgetType)) { if (isWidgetTypeChanged(existingWidgetType, fileWidgetType)) {
existingWidgetType.setDescription(fileWidgetType.getDescription()); existingWidgetType.setDescription(fileWidgetType.getDescription());
@ -214,11 +235,95 @@ public class SystemPatchApplier {
existingWidgetType.setDescriptor(fileWidgetType.getDescriptor()); existingWidgetType.setDescriptor(fileWidgetType.getDescriptor());
widgetTypeService.saveWidgetType(existingWidgetType); widgetTypeService.saveWidgetType(existingWidgetType);
log.trace("Updated widget type: {}", fqn); log.trace("Updated widget type: {}", fqn);
return true; return WidgetTypeChange.UPDATED;
} }
log.trace("Widget type unchanged: {}", fqn); log.trace("Widget type unchanged: {}", fqn);
return false; return WidgetTypeChange.UNCHANGED;
}
private int updateWidgetBundles() {
AtomicInteger updated = new AtomicInteger();
Path widgetBundlesDir = installScripts.getWidgetBundlesDir();
if (!Files.exists(widgetBundlesDir)) {
log.trace("Widget bundles directory does not exist: {}", widgetBundlesDir);
return 0;
}
try (Stream<Path> dirStream = listDir(widgetBundlesDir).filter(path -> path.toString().endsWith(InstallScripts.JSON_EXT))) {
dirStream.forEach(path -> {
try {
if (processWidgetBundleFile(path)) {
updated.incrementAndGet();
}
} catch (Exception e) {
log.error("Unable to process widgets bundle from json: [{}]", path);
throw new RuntimeException("Unable to process widgets bundle from json", e);
}
});
}
return updated.get();
}
private boolean processWidgetBundleFile(Path filePath) {
JsonNode bundleJson = JacksonUtil.toJsonNode(filePath.toFile());
if (bundleJson == null || !bundleJson.has("widgetsBundle")) {
throw new RuntimeException("Invalid widgets bundle json: " + filePath);
}
WidgetsBundle fileBundle = JacksonUtil.treeToValue(bundleJson.get("widgetsBundle"), WidgetsBundle.class);
String alias = fileBundle.getAlias();
if (alias == null || alias.isBlank()) {
throw new RuntimeException("Widgets bundle alias is missing or blank: " + filePath);
}
WidgetsBundle existingBundle = widgetsBundleService.findWidgetsBundleByTenantIdAndAlias(TenantId.SYS_TENANT_ID, alias);
if (existingBundle == null) {
log.warn("Widgets bundle '{}' not found in DB; bundle creation is not supported by the patch applier, skipping.", alias);
return false;
}
if (bundleJson.has("widgetTypes")) {
throw new RuntimeException("Inline widgetTypes in bundle JSON are not supported by the patch applier; " +
"place widget definitions in widget_types/*.json and reference them via widgetTypeFqns: " + filePath);
}
List<String> fileWidgetFqns = new ArrayList<>();
if (bundleJson.has("widgetTypeFqns")) {
bundleJson.get("widgetTypeFqns").forEach(fqnJson -> fileWidgetFqns.add(fqnJson.asText()));
}
boolean changed = false;
if (isWidgetsBundleChanged(existingBundle, fileBundle)) {
existingBundle.setTitle(fileBundle.getTitle());
existingBundle.setDescription(fileBundle.getDescription());
existingBundle.setImage(fileBundle.getImage());
existingBundle.setOrder(fileBundle.getOrder());
existingBundle.setScada(fileBundle.isScada());
widgetsBundleService.saveWidgetsBundle(existingBundle);
log.trace("Updated widgets bundle metadata: {}", alias);
changed = true;
}
List<String> existingFqns = widgetTypeService.findWidgetFqnsByWidgetsBundleId(TenantId.SYS_TENANT_ID, existingBundle.getId());
LinkedHashSet<String> mergedFqns = new LinkedHashSet<>(existingFqns);
if (mergedFqns.addAll(fileWidgetFqns)) {
widgetTypeService.updateWidgetsBundleWidgetFqns(TenantId.SYS_TENANT_ID, existingBundle.getId(), new ArrayList<>(mergedFqns));
log.trace("Linked {} new widget fqn(s) to bundle: {}", mergedFqns.size() - existingFqns.size(), alias);
changed = true;
}
return changed;
}
private boolean isWidgetsBundleChanged(WidgetsBundle existing, WidgetsBundle file) {
// Image is intentionally NOT compared: the file always carries a base64 data URI, while the DB stores
// the system-image URL produced by ImageService.replaceBase64WithImageUrl on save. A naive string compare
// would always report a diff and re-save every system bundle on every patch run. Image content changes
// are out of scope for the patch applier — full reinstall covers them.
return !Objects.equals(existing.getTitle(), file.getTitle())
|| !Objects.equals(existing.getDescription(), file.getDescription())
|| !Objects.equals(existing.getOrder(), file.getOrder())
|| existing.isScada() != file.isScada();
} }
private int createMissingSystemImages() { private int createMissingSystemImages() {
@ -349,4 +454,8 @@ public class SystemPatchApplier {
public record VersionInfo(int major, int minor, int maintenance, int patch) {} public record VersionInfo(int major, int minor, int maintenance, int patch) {}
public record WidgetTypeStats(int created, int updated) {}
private enum WidgetTypeChange { CREATED, UPDATED, UNCHANGED }
} }

1
application/src/main/resources/thingsboard.yml

@ -454,6 +454,7 @@ sql:
log_tenant_stats: "${SQL_LOG_TENANT_STATS:true}" log_tenant_stats: "${SQL_LOG_TENANT_STATS:true}"
# Interval in milliseconds for printing the latest statistic information about the tenant # Interval in milliseconds for printing the latest statistic information about the tenant
log_tenant_stats_interval_ms: "${SQL_LOG_TENANT_STATS_INTERVAL_MS:60000}" log_tenant_stats_interval_ms: "${SQL_LOG_TENANT_STATS_INTERVAL_MS:60000}"
entity_data_query_nulls_order_strategy: "${SQL_ENTITY_DATA_QUERY_NULLS_ORDER_STRATEGY:default}" # Nulls ordering strategy for sql entity data query. Possible values: default, nulls_first, nulls_last. The default value is 'default', which means postgres default behavior will be applied: NULLS LAST for ASC and NULLS FIRST for DESC. The 'nulls_first' value means that NULL values will be ordered before non-NULL values regardless of the sort order. The 'nulls_last' value means that NULL values will be ordered after non-NULL values regardless of the sort order.
postgres: postgres:
# Specify partitioning size for timestamp key-value storage. Example: DAYS, MONTHS, YEARS, INDEFINITE. # Specify partitioning size for timestamp key-value storage. Example: DAYS, MONTHS, YEARS, INDEFINITE.
ts_key_value_partitioning: "${SQL_POSTGRES_TS_KV_PARTITIONING:MONTHS}" ts_key_value_partitioning: "${SQL_POSTGRES_TS_KV_PARTITIONING:MONTHS}"

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

105
application/src/test/java/org/thingsboard/server/service/entitiy/EdqsEntityServiceTest.java

@ -16,19 +16,27 @@
package org.thingsboard.server.service.entitiy; package org.thingsboard.server.service.entitiy;
import com.google.common.collect.Lists; import com.google.common.collect.Lists;
import com.google.common.util.concurrent.Futures;
import com.google.common.util.concurrent.ListenableFuture;
import org.junit.Before; import org.junit.Before;
import org.junit.Test; import org.junit.Test;
import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.mock.mockito.MockBean; import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.test.context.TestPropertySource; import org.springframework.test.context.TestPropertySource;
import org.thingsboard.server.common.data.Device;
import org.thingsboard.server.common.data.EntityType; import org.thingsboard.server.common.data.EntityType;
import org.thingsboard.server.common.data.asset.Asset; import org.thingsboard.server.common.data.asset.Asset;
import org.thingsboard.server.common.data.id.CustomerId; import org.thingsboard.server.common.data.id.CustomerId;
import org.thingsboard.server.common.data.id.IdBased; import org.thingsboard.server.common.data.id.IdBased;
import org.thingsboard.server.common.data.kv.TimeseriesSaveResult;
import org.thingsboard.server.common.data.page.PageData; import org.thingsboard.server.common.data.page.PageData;
import org.thingsboard.server.common.data.query.DeviceTypeFilter;
import org.thingsboard.server.common.data.query.EntityCountQuery; import org.thingsboard.server.common.data.query.EntityCountQuery;
import org.thingsboard.server.common.data.query.EntityData; import org.thingsboard.server.common.data.query.EntityData;
import org.thingsboard.server.common.data.query.EntityDataPageLink;
import org.thingsboard.server.common.data.query.EntityDataQuery; import org.thingsboard.server.common.data.query.EntityDataQuery;
import org.thingsboard.server.common.data.query.EntityDataSortOrder;
import org.thingsboard.server.common.data.query.EntityKey;
import org.thingsboard.server.common.data.query.EntityKeyType; import org.thingsboard.server.common.data.query.EntityKeyType;
import org.thingsboard.server.common.data.query.RelationsQueryFilter; import org.thingsboard.server.common.data.query.RelationsQueryFilter;
import org.thingsboard.server.common.data.relation.EntitySearchDirection; import org.thingsboard.server.common.data.relation.EntitySearchDirection;
@ -43,10 +51,13 @@ import java.util.HashMap;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.UUID; import java.util.UUID;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.TimeUnit; import java.util.concurrent.TimeUnit;
import java.util.stream.Collectors; import java.util.stream.Collectors;
import static org.assertj.core.api.Assertions.assertThat;
import static org.awaitility.Awaitility.await; import static org.awaitility.Awaitility.await;
import static org.thingsboard.server.common.data.query.EntityKeyType.ENTITY_FIELD;
@DaoSqlTest @DaoSqlTest
@TestPropertySource(properties = { @TestPropertySource(properties = {
@ -103,6 +114,100 @@ public class EdqsEntityServiceTest extends EntityServiceTest {
assetService.deleteAssetsByTenantId(tenantId); assetService.deleteAssetsByTenantId(tenantId);
} }
// edqs has no nulls order strategies, always returns NULLs first for ASC and NULLs last for DESC
@Override
@Test
public void testSortByNumericTelemetryKeyWithDifferentNullsOrderStrategy() throws ExecutionException, InterruptedException {
List<Device> devices = new ArrayList<>();
for (int i = 0; i < 5; i++) {
Device device = new Device();
device.setTenantId(tenantId);
device.setName("Device" + i);
device.setType("default");
devices.add(deviceService.saveDevice(device));
Thread.sleep(1);
}
List<Long> values = List.of(1L, 0L, 0L);
List<ListenableFuture<TimeseriesSaveResult>> timeseriesFutures = new ArrayList<>();
for (int i = 0; i < values.size(); i++) {
timeseriesFutures.add(saveTimeseries(devices.get(i).getId(), "test", values.get(i)));
}
Futures.allAsList(timeseriesFutures).get();
DeviceTypeFilter filter = new DeviceTypeFilter();
filter.setDeviceTypes(List.of("default"));
filter.setDeviceNameFilter("");
List<EntityKey> entityFields = Collections.singletonList(new EntityKey(ENTITY_FIELD, "name"));
List<EntityKey> latestValues = Collections.singletonList(new EntityKey(EntityKeyType.TIME_SERIES, "test"));
EntityDataSortOrder ascSortOrder = new EntityDataSortOrder(
new EntityKey(EntityKeyType.TIME_SERIES, "test"), EntityDataSortOrder.Direction.ASC);
EntityDataQuery ascQuery = new EntityDataQuery(filter,
new EntityDataPageLink(10, 0, null, ascSortOrder), entityFields, latestValues, null);
List<String> ascTelemetry = loadAllData(ascQuery, devices.size()).stream()
.map(ed -> ed.getLatest().get(EntityKeyType.TIME_SERIES).get("test").getValue())
.toList();
assertThat(ascTelemetry).containsExactlyElementsOf(List.of("", "", "0", "0", "1"));
EntityDataSortOrder descSortOrder = new EntityDataSortOrder(
new EntityKey(EntityKeyType.TIME_SERIES, "test"), EntityDataSortOrder.Direction.DESC);
EntityDataQuery descQuery = new EntityDataQuery(filter,
new EntityDataPageLink(10, 0, null, descSortOrder), entityFields, latestValues, null);
List<String> descTelemetry = loadAllData(descQuery, devices.size()).stream()
.map(ed -> ed.getLatest().get(EntityKeyType.TIME_SERIES).get("test").getValue())
.toList();
assertThat(descTelemetry).containsExactlyElementsOf(List.of("1", "0", "0", "", ""));
}
// edqs has no nulls order strategies, always returns NULLs first for ASC and NULLs last for DESC
@Override
@Test
public void testSortByBooleanKeyWithDifferentNullsOrderStrategy() throws ExecutionException, InterruptedException {
List<Device> devices = new ArrayList<>();
for (int i = 0; i < 5; i++) {
Device device = new Device();
device.setTenantId(tenantId);
device.setName("Device" + i);
device.setType("default");
devices.add(deviceService.saveDevice(device));
Thread.sleep(1);
}
List<Boolean> values = List.of(true, false, false);
List<ListenableFuture<TimeseriesSaveResult>> timeseriesFutures = new ArrayList<>();
for (int i = 0; i < values.size(); i++) {
timeseriesFutures.add(saveTimeseries(devices.get(i).getId(), "test", values.get(i)));
}
Futures.allAsList(timeseriesFutures).get();
DeviceTypeFilter filter = new DeviceTypeFilter();
filter.setDeviceTypes(List.of("default"));
filter.setDeviceNameFilter("");
List<EntityKey> entityFields = Collections.singletonList(new EntityKey(ENTITY_FIELD, "name"));
List<EntityKey> latestValues = Collections.singletonList(new EntityKey(EntityKeyType.TIME_SERIES, "test"));
EntityDataSortOrder ascSortOrder = new EntityDataSortOrder(
new EntityKey(EntityKeyType.TIME_SERIES, "test"), EntityDataSortOrder.Direction.ASC);
EntityDataQuery ascQuery = new EntityDataQuery(filter,
new EntityDataPageLink(10, 0, null, ascSortOrder), entityFields, latestValues, null);
List<String> ascTelemetry = loadAllData(ascQuery, devices.size()).stream()
.map(ed -> ed.getLatest().get(EntityKeyType.TIME_SERIES).get("test").getValue())
.toList();
assertThat(ascTelemetry).containsExactlyElementsOf(List.of("", "", "false", "false", "true"));
EntityDataSortOrder descSortOrder = new EntityDataSortOrder(
new EntityKey(EntityKeyType.TIME_SERIES, "test"), EntityDataSortOrder.Direction.DESC);
EntityDataQuery descQuery = new EntityDataQuery(filter,
new EntityDataPageLink(10, 0, null, descSortOrder), entityFields, latestValues, null);
List<String> descTelemetry = loadAllData(descQuery, devices.size()).stream()
.map(ed -> ed.getLatest().get(EntityKeyType.TIME_SERIES).get("test").getValue())
.toList();
assertThat(descTelemetry).containsExactlyElementsOf(List.of("true", "false", "false", "", ""));
}
@Override @Override
protected PageData<EntityData> findByQueryAndCheck(CustomerId customerId, EntityDataQuery query, long expectedResultSize) { protected PageData<EntityData> findByQueryAndCheck(CustomerId customerId, EntityDataQuery query, long expectedResultSize) {
return await().atMost(TIMEOUT, TimeUnit.SECONDS).until(() -> findByQuery(customerId, query), return await().atMost(TIMEOUT, TimeUnit.SECONDS).until(() -> findByQuery(customerId, query),

126
application/src/test/java/org/thingsboard/server/service/entitiy/EntityServiceTest.java

@ -26,6 +26,7 @@ import org.junit.Before;
import org.junit.Test; import org.junit.Test;
import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.jdbc.core.ResultSetExtractor; import org.springframework.jdbc.core.ResultSetExtractor;
import org.springframework.test.util.ReflectionTestUtils;
import org.thingsboard.common.util.JacksonUtil; import org.thingsboard.common.util.JacksonUtil;
import org.thingsboard.server.common.data.AttributeScope; import org.thingsboard.server.common.data.AttributeScope;
import org.thingsboard.server.common.data.Customer; import org.thingsboard.server.common.data.Customer;
@ -47,6 +48,7 @@ import org.thingsboard.server.common.data.kv.AttributeKvEntry;
import org.thingsboard.server.common.data.kv.AttributesSaveResult; import org.thingsboard.server.common.data.kv.AttributesSaveResult;
import org.thingsboard.server.common.data.kv.BaseAttributeKvEntry; import org.thingsboard.server.common.data.kv.BaseAttributeKvEntry;
import org.thingsboard.server.common.data.kv.BasicTsKvEntry; import org.thingsboard.server.common.data.kv.BasicTsKvEntry;
import org.thingsboard.server.common.data.kv.BooleanDataEntry;
import org.thingsboard.server.common.data.kv.DoubleDataEntry; import org.thingsboard.server.common.data.kv.DoubleDataEntry;
import org.thingsboard.server.common.data.kv.KvEntry; import org.thingsboard.server.common.data.kv.KvEntry;
import org.thingsboard.server.common.data.kv.LongDataEntry; import org.thingsboard.server.common.data.kv.LongDataEntry;
@ -79,7 +81,6 @@ import org.thingsboard.server.common.data.query.RelationsQueryFilter;
import org.thingsboard.server.common.data.query.SingleEntityFilter; import org.thingsboard.server.common.data.query.SingleEntityFilter;
import org.thingsboard.server.common.data.query.StringFilterPredicate; import org.thingsboard.server.common.data.query.StringFilterPredicate;
import org.thingsboard.server.common.data.query.StringFilterPredicate.StringOperation; import org.thingsboard.server.common.data.query.StringFilterPredicate.StringOperation;
import org.thingsboard.server.common.data.query.TsValue;
import org.thingsboard.server.common.data.relation.EntityRelation; import org.thingsboard.server.common.data.relation.EntityRelation;
import org.thingsboard.server.common.data.relation.EntitySearchDirection; import org.thingsboard.server.common.data.relation.EntitySearchDirection;
import org.thingsboard.server.common.data.relation.RelationEntityTypeFilter; import org.thingsboard.server.common.data.relation.RelationEntityTypeFilter;
@ -100,6 +101,7 @@ import org.thingsboard.server.dao.entityview.EntityViewDao;
import org.thingsboard.server.dao.entityview.EntityViewService; import org.thingsboard.server.dao.entityview.EntityViewService;
import org.thingsboard.server.dao.relation.RelationService; import org.thingsboard.server.dao.relation.RelationService;
import org.thingsboard.server.dao.service.DaoSqlTest; import org.thingsboard.server.dao.service.DaoSqlTest;
import org.thingsboard.server.dao.sql.query.DefaultEntityQueryRepository;
import org.thingsboard.server.dao.sql.relation.RelationRepository; import org.thingsboard.server.dao.sql.relation.RelationRepository;
import org.thingsboard.server.dao.timeseries.TimeseriesService; import org.thingsboard.server.dao.timeseries.TimeseriesService;
import org.thingsboard.server.dao.usagerecord.ApiUsageStateService; import org.thingsboard.server.dao.usagerecord.ApiUsageStateService;
@ -124,7 +126,6 @@ import static org.assertj.core.api.Assertions.assertThat;
import static org.thingsboard.server.common.data.AttributeScope.SERVER_SCOPE; import static org.thingsboard.server.common.data.AttributeScope.SERVER_SCOPE;
import static org.thingsboard.server.common.data.query.EntityKeyType.ATTRIBUTE; import static org.thingsboard.server.common.data.query.EntityKeyType.ATTRIBUTE;
import static org.thingsboard.server.common.data.query.EntityKeyType.ENTITY_FIELD; import static org.thingsboard.server.common.data.query.EntityKeyType.ENTITY_FIELD;
import static org.thingsboard.server.common.data.query.EntityKeyType.SERVER_ATTRIBUTE;
@Slf4j @Slf4j
@DaoSqlTest @DaoSqlTest
@ -154,6 +155,8 @@ public class EntityServiceTest extends AbstractControllerTest {
@Autowired @Autowired
RelationRepository relationRepository; RelationRepository relationRepository;
@Autowired @Autowired
DefaultEntityQueryRepository entityQueryRepository;
@Autowired
RelationService relationService; RelationService relationService;
@Autowired @Autowired
TimeseriesService timeseriesService; TimeseriesService timeseriesService;
@ -1750,6 +1753,117 @@ public class EntityServiceTest extends AbstractControllerTest {
deviceService.deleteDevicesByTenantId(tenantId); deviceService.deleteDevicesByTenantId(tenantId);
} }
@Test
public void testSortByNumericTelemetryKeyWithDifferentNullsOrderStrategy() throws ExecutionException, InterruptedException {
try {
List<Device> devices = new ArrayList<>();
for (int i = 0; i < 5; i++) {
Device device = new Device();
device.setTenantId(tenantId);
device.setName("Device" + i);
device.setType("default");
devices.add(deviceService.saveDevice(device));
Thread.sleep(1);
}
List<Long> values = List.of(1L, 0L, 0L);
List<ListenableFuture<TimeseriesSaveResult>> timeseriesFutures = new ArrayList<>();
for (int i = 0; i < values.size(); i++) {
timeseriesFutures.add(saveTimeseries(devices.get(i).getId(), "test", values.get(i)));
}
Futures.allAsList(timeseriesFutures).get();
assertNullsOrdering("default",
List.of("0", "0", "1", "", ""),
List.of("", "", "1", "0", "0"),
devices.size());
assertNullsOrdering("nulls_first",
List.of("", "", "0", "0", "1"),
List.of("", "", "1", "0", "0"),
devices.size());
assertNullsOrdering("nulls_last",
List.of("0", "0", "1", "", ""),
List.of("1", "0", "0", "", ""),
devices.size());
} finally {
deviceService.deleteDevicesByTenantId(tenantId);
}
}
@Test
public void testSortByBooleanKeyWithDifferentNullsOrderStrategy() throws ExecutionException, InterruptedException {
try {
List<Device> devices = new ArrayList<>();
for (int i = 0; i < 5; i++) {
Device device = new Device();
device.setTenantId(tenantId);
device.setName("Device" + i);
device.setType("default");
devices.add(deviceService.saveDevice(device));
Thread.sleep(1);
}
List<Boolean> values = List.of(true, false, false);
List<ListenableFuture<TimeseriesSaveResult>> timeseriesFutures = new ArrayList<>();
for (int i = 0; i < values.size(); i++) {
timeseriesFutures.add(saveTimeseries(devices.get(i).getId(), "test", values.get(i)));
}
Futures.allAsList(timeseriesFutures).get();
assertNullsOrdering("default",
List.of("false", "false", "true", "", ""),
List.of("", "", "true", "false", "false"),
devices.size());
assertNullsOrdering("nulls_first",
List.of("", "", "false", "false", "true"),
List.of("", "", "true", "false", "false"),
devices.size());
assertNullsOrdering("nulls_last",
List.of("false", "false", "true", "", ""),
List.of("true", "false", "false", "", ""),
devices.size());
} finally {
deviceService.deleteDevicesByTenantId(tenantId);
}
}
private void assertNullsOrdering(String strategy, List<String> expectedAsc, List<String> expectedDesc, int deviceSize) {
String originalStrategy = entityQueryRepository.getNullsOrderStrategy();
ReflectionTestUtils.setField(entityQueryRepository, "nullsOrderStrategy", strategy);
try {
DeviceTypeFilter filter = new DeviceTypeFilter();
filter.setDeviceTypes(List.of("default"));
filter.setDeviceNameFilter("");
List<EntityKey> entityFields = Collections.singletonList(new EntityKey(ENTITY_FIELD, "name"));
List<EntityKey> latestValues = Collections.singletonList(new EntityKey(EntityKeyType.TIME_SERIES, "test"));
EntityDataSortOrder ascSortOrder = new EntityDataSortOrder(
new EntityKey(EntityKeyType.TIME_SERIES, "test"), EntityDataSortOrder.Direction.ASC);
EntityDataQuery ascQuery = new EntityDataQuery(filter,
new EntityDataPageLink(10, 0, null, ascSortOrder), entityFields, latestValues, null);
List<String> ascTelemetry = loadAllData(ascQuery, deviceSize).stream()
.map(ed -> ed.getLatest().get(EntityKeyType.TIME_SERIES).get("test").getValue())
.toList();
assertThat(ascTelemetry).as("ASC with strategy '%s'", strategy).containsExactlyElementsOf(expectedAsc);
EntityDataSortOrder descSortOrder = new EntityDataSortOrder(
new EntityKey(EntityKeyType.TIME_SERIES, "test"), EntityDataSortOrder.Direction.DESC);
EntityDataQuery descQuery = new EntityDataQuery(filter,
new EntityDataPageLink(10, 0, null, descSortOrder), entityFields, latestValues, null);
List<String> descTelemetry = loadAllData(descQuery, deviceSize).stream()
.map(ed -> ed.getLatest().get(EntityKeyType.TIME_SERIES).get("test").getValue())
.toList();
assertThat(descTelemetry).as("DESC with strategy '%s'", strategy).containsExactlyElementsOf(expectedDesc);
} finally {
ReflectionTestUtils.setField(entityQueryRepository, "nullsOrderStrategy", originalStrategy);
}
}
@Test @Test
public void testFindTenantTelemetry() throws ExecutionException, InterruptedException, TimeoutException { public void testFindTenantTelemetry() throws ExecutionException, InterruptedException, TimeoutException {
// save timeseries by sys admin // save timeseries by sys admin
@ -2321,12 +2435,18 @@ public class EntityServiceTest extends AbstractControllerTest {
return timeseriesService.save(tenantId, entityId, timeseries); return timeseriesService.save(tenantId, entityId, timeseries);
} }
private ListenableFuture<TimeseriesSaveResult> saveTimeseries(EntityId entityId, String key, Long value) { protected ListenableFuture<TimeseriesSaveResult> saveTimeseries(EntityId entityId, String key, Long value) {
KvEntry telemetryValue = new LongDataEntry(key, value); KvEntry telemetryValue = new LongDataEntry(key, value);
BasicTsKvEntry timeseries = new BasicTsKvEntry(42L, telemetryValue); BasicTsKvEntry timeseries = new BasicTsKvEntry(42L, telemetryValue);
return timeseriesService.save(tenantId, entityId, timeseries); return timeseriesService.save(tenantId, entityId, timeseries);
} }
protected ListenableFuture<TimeseriesSaveResult> saveTimeseries(EntityId entityId, String key, Boolean value) {
KvEntry telemetryValue = new BooleanDataEntry(key, value);
BasicTsKvEntry timeseries = new BasicTsKvEntry(42L, telemetryValue);
return timeseriesService.save(tenantId, entityId, timeseries);
}
protected void createMultiRootHierarchy(List<Asset> buildings, List<Asset> apartments, protected void createMultiRootHierarchy(List<Asset> buildings, List<Asset> apartments,
Map<String, Map<UUID, String>> entityNameByTypeMap, Map<String, Map<UUID, String>> entityNameByTypeMap,
Map<UUID, UUID> childParentRelationMap) throws InterruptedException { Map<UUID, UUID> childParentRelationMap) throws InterruptedException {

84
application/src/test/java/org/thingsboard/server/service/script/TbelInvokeServiceTest.java

@ -217,6 +217,90 @@ class TbelInvokeServiceTest extends AbstractTbelInvokeTest {
assertThat(compiledScriptsCache.getIfPresent(scriptIdToHash.get(scriptRemovedFromCache))).isNotNull(); assertThat(compiledScriptsCache.getIfPresent(scriptIdToHash.get(scriptRemovedFromCache))).isNotNull();
} }
@Test
void givenForbiddenSocketHandler_whenInvoking_thenThrowsRuntimeError() throws ExecutionException, InterruptedException {
UUID scriptId = evalScript("new java.util.logging.SocketHandler(\"127.0.0.1\", 9999)");
assertThatThrownBy(() -> invokeScript(scriptId, "{\"temperature\":25}"))
.isInstanceOf(ExecutionException.class)
.cause()
.isInstanceOf(TbScriptException.class)
.asInstanceOf(type(TbScriptException.class))
.satisfies(ex -> {
assertThat(ex.getErrorCode()).isEqualTo(TbScriptException.ErrorCode.RUNTIME);
assertThat(ex.getCause().getMessage()).contains("could not resolve class: java.util.logging.SocketHandler");
});
}
@Test
void givenForbiddenZipFile_whenInvoking_thenThrowsRuntimeError() throws ExecutionException, InterruptedException {
UUID scriptId = evalScript("new java.util.zip.ZipFile(\"/tmp/test.zip\")");
assertThatThrownBy(() -> invokeScript(scriptId, "{\"temperature\":25}"))
.isInstanceOf(ExecutionException.class)
.cause()
.isInstanceOf(TbScriptException.class)
.asInstanceOf(type(TbScriptException.class))
.satisfies(ex -> {
assertThat(ex.getErrorCode()).isEqualTo(TbScriptException.ErrorCode.RUNTIME);
assertThat(ex.getCause().getMessage()).contains("could not resolve class: java.util.zip.ZipFile");
});
}
@Test
void givenForbiddenFileHandler_whenInvoking_thenThrowsRuntimeError() throws ExecutionException, InterruptedException {
UUID scriptId = evalScript("new java.util.logging.FileHandler(\"/tmp/test.log\")");
assertThatThrownBy(() -> invokeScript(scriptId, "{\"temperature\":25}"))
.isInstanceOf(ExecutionException.class)
.cause()
.isInstanceOf(TbScriptException.class)
.asInstanceOf(type(TbScriptException.class))
.satisfies(ex -> {
assertThat(ex.getErrorCode()).isEqualTo(TbScriptException.ErrorCode.RUNTIME);
assertThat(ex.getCause().getMessage()).contains("could not resolve class: java.util.logging.FileHandler");
});
}
@Test
void givenForbiddenJarFile_whenInvoking_thenThrowsRuntimeError() throws ExecutionException, InterruptedException {
UUID scriptId = evalScript("new java.util.jar.JarFile(\"/tmp/test.jar\")");
assertThatThrownBy(() -> invokeScript(scriptId, "{\"temperature\":25}"))
.isInstanceOf(ExecutionException.class)
.cause()
.isInstanceOf(TbScriptException.class)
.asInstanceOf(type(TbScriptException.class))
.satisfies(ex -> {
assertThat(ex.getErrorCode()).isEqualTo(TbScriptException.ErrorCode.RUNTIME);
assertThat(ex.getCause().getMessage()).contains("could not resolve class: java.util.jar.JarFile");
});
}
@Test
void givenForbiddenPreferences_whenInvoking_thenThrowsRuntimeError() throws ExecutionException, InterruptedException {
UUID scriptId = evalScript("java.util.prefs.Preferences.userRoot()");
assertThatThrownBy(() -> invokeScript(scriptId, "{\"temperature\":25}"))
.isInstanceOf(ExecutionException.class)
.cause()
.isInstanceOf(TbScriptException.class)
.asInstanceOf(type(TbScriptException.class))
.satisfies(ex -> {
assertThat(ex.getErrorCode()).isEqualTo(TbScriptException.ErrorCode.RUNTIME);
assertThat(ex.getMessage()).contains("unresolvable property or identifier: java");
});
}
@Test
void givenForbiddenLocaleServiceProvider_whenInvoking_thenThrowsRuntimeError() throws ExecutionException, InterruptedException {
UUID scriptId = evalScript("new java.util.spi.LocaleServiceProvider()");
assertThatThrownBy(() -> invokeScript(scriptId, "{\"temperature\":25}"))
.isInstanceOf(ExecutionException.class)
.cause()
.isInstanceOf(TbScriptException.class)
.asInstanceOf(type(TbScriptException.class))
.satisfies(ex -> {
assertThat(ex.getErrorCode()).isEqualTo(TbScriptException.ErrorCode.RUNTIME);
assertThat(ex.getCause().getMessage()).contains("could not resolve class: java.util.spi.LocaleServiceProvider");
});
}
private void assertThatScriptIsBlocked(UUID scriptId) { private void assertThatScriptIsBlocked(UUID scriptId) {
assertThatThrownBy(() -> { assertThatThrownBy(() -> {
invokeScriptResultString(scriptId, "{}"); invokeScriptResultString(scriptId, "{}");

296
application/src/test/java/org/thingsboard/server/system/SystemPatchApplierTest.java

@ -31,9 +31,12 @@ import org.springframework.test.util.ReflectionTestUtils;
import org.thingsboard.common.util.JacksonUtil; import org.thingsboard.common.util.JacksonUtil;
import org.thingsboard.server.common.data.id.TenantId; import org.thingsboard.server.common.data.id.TenantId;
import org.thingsboard.server.common.data.id.WidgetTypeId; import org.thingsboard.server.common.data.id.WidgetTypeId;
import org.thingsboard.server.common.data.id.WidgetsBundleId;
import org.thingsboard.server.common.data.widget.WidgetTypeDetails; import org.thingsboard.server.common.data.widget.WidgetTypeDetails;
import org.thingsboard.server.common.data.widget.WidgetsBundle;
import org.thingsboard.server.dao.resource.ImageService; import org.thingsboard.server.dao.resource.ImageService;
import org.thingsboard.server.dao.widget.WidgetTypeService; import org.thingsboard.server.dao.widget.WidgetTypeService;
import org.thingsboard.server.dao.widget.WidgetsBundleService;
import org.thingsboard.server.service.install.DatabaseSchemaSettingsService; import org.thingsboard.server.service.install.DatabaseSchemaSettingsService;
import org.thingsboard.server.service.install.InstallScripts; import org.thingsboard.server.service.install.InstallScripts;
import org.thingsboard.server.service.system.SystemPatchApplier; import org.thingsboard.server.service.system.SystemPatchApplier;
@ -41,6 +44,7 @@ import org.thingsboard.server.service.system.SystemPatchApplier;
import java.nio.file.Files; import java.nio.file.Files;
import java.nio.file.Path; import java.nio.file.Path;
import java.util.Collections; import java.util.Collections;
import java.util.List;
import java.util.Set; import java.util.Set;
import java.util.UUID; import java.util.UUID;
import java.util.concurrent.CountDownLatch; import java.util.concurrent.CountDownLatch;
@ -81,6 +85,9 @@ public class SystemPatchApplierTest {
@Mock @Mock
private WidgetTypeService widgetTypeService; private WidgetTypeService widgetTypeService;
@Mock
private WidgetsBundleService widgetsBundleService;
@Mock @Mock
private ImageService imageService; private ImageService imageService;
@ -155,19 +162,72 @@ public class SystemPatchApplierTest {
} }
@Test @Test
void whenWidgetNotFound_thenThrowException() throws Exception { void whenWidgetNotFound_thenCreateNewWidget() throws Exception {
Path widgetTypesDir = tempDir.resolve("widget_types"); Path widgetTypesDir = tempDir.resolve("widget_types");
Files.createDirectories(widgetTypesDir); Files.createDirectories(widgetTypesDir);
when(installScripts.getWidgetTypesDir()).thenReturn(widgetTypesDir); when(installScripts.getWidgetTypesDir()).thenReturn(widgetTypesDir);
WidgetTypeDetails testWidget = createTestWidgetType("test_widget", "Test Widget"); WidgetTypeDetails fileWidget = createTestWidgetType("new_widget", "New Widget");
String json = JacksonUtil.toString(testWidget); String json = JacksonUtil.toString(fileWidget);
assertNotNull(json); assertNotNull(json);
Files.writeString(widgetTypesDir.resolve("test_widget.json"), json); Files.writeString(widgetTypesDir.resolve("new_widget.json"), json);
when(widgetTypeService.findWidgetTypeDetailsByTenantIdAndFqn(TenantId.SYS_TENANT_ID, "new_widget")).thenReturn(null);
SystemPatchApplier.WidgetTypeStats stats = ReflectionTestUtils.invokeMethod(reconciler, "updateWidgetTypes");
assertNotNull(stats);
assertEquals(1, stats.created());
assertEquals(0, stats.updated());
verify(widgetTypeService).saveWidgetType(argThat(w -> "new_widget".equals(w.getFqn())));
}
@Test
void whenFqnIsBlank_thenThrowException() throws Exception {
Path widgetTypesDir = tempDir.resolve("widget_types");
Files.createDirectories(widgetTypesDir);
when(installScripts.getWidgetTypesDir()).thenReturn(widgetTypesDir);
when(widgetTypeService.findWidgetTypeDetailsByTenantIdAndFqn(TenantId.SYS_TENANT_ID, "test_widget")).thenReturn(null); WidgetTypeDetails brokenWidget = createTestWidgetType("", "Broken Widget");
String json = JacksonUtil.toString(brokenWidget);
assertNotNull(json);
Files.writeString(widgetTypesDir.resolve("broken.json"), json);
assertThrows(RuntimeException.class, () -> ReflectionTestUtils.invokeMethod(reconciler, "updateWidgetTypes")); assertThrows(RuntimeException.class, () -> ReflectionTestUtils.invokeMethod(reconciler, "updateWidgetTypes"));
verify(widgetTypeService, never()).saveWidgetType(any());
}
@Test
void whenMixOfCreatedAndUpdated_thenStatsAreCorrect() throws Exception {
Path widgetTypesDir = tempDir.resolve("widget_types");
Files.createDirectories(widgetTypesDir);
when(installScripts.getWidgetTypesDir()).thenReturn(widgetTypesDir);
WidgetTypeDetails newFileWidget = createTestWidgetType("widget_new", "Widget New");
Files.writeString(widgetTypesDir.resolve("widget_new.json"), JacksonUtil.toString(newFileWidget));
WidgetTypeDetails changedFileWidget = createTestWidgetType("widget_changed", "Widget Changed New Name");
Files.writeString(widgetTypesDir.resolve("widget_changed.json"), JacksonUtil.toString(changedFileWidget));
WidgetTypeDetails sameFileWidget = createTestWidgetType("widget_same", "Widget Same");
Files.writeString(widgetTypesDir.resolve("widget_same.json"), JacksonUtil.toString(sameFileWidget));
WidgetTypeDetails existingChanged = createTestWidgetType("widget_changed", "Widget Changed Old Name");
existingChanged.setId(new WidgetTypeId(UUID.randomUUID()));
WidgetTypeDetails existingSame = createTestWidgetType("widget_same", "Widget Same");
existingSame.setId(new WidgetTypeId(UUID.randomUUID()));
when(widgetTypeService.findWidgetTypeDetailsByTenantIdAndFqn(TenantId.SYS_TENANT_ID, "widget_new")).thenReturn(null);
when(widgetTypeService.findWidgetTypeDetailsByTenantIdAndFqn(TenantId.SYS_TENANT_ID, "widget_changed")).thenReturn(existingChanged);
when(widgetTypeService.findWidgetTypeDetailsByTenantIdAndFqn(TenantId.SYS_TENANT_ID, "widget_same")).thenReturn(existingSame);
SystemPatchApplier.WidgetTypeStats stats = ReflectionTestUtils.invokeMethod(reconciler, "updateWidgetTypes");
assertNotNull(stats);
assertEquals(1, stats.created());
assertEquals(1, stats.updated());
verify(widgetTypeService, times(2)).saveWidgetType(any());
} }
@Test @Test
@ -189,9 +249,11 @@ public class SystemPatchApplierTest {
when(widgetTypeService.findWidgetTypeDetailsByTenantIdAndFqn(TenantId.SYS_TENANT_ID, "test_widget")) when(widgetTypeService.findWidgetTypeDetailsByTenantIdAndFqn(TenantId.SYS_TENANT_ID, "test_widget"))
.thenReturn(existingWidget); .thenReturn(existingWidget);
Integer updated = ReflectionTestUtils.invokeMethod(reconciler, "updateWidgetTypes"); SystemPatchApplier.WidgetTypeStats stats = ReflectionTestUtils.invokeMethod(reconciler, "updateWidgetTypes");
assertEquals(1, updated); assertNotNull(stats);
assertEquals(0, stats.created());
assertEquals(1, stats.updated());
verify(widgetTypeService).saveWidgetType(argThat(w -> verify(widgetTypeService).saveWidgetType(argThat(w ->
w.getDescriptor().get("version").asInt() == 2 w.getDescriptor().get("version").asInt() == 2
)); ));
@ -214,9 +276,11 @@ public class SystemPatchApplierTest {
when(widgetTypeService.findWidgetTypeDetailsByTenantIdAndFqn(TenantId.SYS_TENANT_ID, "test_widget")) when(widgetTypeService.findWidgetTypeDetailsByTenantIdAndFqn(TenantId.SYS_TENANT_ID, "test_widget"))
.thenReturn(existingWidget); .thenReturn(existingWidget);
Integer updated = ReflectionTestUtils.invokeMethod(reconciler, "updateWidgetTypes"); SystemPatchApplier.WidgetTypeStats stats = ReflectionTestUtils.invokeMethod(reconciler, "updateWidgetTypes");
assertEquals(1, updated); assertNotNull(stats);
assertEquals(0, stats.created());
assertEquals(1, stats.updated());
verify(widgetTypeService).saveWidgetType(argThat(w -> "New Name".equals(w.getName()))); verify(widgetTypeService).saveWidgetType(argThat(w -> "New Name".equals(w.getName())));
} }
@ -237,9 +301,11 @@ public class SystemPatchApplierTest {
when(widgetTypeService.findWidgetTypeDetailsByTenantIdAndFqn(TenantId.SYS_TENANT_ID, "test_widget")) when(widgetTypeService.findWidgetTypeDetailsByTenantIdAndFqn(TenantId.SYS_TENANT_ID, "test_widget"))
.thenReturn(existingWidget); .thenReturn(existingWidget);
Integer updated = ReflectionTestUtils.invokeMethod(reconciler, "updateWidgetTypes"); SystemPatchApplier.WidgetTypeStats stats = ReflectionTestUtils.invokeMethod(reconciler, "updateWidgetTypes");
assertEquals(0, updated); assertNotNull(stats);
assertEquals(0, stats.created());
assertEquals(0, stats.updated());
verify(widgetTypeService, never()).saveWidgetType(any()); verify(widgetTypeService, never()).saveWidgetType(any());
} }
@ -339,8 +405,8 @@ public class SystemPatchApplierTest {
// Simulate work while holding lock // Simulate work while holding lock
Thread.sleep(100); Thread.sleep(100);
Integer updated = ReflectionTestUtils.invokeMethod(reconciler, "updateWidgetTypes"); SystemPatchApplier.WidgetTypeStats stats = ReflectionTestUtils.invokeMethod(reconciler, "updateWidgetTypes");
firstThreadSavedWidget.set(updated != null && updated > 0); firstThreadSavedWidget.set(stats != null && stats.updated() > 0);
ReflectionTestUtils.invokeMethod(reconciler, "releaseAdvisoryLock"); ReflectionTestUtils.invokeMethod(reconciler, "releaseAdvisoryLock");
} }
@ -360,8 +426,8 @@ public class SystemPatchApplierTest {
secondThreadAcquiredLock.set(Boolean.TRUE.equals(acquired)); secondThreadAcquiredLock.set(Boolean.TRUE.equals(acquired));
if (secondThreadAcquiredLock.get()) { if (secondThreadAcquiredLock.get()) {
Integer updated = ReflectionTestUtils.invokeMethod(reconciler, "updateWidgetTypes"); SystemPatchApplier.WidgetTypeStats stats = ReflectionTestUtils.invokeMethod(reconciler, "updateWidgetTypes");
secondThreadSavedWidget.set(updated != null && updated > 0); secondThreadSavedWidget.set(stats != null && stats.updated() > 0);
ReflectionTestUtils.invokeMethod(reconciler, "releaseAdvisoryLock"); ReflectionTestUtils.invokeMethod(reconciler, "releaseAdvisoryLock");
} }
@ -560,6 +626,7 @@ public class SystemPatchApplierTest {
Path widgetTypesDir = tempDir.resolve("widget_types"); Path widgetTypesDir = tempDir.resolve("widget_types");
Files.createDirectories(widgetTypesDir); Files.createDirectories(widgetTypesDir);
when(installScripts.getWidgetTypesDir()).thenReturn(widgetTypesDir); when(installScripts.getWidgetTypesDir()).thenReturn(widgetTypesDir);
when(installScripts.getWidgetBundlesDir()).thenReturn(tempDir.resolve("widget_bundles_missing"));
ReflectionTestUtils.invokeMethod(reconciler, "applyPatchIfNeeded"); ReflectionTestUtils.invokeMethod(reconciler, "applyPatchIfNeeded");
@ -607,6 +674,7 @@ public class SystemPatchApplierTest {
Path widgetTypesDir = tempDir.resolve("widget_types"); Path widgetTypesDir = tempDir.resolve("widget_types");
Files.createDirectories(widgetTypesDir); Files.createDirectories(widgetTypesDir);
when(installScripts.getWidgetTypesDir()).thenReturn(widgetTypesDir); when(installScripts.getWidgetTypesDir()).thenReturn(widgetTypesDir);
when(installScripts.getWidgetBundlesDir()).thenReturn(tempDir.resolve("widget_bundles_missing"));
ReflectionTestUtils.invokeMethod(reconciler, "applyPatchIfNeeded"); ReflectionTestUtils.invokeMethod(reconciler, "applyPatchIfNeeded");
@ -834,6 +902,7 @@ public class SystemPatchApplierTest {
Path widgetTypesDir = tempDir.resolve("widget_types"); Path widgetTypesDir = tempDir.resolve("widget_types");
Files.createDirectories(widgetTypesDir); Files.createDirectories(widgetTypesDir);
when(installScripts.getWidgetTypesDir()).thenReturn(widgetTypesDir); when(installScripts.getWidgetTypesDir()).thenReturn(widgetTypesDir);
when(installScripts.getWidgetBundlesDir()).thenReturn(tempDir.resolve("widget_bundles_missing"));
when(imageService.getAllImageKeysByTenantId(TenantId.SYS_TENANT_ID)).thenReturn(Collections.emptySet()); when(imageService.getAllImageKeysByTenantId(TenantId.SYS_TENANT_ID)).thenReturn(Collections.emptySet());
@ -854,4 +923,201 @@ public class SystemPatchApplierTest {
verify(imageService, never()).createOrUpdateSystemImage(anyString(), any(byte[].class)); verify(imageService, never()).createOrUpdateSystemImage(anyString(), any(byte[].class));
} }
// --- updateWidgetBundles tests ---
@Test
void whenWidgetBundlesDirDoesNotExist_thenReturnsZero() {
when(installScripts.getWidgetBundlesDir()).thenReturn(tempDir.resolve("missing_bundles"));
Integer updated = ReflectionTestUtils.invokeMethod(reconciler, "updateWidgetBundles");
assertEquals(0, updated);
verify(widgetsBundleService, never()).saveWidgetsBundle(any());
verify(widgetTypeService, never()).updateWidgetsBundleWidgetFqns(any(), any(), any());
}
@Test
void whenBundleNotInDb_thenSkipWithoutCreation() throws Exception {
Path bundlesDir = tempDir.resolve("widget_bundles");
Files.createDirectories(bundlesDir);
when(installScripts.getWidgetBundlesDir()).thenReturn(bundlesDir);
Files.writeString(bundlesDir.resolve("charts.json"),
"{\"widgetsBundle\":{\"alias\":\"charts\",\"title\":\"Charts\",\"order\":10}," +
"\"widgetTypeFqns\":[\"line_chart\"]}");
when(widgetsBundleService.findWidgetsBundleByTenantIdAndAlias(TenantId.SYS_TENANT_ID, "charts")).thenReturn(null);
Integer updated = ReflectionTestUtils.invokeMethod(reconciler, "updateWidgetBundles");
assertEquals(0, updated);
verify(widgetsBundleService, never()).saveWidgetsBundle(any());
verify(widgetTypeService, never()).updateWidgetsBundleWidgetFqns(any(), any(), any());
}
@Test
void whenBundleExistsAndHasNewFqn_thenMergeFqns() throws Exception {
Path bundlesDir = tempDir.resolve("widget_bundles");
Files.createDirectories(bundlesDir);
when(installScripts.getWidgetBundlesDir()).thenReturn(bundlesDir);
Files.writeString(bundlesDir.resolve("charts.json"),
"{\"widgetsBundle\":{\"alias\":\"charts\",\"title\":\"Charts\",\"description\":\"d\",\"order\":10}," +
"\"widgetTypeFqns\":[\"line_chart\",\"bar_chart\",\"new_chart\"]}");
WidgetsBundle existingBundle = createTestBundle("charts", "Charts");
existingBundle.setDescription("d");
existingBundle.setOrder(10);
when(widgetsBundleService.findWidgetsBundleByTenantIdAndAlias(TenantId.SYS_TENANT_ID, "charts")).thenReturn(existingBundle);
when(widgetTypeService.findWidgetFqnsByWidgetsBundleId(TenantId.SYS_TENANT_ID, existingBundle.getId()))
.thenReturn(List.of("line_chart", "bar_chart"));
Integer updated = ReflectionTestUtils.invokeMethod(reconciler, "updateWidgetBundles");
assertEquals(1, updated);
verify(widgetsBundleService, never()).saveWidgetsBundle(any());
verify(widgetTypeService).updateWidgetsBundleWidgetFqns(
eq(TenantId.SYS_TENANT_ID),
eq(existingBundle.getId()),
argThat(fqns -> fqns.size() == 3
&& fqns.get(0).equals("line_chart")
&& fqns.get(1).equals("bar_chart")
&& fqns.get(2).equals("new_chart"))
);
}
@Test
void whenBundleExistsAndAllFqnsAlreadyLinked_thenNoLinkUpdate() throws Exception {
Path bundlesDir = tempDir.resolve("widget_bundles");
Files.createDirectories(bundlesDir);
when(installScripts.getWidgetBundlesDir()).thenReturn(bundlesDir);
Files.writeString(bundlesDir.resolve("charts.json"),
"{\"widgetsBundle\":{\"alias\":\"charts\",\"title\":\"Charts\",\"description\":\"d\",\"order\":10}," +
"\"widgetTypeFqns\":[\"line_chart\",\"bar_chart\"]}");
WidgetsBundle existingBundle = createTestBundle("charts", "Charts");
existingBundle.setDescription("d");
existingBundle.setOrder(10);
when(widgetsBundleService.findWidgetsBundleByTenantIdAndAlias(TenantId.SYS_TENANT_ID, "charts")).thenReturn(existingBundle);
when(widgetTypeService.findWidgetFqnsByWidgetsBundleId(TenantId.SYS_TENANT_ID, existingBundle.getId()))
.thenReturn(List.of("line_chart", "bar_chart"));
Integer updated = ReflectionTestUtils.invokeMethod(reconciler, "updateWidgetBundles");
assertEquals(0, updated);
verify(widgetsBundleService, never()).saveWidgetsBundle(any());
verify(widgetTypeService, never()).updateWidgetsBundleWidgetFqns(any(), any(), any());
}
@Test
void whenOnlyBundleImageFormatDiffers_thenNoUpdate() throws Exception {
Path bundlesDir = tempDir.resolve("widget_bundles");
Files.createDirectories(bundlesDir);
when(installScripts.getWidgetBundlesDir()).thenReturn(bundlesDir);
// File carries a base64 data URI; DB has the resolved system-image URL — same content, different format.
Files.writeString(bundlesDir.resolve("charts.json"),
"{\"widgetsBundle\":{\"alias\":\"charts\",\"title\":\"Charts\",\"description\":\"d\",\"order\":10," +
"\"image\":\"data:image/png;base64,iVBORw0KGgo\"}," +
"\"widgetTypeFqns\":[]}");
WidgetsBundle existingBundle = createTestBundle("charts", "Charts");
existingBundle.setDescription("d");
existingBundle.setOrder(10);
existingBundle.setImage("tb-image;/api/images/system/charts.png");
when(widgetsBundleService.findWidgetsBundleByTenantIdAndAlias(TenantId.SYS_TENANT_ID, "charts")).thenReturn(existingBundle);
when(widgetTypeService.findWidgetFqnsByWidgetsBundleId(TenantId.SYS_TENANT_ID, existingBundle.getId()))
.thenReturn(List.of());
Integer updated = ReflectionTestUtils.invokeMethod(reconciler, "updateWidgetBundles");
assertEquals(0, updated);
verify(widgetsBundleService, never()).saveWidgetsBundle(any());
verify(widgetTypeService, never()).updateWidgetsBundleWidgetFqns(any(), any(), any());
}
@Test
void whenBundleMetadataChanged_thenUpdateBundle() throws Exception {
Path bundlesDir = tempDir.resolve("widget_bundles");
Files.createDirectories(bundlesDir);
when(installScripts.getWidgetBundlesDir()).thenReturn(bundlesDir);
Files.writeString(bundlesDir.resolve("charts.json"),
"{\"widgetsBundle\":{\"alias\":\"charts\",\"title\":\"New Title\",\"description\":\"new\",\"order\":20}," +
"\"widgetTypeFqns\":[\"line_chart\"]}");
WidgetsBundle existingBundle = createTestBundle("charts", "Old Title");
existingBundle.setDescription("old");
existingBundle.setOrder(10);
when(widgetsBundleService.findWidgetsBundleByTenantIdAndAlias(TenantId.SYS_TENANT_ID, "charts")).thenReturn(existingBundle);
when(widgetTypeService.findWidgetFqnsByWidgetsBundleId(TenantId.SYS_TENANT_ID, existingBundle.getId()))
.thenReturn(List.of("line_chart"));
Integer updated = ReflectionTestUtils.invokeMethod(reconciler, "updateWidgetBundles");
assertEquals(1, updated);
verify(widgetsBundleService).saveWidgetsBundle(argThat(b ->
"New Title".equals(b.getTitle()) && "new".equals(b.getDescription()) && b.getOrder() == 20
));
verify(widgetTypeService, never()).updateWidgetsBundleWidgetFqns(any(), any(), any());
}
@Test
void whenBundleAliasIsBlank_thenThrowException() throws Exception {
Path bundlesDir = tempDir.resolve("widget_bundles");
Files.createDirectories(bundlesDir);
when(installScripts.getWidgetBundlesDir()).thenReturn(bundlesDir);
Files.writeString(bundlesDir.resolve("broken.json"),
"{\"widgetsBundle\":{\"alias\":\"\",\"title\":\"Broken\"}}");
assertThrows(RuntimeException.class, () -> ReflectionTestUtils.invokeMethod(reconciler, "updateWidgetBundles"));
verify(widgetsBundleService, never()).saveWidgetsBundle(any());
}
@Test
void whenBundleJsonMissingWidgetsBundleField_thenThrowException() throws Exception {
Path bundlesDir = tempDir.resolve("widget_bundles");
Files.createDirectories(bundlesDir);
when(installScripts.getWidgetBundlesDir()).thenReturn(bundlesDir);
Files.writeString(bundlesDir.resolve("broken.json"), "{\"foo\":\"bar\"}");
assertThrows(RuntimeException.class, () -> ReflectionTestUtils.invokeMethod(reconciler, "updateWidgetBundles"));
verify(widgetsBundleService, never()).saveWidgetsBundle(any());
}
@Test
void whenBundleHasInlineWidgetTypes_thenThrowException() throws Exception {
Path bundlesDir = tempDir.resolve("widget_bundles");
Files.createDirectories(bundlesDir);
when(installScripts.getWidgetBundlesDir()).thenReturn(bundlesDir);
Files.writeString(bundlesDir.resolve("charts.json"),
"{\"widgetsBundle\":{\"alias\":\"charts\",\"title\":\"Charts\",\"description\":\"d\",\"order\":10}," +
"\"widgetTypes\":[" +
"{\"fqn\":\"inline_chart\",\"name\":\"Inline\",\"descriptor\":{\"type\":\"latest\"}}" +
"]}");
WidgetsBundle existingBundle = createTestBundle("charts", "Charts");
existingBundle.setDescription("d");
existingBundle.setOrder(10);
when(widgetsBundleService.findWidgetsBundleByTenantIdAndAlias(TenantId.SYS_TENANT_ID, "charts")).thenReturn(existingBundle);
assertThrows(RuntimeException.class, () -> ReflectionTestUtils.invokeMethod(reconciler, "updateWidgetBundles"));
verify(widgetTypeService, never()).saveWidgetType(any());
verify(widgetTypeService, never()).updateWidgetsBundleWidgetFqns(any(), any(), any());
verify(widgetsBundleService, never()).saveWidgetsBundle(any());
}
private WidgetsBundle createTestBundle(String alias, String title) {
WidgetsBundle bundle = new WidgetsBundle();
bundle.setId(new WidgetsBundleId(UUID.randomUUID()));
bundle.setAlias(alias);
bundle.setTitle(title);
bundle.setTenantId(TenantId.SYS_TENANT_ID);
return bundle;
}
} }

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.dao.exception.DataValidationException; import org.thingsboard.server.dao.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());
}
}
}
} }
} }

35
dao/src/main/java/org/thingsboard/server/dao/sql/query/DefaultEntityQueryRepository.java

@ -15,6 +15,7 @@
*/ */
package org.thingsboard.server.dao.sql.query; package org.thingsboard.server.dao.sql.query;
import jakarta.annotation.PostConstruct;
import lombok.Getter; import lombok.Getter;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value; import org.springframework.beans.factory.annotation.Value;
@ -60,6 +61,7 @@ import java.util.HashMap;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.Optional; import java.util.Optional;
import java.util.Set;
import java.util.UUID; import java.util.UUID;
import java.util.stream.Collectors; import java.util.stream.Collectors;
@ -318,10 +320,19 @@ public class DefaultEntityQueryRepository implements EntityQueryRepository {
.replace("$in", "from").replace("$out", "to") .replace("$in", "from").replace("$out", "to")
.replace("$rootIdCondition", "in (:relation_root_ids)"); .replace("$rootIdCondition", "in (:relation_root_ids)");
private static final String NULLS_ORDER_DEFAULT = "default";
private static final String NULLS_ORDER_FIRST = "nulls_first";
private static final String NULLS_ORDER_LAST = "nulls_last";
private static final Set<String> ACCEPTED_NULLS_ORDER_STRATEGIES = Set.of(NULLS_ORDER_DEFAULT, NULLS_ORDER_FIRST, NULLS_ORDER_LAST);
@Getter @Getter
@Value("${sql.relations.max_level:50}") @Value("${sql.relations.max_level:50}")
int maxLevelAllowed; //This value has to be reasonable small to prevent infinite recursion as early as possible int maxLevelAllowed; //This value has to be reasonable small to prevent infinite recursion as early as possible
@Getter
@Value("${sql.entity_data_query_nulls_order_strategy:default}")
String nullsOrderStrategy;
private final NamedParameterJdbcTemplate jdbcTemplate; private final NamedParameterJdbcTemplate jdbcTemplate;
private final TransactionTemplate transactionTemplate; private final TransactionTemplate transactionTemplate;
private final DefaultQueryLogComponent queryLog; private final DefaultQueryLogComponent queryLog;
@ -332,6 +343,15 @@ public class DefaultEntityQueryRepository implements EntityQueryRepository {
this.queryLog = queryLog; this.queryLog = queryLog;
} }
@PostConstruct
void validateNullsOrderStrategy() {
if (!ACCEPTED_NULLS_ORDER_STRATEGIES.contains(nullsOrderStrategy)) {
log.error("Invalid value '{}' for sql.entity_data_query_nulls_order_strategy. Accepted values are: {}. Falling back to '{}'.",
nullsOrderStrategy, ACCEPTED_NULLS_ORDER_STRATEGIES, NULLS_ORDER_DEFAULT);
nullsOrderStrategy = NULLS_ORDER_DEFAULT;
}
}
@Override @Override
public long countEntitiesByQuery(TenantId tenantId, CustomerId customerId, EntityCountQuery query) { public long countEntitiesByQuery(TenantId tenantId, CustomerId customerId, EntityCountQuery query) {
EntityType entityType = resolveEntityType(query.getEntityFilter()); EntityType entityType = resolveEntityType(query.getEntityFilter());
@ -502,11 +522,12 @@ public class DefaultEntityQueryRepository implements EntityQueryRepository {
if (sortOrderMappingOpt.isPresent()) { if (sortOrderMappingOpt.isPresent()) {
EntityKeyMapping sortOrderMapping = sortOrderMappingOpt.get(); EntityKeyMapping sortOrderMapping = sortOrderMappingOpt.get();
String direction = sortOrder.getDirection() == EntityDataSortOrder.Direction.ASC ? "asc" : "desc"; String direction = sortOrder.getDirection() == EntityDataSortOrder.Direction.ASC ? "asc" : "desc";
String nullsOrder = resolveNullsOrder();
if (sortOrderMapping.getEntityKey().getType() == EntityKeyType.ENTITY_FIELD) { if (sortOrderMapping.getEntityKey().getType() == EntityKeyType.ENTITY_FIELD) {
dataQuery = String.format("%s order by %s %s, result.id %s", dataQuery, sortOrderMapping.getValueAlias(), direction, direction); dataQuery = String.format("%s order by %s %s%s, result.id %s", dataQuery, sortOrderMapping.getValueAlias(), direction, nullsOrder, direction);
} else { } else {
dataQuery = String.format("%s order by %s %s, %s %s, result.id %s", dataQuery, dataQuery = String.format("%s order by %s %s%s, %s %s, result.id %s", dataQuery,
sortOrderMapping.getSortOrderNumAlias(), direction, sortOrderMapping.getSortOrderStrAlias(), direction, direction); sortOrderMapping.getSortOrderNumAlias(), direction, nullsOrder, sortOrderMapping.getSortOrderStrAlias(), direction, direction);
} }
} }
} }
@ -525,6 +546,14 @@ public class DefaultEntityQueryRepository implements EntityQueryRepository {
}); });
} }
private String resolveNullsOrder() {
return switch (nullsOrderStrategy) {
case NULLS_ORDER_FIRST -> " NULLS FIRST";
case NULLS_ORDER_LAST -> " NULLS LAST";
default -> "";
};
}
private String buildEntityWhere(SqlQueryContext ctx, EntityFilter entityFilter, List<EntityKeyMapping> entityFieldsFilters) { private String buildEntityWhere(SqlQueryContext ctx, EntityFilter entityFilter, List<EntityKeyMapping> entityFieldsFilters) {
String permissionQuery = this.buildPermissionQuery(ctx, entityFilter); String permissionQuery = this.buildPermissionQuery(ctx, entityFilter);
String entityFilterQuery = this.buildEntityFilterQuery(ctx, entityFilter); String entityFilterQuery = this.buildEntityFilterQuery(ctx, entityFilter);

4
dao/src/main/java/org/thingsboard/server/dao/sql/query/EntityKeyMapping.java

@ -494,8 +494,8 @@ public class EntityKeyMapping {
String attrNumAlias = getSortOrderNumAlias(); String attrNumAlias = getSortOrderNumAlias();
String attrVarcharAlias = getSortOrderStrAlias(); String attrVarcharAlias = getSortOrderStrAlias();
String attrSortOrderSelection = String attrSortOrderSelection =
String.format("coalesce(%s.dbl_v, cast(%s.long_v as double precision), (case when %s.bool_v then 1 else 0 end)) %s," + String.format("coalesce(%s.dbl_v, cast(%s.long_v as double precision), (case when %s.bool_v is null then null when %s.bool_v then 1 else 0 end)) %s," +
"coalesce(%s.str_v, cast(%s.json_v as varchar), '') %s", alias, alias, alias, attrNumAlias, alias, alias, attrVarcharAlias); "coalesce(%s.str_v, cast(%s.json_v as varchar), '') %s", alias, alias, alias, alias, attrNumAlias, alias, alias, attrVarcharAlias);
return String.join(", ", attrValSelection, attrTsSelection, attrSortOrderSelection); return String.join(", ", attrValSelection, attrTsSelection, attrSortOrderSelection);
} else { } else {
return String.join(", ", attrValSelection, attrTsSelection); return String.join(", ", attrValSelection, attrTsSelection);

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>

49
pom.xml

@ -62,9 +62,13 @@
<pkg.implementationTitle>${project.name}</pkg.implementationTitle> <pkg.implementationTitle>${project.name}</pkg.implementationTitle>
<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>
<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 -->
@ -88,8 +92,8 @@
<zookeeper.version>3.9.5</zookeeper.version> <!-- to fix CVE-2026-24308 and CVE-2026-24281. TODO: remove override when fixed in curator-client --> <zookeeper.version>3.9.5</zookeeper.version> <!-- to fix CVE-2026-24308 and CVE-2026-24281. TODO: remove override when fixed in curator-client -->
<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.10</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>
@ -1001,25 +1005,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>
@ -1266,6 +1251,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>
@ -1344,6 +1342,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>

15
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, type: widgetContextCompletions.ctx.type,
description: widgetContextCompletions.ctx.description, description: widgetContextCompletions.ctx.description,
children: widgetContextCompletions.ctx.children children: widgetContextCompletions.ctx.children
}, }
}
};
export const AngularContainerFunctionEditorCompleter = new TbEditorCompleter(containerFunctionCompletions);
export const HTMLContainerFunctionEditorCompleter = new TbEditorCompleter(
{...containerFunctionCompletions,
container: { container: {
meta: 'argument', meta: 'argument',
type: 'HTMLElement', type: 'HTMLElement',
description: 'Container element of the widget' description: 'Container element of the widget'
}, }}
} );
};
export const ContainerFunctionEditorCompleter = new TbEditorCompleter(containerFunctionCompletions);

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

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

114
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. * 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%; height: 100%;
::ng-deep { }
.mat-mdc-tab-body-wrapper { }
position: relative; &.tb-fullscreen {
top: 0; padding: 8px;
flex: 1; 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;
} }
} }
} }
}
} }

63
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. /// 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 { WidgetResource } from '@shared/models/widget.models';
import { import {
ControlValueAccessor, ControlValueAccessor,
@ -28,7 +39,8 @@ import {
Validators Validators
} from '@angular/forms'; } from '@angular/forms';
import { import {
ContainerFunctionEditorCompleter, AngularContainerFunctionEditorCompleter,
HTMLContainerFunctionEditorCompleter,
HtmlContainerWidgetSettings, HtmlContainerWidgetSettings,
HtmlContainerWidgetType HtmlContainerWidgetType
} from '@home/components/widget/lib/html/html-container-widget.models'; } from '@home/components/widget/lib/html/html-container-widget.models';
@ -52,23 +64,39 @@ import { WidgetService } from '@core/http/widget.service';
multi: true, multi: true,
} }
], ],
encapsulation: ViewEncapsulation.None,
standalone: false standalone: false
}) })
export class HtmlContainerSettingsComponent implements OnInit, ControlValueAccessor, Validator { export class HtmlContainerSettingsComponent implements OnInit, AfterViewInit, ControlValueAccessor, Validator {
HtmlContainerWidgetType = HtmlContainerWidgetType; HtmlContainerWidgetType = HtmlContainerWidgetType;
functionScopeVariables = this.widgetService.getWidgetScopeVariables(); functionScopeVariables = this.widgetService.getWidgetScopeVariables();
containerFunctionEditorCompleter = ContainerFunctionEditorCompleter; get containerFunctionEditorCompleter() {
return this.htmlContainerSettingsForm.get('type').value === HtmlContainerWidgetType.ANGULAR
? AngularContainerFunctionEditorCompleter
: HTMLContainerFunctionEditorCompleter;
}
@HostBinding('class') @HostBinding('class')
hostClass = 'tb-html-container-settings'; hostClass = 'tb-html-container-settings';
@ViewChild('leftPanel', { read: ElementRef })
leftPanelElmRef!: ElementRef;
@ViewChild('rightPanel', { read: ElementRef })
rightPanelElmRef!: ElementRef;
@Input() @Input()
disabled: boolean; disabled: boolean;
fullscreen = false;
tabsAnimationDuration = '500ms';
htmlContainerSettingsForm: UntypedFormGroup; htmlContainerSettingsForm: UntypedFormGroup;
private modelValue: HtmlContainerWidgetSettings; private modelValue: HtmlContainerWidgetSettings;
constructor(private fb: UntypedFormBuilder, 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 { registerOnChange(fn: any): void {
this.propagateChange = fn; this.propagateChange = fn;
} }
registerOnTouched(fn: any): void { registerOnTouched(_fn: any): void {
} }
setDisabledState(isDisabled: boolean): void { setDisabledState(isDisabled: boolean): void {
@ -150,7 +193,15 @@ export class HtmlContainerSettingsComponent implements OnInit, ControlValueAcces
this.resourcesFormArray.removeAt(index); 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() { private updateModel() {
this.modelValue = this.htmlContainerSettingsForm.value; this.modelValue = this.htmlContainerSettingsForm.value;

2
ui-ngx/src/app/shared/import-export/import-export.models.ts

@ -210,7 +210,7 @@ function splitCSV(str: string, sep: string): string[] {
foo = foo.shift().split(sep).concat(foo); foo = foo.shift().split(sep).concat(foo);
} }
} else { } else {
foo[x].replace(/""/g, '"'); foo[x] = foo[x].replace(/""/g, '"');
} }
} }
return foo; return foo;

20
ui-ngx/src/app/shared/models/page/page-link.ts

@ -84,11 +84,25 @@ export function sortItems(item1: any, item2: any, property: string, asc: boolean
result = item1Value - item2Value; result = item1Value - item2Value;
} else if (item1Type === 'string' && item2Type === 'string') { } else if (item1Type === 'string' && item2Type === 'string') {
result = item1Value.localeCompare(item2Value); result = item1Value.localeCompare(item2Value);
} else if ((item1Type === 'boolean' && item2Type === 'boolean') || (item1Type !== item2Type)) { } else if (item1Type === 'boolean' && item2Type === 'boolean') {
if (item1Value && !item2Value) { result = item1Value ? 1 : -1;
} else if (item1Type !== item2Type) {
const item1Empty = item1Value === null || item1Value === undefined || item1Value === '';
const item2Empty = item2Value === null || item2Value === undefined || item2Value === '';
if (!item1Empty && item2Empty) {
result = 1; result = 1;
} else if (!item1Value && item2Value) { } else if (item1Empty && !item2Empty) {
result = -1; result = -1;
} else if (!item1Empty && !item2Empty) {
const str1 = String(item1Value).trim();
const str2 = String(item2Value).trim();
const num1 = str1.length ? Number(str1) : NaN;
const num2 = str2.length ? Number(str2) : NaN;
if (!isNaN(num1) && !isNaN(num2)) {
result = num1 - num2;
} else {
result = String(item1Value).localeCompare(String(item2Value));
}
} }
} }
} }

Loading…
Cancel
Save