diff --git a/application/pom.xml b/application/pom.xml index 4114f98759..f6ceba95a1 100644 --- a/application/pom.xml +++ b/application/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard - 4.3.1.2-SNAPSHOT + 4.3.1.3-SNAPSHOT thingsboard application @@ -395,11 +395,7 @@ org.thingsboard.langchain4j - langchain4j-google-ai-gemini - - - org.thingsboard.langchain4j - langchain4j-vertex-ai-gemini + langchain4j-google-genai org.thingsboard.langchain4j @@ -415,13 +411,7 @@ org.thingsboard.langchain4j - langchain4j-github-models - - - com.azure - azure-core-test - - + langchain4j-open-ai-official org.thingsboard.langchain4j diff --git a/application/src/main/data/json/system/widget_types/html_container.json b/application/src/main/data/json/system/widget_types/html_container.json index 25b9e5ac5d..a27996593b 100644 --- a/application/src/main/data/json/system/widget_types/html_container.json +++ b/application/src/main/data/json/system/widget_types/html_container.json @@ -11,7 +11,7 @@ "resources": [], "templateHtml": "\n", "templateCss": "", - "controllerScript": "self.onInit = function() {\n \n}\n\nself.typeParameters = function() {\n return {\n previewWidth: '100%',\n previewHeight: '100%',\n overflowVisible: true\n };\n};\n", + "controllerScript": "self.onInit = function() {\n \n}\n\nself.typeParameters = function() {\n return {\n previewWidth: '100%',\n previewHeight: '100%',\n overflowVisible: true\n };\n};\n\nself.actionSources = function() {\n return {\n 'javaScript': {\n name: 'JavaScript',\n multiple: true\n }\n };\n}", "settingsDirective": "tb-html-container-widget-settings", "hasBasicMode": true, "basicModeDirective": "tb-html-container-basic-config", diff --git a/application/src/main/java/org/thingsboard/server/controller/AlarmCommentController.java b/application/src/main/java/org/thingsboard/server/controller/AlarmCommentController.java index 8a113fb424..998ca1cfa5 100644 --- a/application/src/main/java/org/thingsboard/server/controller/AlarmCommentController.java +++ b/application/src/main/java/org/thingsboard/server/controller/AlarmCommentController.java @@ -31,6 +31,7 @@ import org.thingsboard.server.common.data.alarm.Alarm; import org.thingsboard.server.common.data.alarm.AlarmComment; import org.thingsboard.server.common.data.alarm.AlarmCommentInfo; import org.thingsboard.server.common.data.alarm.AlarmCommentType; +import org.thingsboard.server.common.data.exception.ThingsboardErrorCode; import org.thingsboard.server.common.data.exception.ThingsboardException; import org.thingsboard.server.common.data.id.AlarmCommentId; import org.thingsboard.server.common.data.id.AlarmId; @@ -39,6 +40,7 @@ import org.thingsboard.server.common.data.page.PageLink; import org.thingsboard.server.config.annotations.ApiOperation; import org.thingsboard.server.queue.util.TbCoreComponent; import org.thingsboard.server.service.entitiy.alarm.TbAlarmCommentService; +import org.thingsboard.server.service.security.model.SecurityUser; import org.thingsboard.server.service.security.permission.Operation; import static org.thingsboard.server.controller.ControllerConstants.ALARM_COMMENT_ID_PARAM_DESCRIPTION; @@ -77,9 +79,13 @@ public class AlarmCommentController extends BaseController { checkParameter(ALARM_ID, strAlarmId); AlarmId alarmId = new AlarmId(toUUID(strAlarmId)); Alarm alarm = checkAlarmInfoId(alarmId, Operation.WRITE); + SecurityUser currentUser = getCurrentUser(); + if (alarmComment.getId() != null) { + checkUserPermission(alarmComment, alarmId, "edit", currentUser); + } alarmComment.setAlarmId(alarmId); alarmComment.setType(AlarmCommentType.OTHER); - return tbAlarmCommentService.saveAlarmComment(alarm, alarmComment, getCurrentUser()); + return tbAlarmCommentService.saveAlarmComment(alarm, alarmComment, currentUser); } @ApiOperation(value = "Delete Alarm comment (deleteAlarmComment)", @@ -93,7 +99,11 @@ public class AlarmCommentController extends BaseController { AlarmCommentId alarmCommentId = new AlarmCommentId(toUUID(strCommentId)); AlarmComment alarmComment = checkAlarmCommentId(alarmCommentId, alarmId); - tbAlarmCommentService.deleteAlarmComment(alarm, alarmComment, getCurrentUser()); + SecurityUser currentUser = getCurrentUser(); + if (!currentUser.isTenantAdmin()) { + checkUserPermission(alarmComment, alarmId, "delete", currentUser); + } + tbAlarmCommentService.deleteAlarmComment(alarm, alarmComment, currentUser); } @ApiOperation(value = "Get Alarm comments (getAlarmComments)", @@ -120,4 +130,12 @@ public class AlarmCommentController extends BaseController { return checkNotNull(alarmCommentService.findAlarmComments(alarm.getTenantId(), alarmId, pageLink)); } + private void checkUserPermission(AlarmComment alarmComment, AlarmId alarmId, String operation, SecurityUser currentUser) throws ThingsboardException { + AlarmComment existingAlarmComment = checkAlarmCommentId(alarmComment.getId(), alarmId); + if (existingAlarmComment.getUserId() != null && !existingAlarmComment.getUserId().equals(currentUser.getId())) { + throw new ThingsboardException("User is not allowed to " + operation + " other user's comment", + ThingsboardErrorCode.PERMISSION_DENIED); + } + } + } diff --git a/application/src/main/java/org/thingsboard/server/controller/SystemInfoController.java b/application/src/main/java/org/thingsboard/server/controller/SystemInfoController.java index ee2e871d6e..3a27e4f462 100644 --- a/application/src/main/java/org/thingsboard/server/controller/SystemInfoController.java +++ b/application/src/main/java/org/thingsboard/server/controller/SystemInfoController.java @@ -40,6 +40,7 @@ import org.thingsboard.server.common.data.mobile.qrCodeSettings.QrCodeSettings; import org.thingsboard.server.common.data.page.PageLink; import org.thingsboard.server.common.data.settings.UserSettings; import org.thingsboard.server.common.data.settings.UserSettingsType; +import org.thingsboard.server.common.msg.edqs.EdqsService; import org.thingsboard.server.common.data.tenant.profile.DefaultTenantProfileConfiguration; import org.thingsboard.server.dao.mobile.QrCodeSettingService; import org.thingsboard.server.dao.trendz.TrendzSettingsService; @@ -52,6 +53,7 @@ import org.thingsboard.server.utils.DebugModeRateLimitsConfig; import java.util.Collections; import java.util.List; import java.util.Optional; +import java.util.Set; import java.util.stream.Collectors; @Hidden @@ -76,6 +78,11 @@ public class SystemInfoController extends BaseController { @Value("${debug.settings.default_duration:15}") private int defaultDebugDurationMinutes; + @Value("${sql.entity_data_query_nulls_order_strategy:default}") + private String nullsOrderStrategy; + + private static final Set ACCEPTED_NULLS_ORDER_STRATEGIES = Set.of("default", "nulls_first", "nulls_last"); + @Autowired(required = false) private BuildProperties buildProperties; @@ -91,6 +98,9 @@ public class SystemInfoController extends BaseController { @Autowired private TrendzSettingsService trendzSettingsService; + @Autowired + private EdqsService edqsService; + @PostConstruct public void init() { JsonNode info = buildInfoObject(); @@ -150,6 +160,8 @@ public class SystemInfoController extends BaseController { } systemParams.setUserSettings(userSettingsNode); systemParams.setMaxDatapointsLimit(maxDatapointsLimit); + systemParams.setNullsOrderStrategy(ACCEPTED_NULLS_ORDER_STRATEGIES.contains(nullsOrderStrategy) ? nullsOrderStrategy : "default"); + systemParams.setEdqsEnabled(edqsService.isApiEnabled()); if (!currentUser.isSystemAdmin()) { DefaultTenantProfileConfiguration tenantProfileConfiguration = tenantProfileCache.get(tenantId).getDefaultProfileConfiguration(); systemParams.setMaxResourceSize(tenantProfileConfiguration.getMaxResourceSize()); diff --git a/application/src/main/java/org/thingsboard/server/service/ai/AiChatModelServiceImpl.java b/application/src/main/java/org/thingsboard/server/service/ai/AiChatModelServiceImpl.java index 15be6f3734..a2bc29104b 100644 --- a/application/src/main/java/org/thingsboard/server/service/ai/AiChatModelServiceImpl.java +++ b/application/src/main/java/org/thingsboard/server/service/ai/AiChatModelServiceImpl.java @@ -15,14 +15,8 @@ */ package org.thingsboard.server.service.ai; -import com.fasterxml.jackson.core.io.JsonStringEncoder; import com.google.common.util.concurrent.FluentFuture; import com.google.common.util.concurrent.Futures; -import dev.langchain4j.data.message.ChatMessage; -import dev.langchain4j.data.message.Content; -import dev.langchain4j.data.message.TextContent; -import dev.langchain4j.data.message.UserMessage; -import dev.langchain4j.model.ModelProvider; import dev.langchain4j.model.chat.ChatModel; import dev.langchain4j.model.chat.request.ChatRequest; import dev.langchain4j.model.chat.response.ChatResponse; @@ -31,9 +25,6 @@ import org.springframework.stereotype.Service; import org.thingsboard.server.common.data.ai.model.chat.AiChatModelConfig; import org.thingsboard.server.common.data.ai.model.chat.Langchain4jChatModelConfigurer; -import java.util.List; -import java.util.stream.Collectors; - @Service @RequiredArgsConstructor class AiChatModelServiceImpl implements AiChatModelService { @@ -49,39 +40,7 @@ class AiChatModelServiceImpl implements AiChatModelService { } catch (Throwable t) { return FluentFuture.from(Futures.immediateFailedFuture(t)); } - if (langChainChatModel.provider() == ModelProvider.GITHUB_MODELS) { - chatRequest = prepareGithubChatRequest(chatRequest); - } return aiRequestsExecutor.sendChatRequestAsync(langChainChatModel, chatRequest); } - private ChatRequest prepareGithubChatRequest(ChatRequest chatRequest) { - List messages = chatRequest.messages().stream() - .map(this::prepareUserMessage) - .collect(Collectors.toList()); - - return ChatRequest.builder() - .messages(messages) - .responseFormat(chatRequest.responseFormat()) - .build(); - } - - private ChatMessage prepareUserMessage(ChatMessage message) { - if (message instanceof UserMessage userMessage) { - List newContents = userMessage.contents().stream() - .map(this::prepareContent) - .collect(Collectors.toList()); - - return UserMessage.from(newContents); - } - return message; - } - - private Content prepareContent(Content content) { - if (content instanceof TextContent txt) { - return new TextContent(new String(JsonStringEncoder.getInstance().quoteAsString(txt.text()))); - } - return content; - } - } diff --git a/application/src/main/java/org/thingsboard/server/service/ai/Langchain4jChatModelConfigurerImpl.java b/application/src/main/java/org/thingsboard/server/service/ai/Langchain4jChatModelConfigurerImpl.java index 8b569f052c..3bb39c3671 100644 --- a/application/src/main/java/org/thingsboard/server/service/ai/Langchain4jChatModelConfigurerImpl.java +++ b/application/src/main/java/org/thingsboard/server/service/ai/Langchain4jChatModelConfigurerImpl.java @@ -15,26 +15,18 @@ */ package org.thingsboard.server.service.ai; -import com.google.api.gax.core.FixedCredentialsProvider; -import com.google.api.gax.retrying.RetrySettings; +import com.google.auth.oauth2.GoogleCredentials; import com.google.auth.oauth2.ServiceAccountCredentials; -import com.google.cloud.vertexai.Transport; -import com.google.cloud.vertexai.VertexAI; -import com.google.cloud.vertexai.api.GenerationConfig; -import com.google.cloud.vertexai.api.PredictionServiceClient; -import com.google.cloud.vertexai.api.PredictionServiceSettings; -import com.google.cloud.vertexai.generativeai.GenerativeModel; import dev.langchain4j.model.anthropic.AnthropicChatModel; import dev.langchain4j.model.azure.AzureOpenAiChatModel; import dev.langchain4j.model.bedrock.BedrockChatModel; import dev.langchain4j.model.chat.ChatModel; import dev.langchain4j.model.chat.request.ChatRequestParameters; -import dev.langchain4j.model.github.GitHubModelsChatModel; -import dev.langchain4j.model.googleai.GoogleAiGeminiChatModel; +import dev.langchain4j.model.google.genai.GoogleGenAiChatModel; import dev.langchain4j.model.mistralai.MistralAiChatModel; import dev.langchain4j.model.ollama.OllamaChatModel; import dev.langchain4j.model.openai.OpenAiChatModel; -import dev.langchain4j.model.vertexai.gemini.VertexAiGeminiChatModel; +import dev.langchain4j.model.openaiofficial.OpenAiOfficialChatModel; import org.springframework.http.HttpHeaders; import org.springframework.stereotype.Component; import org.thingsboard.common.util.SsrfProtectionValidator; @@ -50,7 +42,6 @@ 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.AmazonBedrockProviderConfig; 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.OllamaProviderConfig; import software.amazon.awssdk.auth.credentials.AwsBasicCredentials; import software.amazon.awssdk.auth.credentials.StaticCredentialsProvider; @@ -107,7 +98,7 @@ class Langchain4jChatModelConfigurerImpl implements Langchain4jChatModelConfigur @Override public ChatModel configureChatModel(GoogleAiGeminiChatModelConfig chatModelConfig) { - return GoogleAiGeminiChatModel.builder() + return GoogleGenAiChatModel.builder() .apiKey(chatModelConfig.providerConfig().apiKey()) .modelName(chatModelConfig.modelId()) .temperature(chatModelConfig.temperature()) @@ -123,84 +114,28 @@ class Langchain4jChatModelConfigurerImpl implements Langchain4jChatModelConfigur @Override public ChatModel configureChatModel(GoogleVertexAiGeminiChatModelConfig chatModelConfig) { - GoogleVertexAiGeminiProviderConfig providerConfig = chatModelConfig.providerConfig(); - - // construct service account credentials using service account key JSON - ServiceAccountCredentials serviceAccountCredentials; + GoogleCredentials credentials; try { - serviceAccountCredentials = ServiceAccountCredentials.fromStream(new ByteArrayInputStream(providerConfig.serviceAccountKey().getBytes())); + credentials = ServiceAccountCredentials + .fromStream(new ByteArrayInputStream(chatModelConfig.providerConfig().serviceAccountKey().getBytes(StandardCharsets.UTF_8))) + .createScoped("https://www.googleapis.com/auth/cloud-platform"); } catch (IOException e) { throw new RuntimeException("Failed to parse service account key JSON", e); } - - PredictionServiceSettings predictionServiceClientSettings; - try { - // create prediction service settings for REST transport with service account key credentials - PredictionServiceSettings.Builder settingsBuilder = PredictionServiceSettings.newHttpJsonBuilder() - .setCredentialsProvider(FixedCredentialsProvider.create(serviceAccountCredentials)); - - // get the retry settings that control request timeout for generateContent RPC - RetrySettings.Builder retrySettings = settingsBuilder - .generateContentSettings() - .getRetrySettings() - .toBuilder(); - - // set request timeout from model config - if (chatModelConfig.timeoutSeconds() != null) { - retrySettings.setTotalTimeoutDuration(Duration.ofSeconds(chatModelConfig.timeoutSeconds())); - } - - // set updated retry settings - settingsBuilder.generateContentSettings().setRetrySettings(retrySettings.build()); - - // build the client settings - predictionServiceClientSettings = settingsBuilder.build(); - } catch (IOException e) { - throw new RuntimeException("Failed to create prediction service client settings", e); - } - - // construct Vertex AI instance - var vertexAI = new VertexAI.Builder() - .setProjectId(providerConfig.projectId()) - .setLocation(providerConfig.location()) - .setPredictionClientSupplier(() -> createPredictionServiceClient(predictionServiceClientSettings)) - .setTransport(Transport.REST) // GRPC also possible, but likely does not work with service account keys + return GoogleGenAiChatModel.builder() + .projectId(chatModelConfig.providerConfig().projectId()) + .location(chatModelConfig.providerConfig().location()) + .googleCredentials(credentials) + .modelName(chatModelConfig.modelId()) + .temperature(chatModelConfig.temperature()) + .topP(chatModelConfig.topP()) + .topK(chatModelConfig.topK()) + .frequencyPenalty(chatModelConfig.frequencyPenalty()) + .presencePenalty(chatModelConfig.presencePenalty()) + .maxOutputTokens(chatModelConfig.maxOutputTokens()) + .timeout(toDuration(chatModelConfig.timeoutSeconds())) + .maxRetries(chatModelConfig.maxRetries()) .build(); - - // map model config to generation config - var generationConfigBuilder = GenerationConfig.newBuilder(); - if (chatModelConfig.temperature() != null) { - generationConfigBuilder.setTemperature(chatModelConfig.temperature().floatValue()); - } - if (chatModelConfig.topP() != null) { - generationConfigBuilder.setTopP(chatModelConfig.topP().floatValue()); - } - if (chatModelConfig.topK() != null) { - generationConfigBuilder.setTopK(chatModelConfig.topK()); - } - if (chatModelConfig.frequencyPenalty() != null) { - generationConfigBuilder.setFrequencyPenalty(chatModelConfig.frequencyPenalty().floatValue()); - } - if (chatModelConfig.presencePenalty() != null) { - generationConfigBuilder.setPresencePenalty(chatModelConfig.presencePenalty().floatValue()); - } - if (chatModelConfig.maxOutputTokens() != null) { - generationConfigBuilder.setMaxOutputTokens(chatModelConfig.maxOutputTokens()); - } - var generationConfig = generationConfigBuilder.build(); - - // construct generative model instance - var generativeModel = new GenerativeModel(chatModelConfig.modelId(), vertexAI).withGenerationConfig(generationConfig); - - return new VertexAiGeminiChatModel(generativeModel, generationConfig, chatModelConfig.maxRetries()); - } - - private static PredictionServiceClient createPredictionServiceClient(PredictionServiceSettings settings) { - try { - return PredictionServiceClient.create(settings); - } catch (IOException e) { - throw new RuntimeException("Failed to create prediction service client", e); - } } @Override @@ -262,14 +197,16 @@ class Langchain4jChatModelConfigurerImpl implements Langchain4jChatModelConfigur @Override public ChatModel configureChatModel(GitHubModelsChatModelConfig chatModelConfig) { - return GitHubModelsChatModel.builder() - .gitHubToken(chatModelConfig.providerConfig().personalAccessToken()) + return OpenAiOfficialChatModel.builder() + .isGitHubModels(true) + .strictJsonSchema(true) + .apiKey(chatModelConfig.providerConfig().personalAccessToken()) .modelName(chatModelConfig.modelId()) .temperature(chatModelConfig.temperature()) .topP(chatModelConfig.topP()) .frequencyPenalty(chatModelConfig.frequencyPenalty()) .presencePenalty(chatModelConfig.presencePenalty()) - .maxTokens(chatModelConfig.maxOutputTokens()) + .maxCompletionTokens(chatModelConfig.maxOutputTokens()) .timeout(toDuration(chatModelConfig.timeoutSeconds())) .maxRetries(chatModelConfig.maxRetries()) .build(); diff --git a/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/SimpleCalculatedFieldState.java b/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/SimpleCalculatedFieldState.java index 7f21f8d2e7..a837b5ebde 100644 --- a/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/SimpleCalculatedFieldState.java +++ b/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/SimpleCalculatedFieldState.java @@ -67,6 +67,8 @@ public class SimpleCalculatedFieldState extends BaseCalculatedFieldState { ObjectNode valuesNode = JacksonUtil.newObjectNode(); if (result instanceof Double doubleValue) { valuesNode.put(outputName, doubleValue); + } else if (result instanceof Long longValue) { + valuesNode.put(outputName, longValue); } else if (result instanceof Integer integerValue) { valuesNode.put(outputName, integerValue); } else { diff --git a/application/src/main/java/org/thingsboard/server/service/entitiy/queue/DefaultTbQueueService.java b/application/src/main/java/org/thingsboard/server/service/entitiy/queue/DefaultTbQueueService.java index 87c1aaa707..4e42b41dd2 100644 --- a/application/src/main/java/org/thingsboard/server/service/entitiy/queue/DefaultTbQueueService.java +++ b/application/src/main/java/org/thingsboard/server/service/entitiy/queue/DefaultTbQueueService.java @@ -27,6 +27,7 @@ import org.thingsboard.server.common.data.tenant.profile.TenantProfileQueueConfi import org.thingsboard.server.common.msg.queue.TopicPartitionInfo; import org.thingsboard.server.dao.queue.QueueService; import org.thingsboard.server.queue.TbQueueAdmin; +import org.thingsboard.server.queue.discovery.TopicService; import org.thingsboard.server.queue.util.TbCoreComponent; import org.thingsboard.server.service.entitiy.AbstractTbEntityService; @@ -45,6 +46,7 @@ public class DefaultTbQueueService extends AbstractTbEntityService implements Tb private final QueueService queueService; private final TbClusterService tbClusterService; private final TbQueueAdmin tbQueueAdmin; + private final TopicService topicService; @Override public Queue saveQueue(Queue queue) { @@ -173,9 +175,10 @@ public class DefaultTbQueueService extends AbstractTbEntityService implements Tb private void createTopicsIfNeeded(Queue queue, Queue oldQueue) { int newPartitions = queue.getPartitions(); int oldPartitions = oldQueue != null ? oldQueue.getPartitions() : 0; + String topic = topicService.buildTopicName(queue.getTopic()); for (int i = oldPartitions; i < newPartitions; i++) { tbQueueAdmin.createTopicIfNotExists( - new TopicPartitionInfo(queue.getTopic(), queue.getTenantId(), i, false).getFullTopicName(), + new TopicPartitionInfo(topic, queue.getTenantId(), i, false).getFullTopicName(), queue.getCustomProperties(), true); // forcing topic creation because the topic may still be cached on some nodes } diff --git a/application/src/main/java/org/thingsboard/server/service/queue/DefaultTbCoreConsumerService.java b/application/src/main/java/org/thingsboard/server/service/queue/DefaultTbCoreConsumerService.java index bf53a8b401..ffb6af8319 100644 --- a/application/src/main/java/org/thingsboard/server/service/queue/DefaultTbCoreConsumerService.java +++ b/application/src/main/java/org/thingsboard/server/service/queue/DefaultTbCoreConsumerService.java @@ -464,10 +464,10 @@ public class DefaultTbCoreConsumerService extends AbstractConsumerService 0 ? RpcError.values()[proto.getError()] : null; + void forwardToCoreRpcService(FromDeviceRPCResponseProto proto, TbCallback callback) { + RpcError error = RpcError.fromProtoErrorCode(proto.getError()); FromDeviceRpcResponse response = new FromDeviceRpcResponse(new UUID(proto.getRequestIdMSB(), proto.getRequestIdLSB()) - , proto.getResponse(), error); + , proto.hasResponse() ? proto.getResponse() : null, error); tbCoreDeviceRpcService.processRpcResponseFromRuleEngine(response); callback.onSuccess(); } diff --git a/application/src/main/java/org/thingsboard/server/service/queue/DefaultTbRuleEngineConsumerService.java b/application/src/main/java/org/thingsboard/server/service/queue/DefaultTbRuleEngineConsumerService.java index 74e0de9ea6..bb70a5f136 100644 --- a/application/src/main/java/org/thingsboard/server/service/queue/DefaultTbRuleEngineConsumerService.java +++ b/application/src/main/java/org/thingsboard/server/service/queue/DefaultTbRuleEngineConsumerService.java @@ -179,9 +179,9 @@ public class DefaultTbRuleEngineConsumerService extends AbstractPartitionBasedCo callback.onSuccess(); } else if (nfMsg.hasFromDeviceRpcResponse()) { TransportProtos.FromDeviceRPCResponseProto proto = nfMsg.getFromDeviceRpcResponse(); - RpcError error = proto.getError() > 0 ? RpcError.values()[proto.getError()] : null; + RpcError error = RpcError.fromProtoErrorCode(proto.getError()); FromDeviceRpcResponse response = new FromDeviceRpcResponse(new UUID(proto.getRequestIdMSB(), proto.getRequestIdLSB()) - , proto.getResponse(), error); + , proto.hasResponse() ? proto.getResponse() : null, error); tbDeviceRpcService.processRpcResponseFromDevice(response); callback.onSuccess(); } else if (nfMsg.getQueueUpdateMsgsCount() > 0) { diff --git a/application/src/main/resources/thingsboard.yml b/application/src/main/resources/thingsboard.yml index 454c59d7d7..b5a8003241 100644 --- a/application/src/main/resources/thingsboard.yml +++ b/application/src/main/resources/thingsboard.yml @@ -1158,6 +1158,8 @@ transport: timeout: "${CLIENT_SIDE_RPC_TIMEOUT:60000}" # Enable/disable http/mqtt/coap/lwm2m transport protocols (has higher priority than certain protocol's 'enabled' property) api_enabled: "${TB_TRANSPORT_API_ENABLED:true}" + # Size of the thread pool that executes transport API callbacks (session registration, telemetry/attribute and RPC responses, entity update notifications, and the tenant profile fetch on a cache miss). Bounds how many such callbacks - including those that block on a backend round-trip - can run concurrently. + callback_thread_pool_size: "${TB_TRANSPORT_CALLBACK_THREAD_POOL_SIZE:20}" log: # Enable/Disable log of transport messages to telemetry. For example, logging of LwM2M registration update enabled: "${TB_TRANSPORT_LOG_ENABLED:true}" diff --git a/application/src/test/java/org/thingsboard/server/client/AlarmCommentApiClientTest.java b/application/src/test/java/org/thingsboard/server/client/AlarmCommentApiClientTest.java index 31da96b7b4..50707e4c38 100644 --- a/application/src/test/java/org/thingsboard/server/client/AlarmCommentApiClientTest.java +++ b/application/src/test/java/org/thingsboard/server/client/AlarmCommentApiClientTest.java @@ -100,7 +100,7 @@ public class AlarmCommentApiClientTest extends AbstractApiClientTest { .filter(alarmCommentInfo -> alarmCommentInfo.getId().getId().equals(commentToDeleteId)) .findFirst() .get(); - assertEquals("User " + clientTenantAdmin.getEmail() + " deleted his comment", deletedComment.getComment().get("text").asText()); + assertEquals("Comment was deleted by user " + clientTenantAdmin.getEmail(), deletedComment.getComment().get("text").asText()); } } diff --git a/application/src/test/java/org/thingsboard/server/client/ClientDocsExampleTest.java b/application/src/test/java/org/thingsboard/server/client/ClientDocsExampleTest.java new file mode 100644 index 0000000000..e75f3b29c0 --- /dev/null +++ b/application/src/test/java/org/thingsboard/server/client/ClientDocsExampleTest.java @@ -0,0 +1,318 @@ +/** + * Copyright © 2016-2026 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.client; + +import org.junit.Test; +import org.thingsboard.client.ApiException; +import org.thingsboard.client.ThingsboardClient; +import org.thingsboard.client.model.ApiKeyInfo; +import org.thingsboard.client.model.Asset; +import org.thingsboard.client.model.AttributeData; +import org.thingsboard.client.model.BooleanFilterPredicate; +import org.thingsboard.client.model.BooleanOperation; +import org.thingsboard.client.model.Device; +import org.thingsboard.client.model.EntityCountQuery; +import org.thingsboard.client.model.EntityKey; +import org.thingsboard.client.model.EntityKeyType; +import org.thingsboard.client.model.EntityKeyValueType; +import org.thingsboard.client.model.EntityType; +import org.thingsboard.client.model.EntityTypeFilter; +import org.thingsboard.client.model.FilterPredicateValueBoolean; +import org.thingsboard.client.model.KeyFilter; +import org.thingsboard.client.model.PageDataDevice; +import org.thingsboard.client.model.TsData; +import org.thingsboard.server.dao.service.DaoSqlTest; + +import java.util.List; +import java.util.Map; +import java.util.UUID; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; +import static org.junit.Assert.fail; + +/** + * Mirrors every code snippet from the Java client documentation page + * ({@code /docs/reference/java-client/}, CE edition). Each snippet appears + * character-for-character with two allowances: + *
    + *
  • placeholder values ({@code "{BASE_URL}"}, {@code "YOUR_API_KEY_VALUE"}, + * {@code "YOUR_DEVICE_ID"}, {@code "YOUR_ASSET_ID"}, + * {@code "nonexistent-id"}, {@code "tenant@thingsboard.org"}, + * {@code "tenant"}) are swapped for real test values;
  • + *
  • {@code System.out.println} / {@code System.out.printf} calls inside the + * snippet are replaced with equivalent JUnit assertions, so the test + * actually verifies behavior instead of only compilation.
  • + *
+ * Setup code that pre-creates entities required by a snippet and post-snippet + * verifications stay outside the snippet block. + */ +@DaoSqlTest +public class ClientDocsExampleTest extends AbstractApiClientTest { + + // /docs/reference/java-client/#quickstart + @Test + public void testQuickstart() throws Exception { + // setup: real API key for the snippet's "YOUR_API_KEY_VALUE" placeholder + ApiKeyInfo keyRequest = new ApiKeyInfo(); + keyRequest.setDescription("ClientDocsExampleTest"); + keyRequest.setUserId(clientTenantAdmin.getId()); + keyRequest.setEnabled(true); + String apiKeyValue = this.client.saveApiKey(keyRequest).getValue(); + + // === doc snippet === + ThingsboardClient client = ThingsboardClient.builder() + .url(getBaseUrl()) + .apiKey(apiKeyValue) + .build(); + + Device newDevice = new Device(); + newDevice.setName("Quickstart Device"); + newDevice.setType("default"); + Device savedDevice = client.saveDevice(newDevice, null, null, null, null); + + String deviceId = savedDevice.getId().getId().toString(); + client.saveEntityTelemetry("DEVICE", deviceId, "ANY", """ + {"temperature": 22.4} + """); + assertEquals("Quickstart Device", savedDevice.getName()); + + client.deleteDevice(deviceId); + + // post-snippet verification: the device is gone after deletion + assertReturns404(() -> client.getDeviceById(deviceId)); + } + + // /docs/reference/java-client/#api-key-recommended + @Test + public void testAuthenticationViaApiKey() throws Exception { + // setup: real API key for the snippet's "YOUR_API_KEY_VALUE" placeholder + ApiKeyInfo keyRequest = new ApiKeyInfo(); + keyRequest.setDescription("ClientDocsExampleTest"); + keyRequest.setUserId(clientTenantAdmin.getId()); + keyRequest.setEnabled(true); + String apiKeyValue = this.client.saveApiKey(keyRequest).getValue(); + + // === doc snippet === + String url = getBaseUrl(); + String apiKey = apiKeyValue; + ThingsboardClient client = ThingsboardClient.builder() + .url(url) + .apiKey(apiKey) + .build(); + + assertEquals(TENANT_ADMIN_USERNAME, client.getUser().getEmail()); + } + + // /docs/reference/java-client/#username-and-password-jwt + @Test + public void testAuthenticationViaCredentials() throws Exception { + // === doc snippet === + String url = getBaseUrl(); + ThingsboardClient client = ThingsboardClient.builder() + .url(url) + .credentials(TENANT_ADMIN_USERNAME, TEST_PASSWORD) + .build(); + + assertEquals(TENANT_ADMIN_USERNAME, client.getUser().getEmail()); + } + + // /docs/reference/java-client/#rate-limit-handling + @Test + public void testRateLimitHandlingBuilderOptions() throws Exception { + // setup: real url + api key that the snippet references as locals + String url = getBaseUrl(); + ApiKeyInfo keyRequest = new ApiKeyInfo(); + keyRequest.setDescription("ClientDocsExampleTest"); + keyRequest.setUserId(clientTenantAdmin.getId()); + keyRequest.setEnabled(true); + String apiKey = this.client.saveApiKey(keyRequest).getValue(); + + // === doc snippet === + ThingsboardClient client = ThingsboardClient.builder() + .url(url) + .apiKey(apiKey) + .maxRetries(3) // default 3 + .initialRetryDelayMs(1000) // default 1 s + .maxRetryDelayMs(30_000) // default 30 s + .build(); + + // post-snippet verification: the tuned client is actually usable + assertEquals(TENANT_ADMIN_USERNAME, client.getUser().getEmail()); + } + + // /docs/reference/java-client/#working-with-entities + @Test + public void testWorkingWithEntities() throws Exception { + // === doc snippet === + Device newDevice = new Device(); + newDevice.setName("Test Device"); + newDevice.setType("default"); + Device savedDevice = client.saveDevice(newDevice, null, null, null, null); + + String deviceId = savedDevice.getId().getId().toString(); + Device fetched = client.getDeviceById(deviceId); + assertEquals("Test Device", fetched.getName()); + + client.deleteDevice(deviceId); + + // post-snippet verification: the device is gone after deletion + assertReturns404(() -> client.getDeviceById(deviceId)); + } + + // /docs/reference/java-client/#push-telemetry + @Test + public void testPushTelemetry() throws Exception { + // setup: create a real device whose id replaces "YOUR_DEVICE_ID" + Device setup = new Device(); + setup.setName("Telemetry Setup Device"); + setup.setType("default"); + String realDeviceId = client.saveDevice(setup, null, null, null, null) + .getId().getId().toString(); + + // === doc snippet === + String deviceId = realDeviceId; + String body = """ + {"temperature": 26.5, "humidity": 87} + """; + client.saveEntityTelemetry("DEVICE", deviceId, "ANY", body); + + // post-snippet verification: telemetry was actually persisted + Map> latest = + client.getLatestTimeseries("DEVICE", deviceId, "temperature,humidity", false, null); + assertEquals("26.5", latest.get("temperature").get(0).getValue().toString()); + assertEquals("87", latest.get("humidity").get(0).getValue().toString()); + } + + // /docs/reference/java-client/#read-and-write-attributes-read-modify-write + @Test + public void testReadModifyWriteAttributes() throws Exception { + // setup: create a real asset whose id replaces "YOUR_ASSET_ID" + Asset setupAsset = new Asset(); + setupAsset.setName("Counter Setup Asset"); + setupAsset.setType("building"); + String realAssetId = client.saveAsset(setupAsset, null, null, null) + .getId().getId().toString(); + + // === doc snippet === + String assetId = realAssetId; + + List attrs = client.getAttributesByScope( + "ASSET", assetId, "SERVER_SCOPE", "deviceCount", null); + + // getValue() returns Object — JSON numbers come back as Number subclasses + long current = attrs.isEmpty() ? 0L : ((Number) attrs.get(0).getValue()).longValue(); + long updated = current + 1; + + client.saveEntityAttributesV2("ASSET", assetId, "SERVER_SCOPE", + "{\"deviceCount\": %d}".formatted(updated)); + + // post-snippet verification: the increment was actually persisted + List after = client.getAttributesByScope( + "ASSET", assetId, "SERVER_SCOPE", "deviceCount", null); + assertEquals(1, after.size()); + assertEquals(updated, ((Number) after.get(0).getValue()).longValue()); + } + + // /docs/reference/java-client/#paginated-tenant-list + @Test + public void testPaginatedTenantList() throws Exception { + // setup: populate the tenant with a few devices so the iteration has something to walk + int expectedDeviceCount = 5; + for (int i = 0; i < expectedDeviceCount; i++) { + Device d = new Device(); + d.setName("Page Setup Device " + i); + d.setType("default"); + client.saveDevice(d, null, null, null, null); + } + + // === doc snippet === + int page = 0; // pages are zero-indexed + PageDataDevice devices; + do { + devices = client.getTenantDevices(100, page, null, null, null, null); + devices.getData().forEach(d -> assertEquals("default", d.getType())); + page++; + } while (devices.getHasNext()); + + // post-snippet verification: pagination terminated and reached every device + assertEquals((long) expectedDeviceCount, devices.getTotalElements().longValue()); + } + + // /docs/reference/java-client/#filtered-query-with-entity-data-query-api + @Test + public void testEntityDataQueryCountFiltered() throws Exception { + // setup: create a mix of active and inactive devices for the count query + Device active1 = client.saveDevice( + new Device().name("Active_1").type("default"), + null, null, null, null); + Device active2 = client.saveDevice( + new Device().name("Active_2").type("default"), + null, null, null, null); + client.saveDevice( + new Device().name("Inactive_1").type("default"), + null, null, null, null); + client.saveEntityAttributesV2("DEVICE", active1.getId().getId().toString(), + "SERVER_SCOPE", "{\"active\": true}"); + client.saveEntityAttributesV2("DEVICE", active2.getId().getId().toString(), + "SERVER_SCOPE", "{\"active\": true}"); + + // === doc snippet === + EntityTypeFilter typeFilter = new EntityTypeFilter(); + typeFilter.setEntityType(EntityType.DEVICE); + + EntityCountQuery totalQuery = new EntityCountQuery(); + totalQuery.setEntityFilter(typeFilter); + assertEquals(3L, client.countEntitiesByQuery(totalQuery).longValue()); + + KeyFilter activeFilter = new KeyFilter(); + activeFilter.setKey(new EntityKey().type(EntityKeyType.ATTRIBUTE).key("active")); + activeFilter.setValueType(EntityKeyValueType.BOOLEAN); + BooleanFilterPredicate predicate = new BooleanFilterPredicate(); + predicate.setOperation(BooleanOperation.EQUAL); + predicate.setValue(new FilterPredicateValueBoolean().defaultValue(true)); + activeFilter.setPredicate(predicate); + + EntityCountQuery activeQuery = new EntityCountQuery(); + activeQuery.setEntityFilter(typeFilter); + activeQuery.setKeyFilters(List.of(activeFilter)); + assertEquals(2L, client.countEntitiesByQuery(activeQuery).longValue()); + } + + // /docs/reference/java-client/#error-handling + @Test + public void testErrorHandling404() { + // setup: a real (random) UUID that doesn't resolve, replacing "nonexistent-id"; + // the flag captures whether the 404 branch ran so we can assert the snippet + // actually entered error handling (instead of silently completing). + String missingDeviceId = UUID.randomUUID().toString(); + boolean[] caught404 = {false}; + + // === doc snippet === + try { + Device device = client.getDeviceById(missingDeviceId); + } catch (ApiException e) { + if (e.getCode() == 404) { + caught404[0] = true; + } else { + fail("API error " + e.getCode() + ": " + e.getResponseBody()); + } + } + + // post-snippet verification: the snippet actually exercised the 404 branch + assertTrue("Expected ApiException with code 404", caught404[0]); + } +} diff --git a/application/src/test/java/org/thingsboard/server/controller/AbstractWebTest.java b/application/src/test/java/org/thingsboard/server/controller/AbstractWebTest.java index 7803b0c746..bca56deaf7 100644 --- a/application/src/test/java/org/thingsboard/server/controller/AbstractWebTest.java +++ b/application/src/test/java/org/thingsboard/server/controller/AbstractWebTest.java @@ -229,6 +229,7 @@ public abstract class AbstractWebTest extends AbstractInMemoryStorageTest { protected static final String DIFFERENT_TENANT_ADMIN_PASSWORD = "difftenant"; protected static final String CUSTOMER_USER_EMAIL = "testcustomer@thingsboard.org"; + protected static final String SECOND_CUSTOMER_USER_EMAIL = "testsecondcustomer@thingsboard.org"; private static final String CUSTOMER_USER_PASSWORD = "customer"; protected static final String DIFFERENT_CUSTOMER_USER_EMAIL = "testdifferentcustomer@thingsboard.org"; @@ -268,6 +269,7 @@ public abstract class AbstractWebTest extends AbstractInMemoryStorageTest { protected CustomerId differentTenantCustomerId; protected UserId customerUserId; + protected UserId secondCustomerUserId; protected UserId differentCustomerUserId; protected UserId differentTenantCustomerUserId; @@ -393,9 +395,17 @@ public abstract class AbstractWebTest extends AbstractInMemoryStorageTest { customerUser.setCustomerId(savedCustomer.getId()); customerUser.setEmail(CUSTOMER_USER_EMAIL); - customerUser = createUserAndLogin(customerUser, CUSTOMER_USER_PASSWORD); + customerUser = createUserAndActivate(customerUser, CUSTOMER_USER_PASSWORD); customerUserId = customerUser.getId(); + User secondCustomerUser = new User(); + secondCustomerUser.setAuthority(Authority.CUSTOMER_USER); + secondCustomerUser.setTenantId(tenantId); + secondCustomerUser.setCustomerId(customerId); + secondCustomerUser.setEmail(SECOND_CUSTOMER_USER_EMAIL); + secondCustomerUser = createUserAndActivate(secondCustomerUser, CUSTOMER_USER_PASSWORD); + secondCustomerUserId = secondCustomerUser.getId(); + resetTokens(); log.debug("Executed web test setup"); @@ -494,6 +504,10 @@ public abstract class AbstractWebTest extends AbstractInMemoryStorageTest { login(CUSTOMER_USER_EMAIL, CUSTOMER_USER_PASSWORD); } + protected void loginSecondCustomerUser() throws Exception { + login(SECOND_CUSTOMER_USER_EMAIL, CUSTOMER_USER_PASSWORD); + } + protected void loginUser(String userName, String password) throws Exception { login(userName, password); } @@ -608,6 +622,13 @@ public abstract class AbstractWebTest extends AbstractInMemoryStorageTest { return savedUser; } + protected User createUserAndActivate(User user, String password) throws Exception { + User savedUser = doPost("/api/user", user, User.class); + JsonNode activateRequest = getActivateRequest(password); + doPost("/api/noauth/activate", activateRequest).andExpect(status().isOk()); + return savedUser; + } + protected User createUser(User user, String password) throws Exception { User savedUser = doPost("/api/user", user, User.class); JsonNode activateRequest = getActivateRequest(password); diff --git a/application/src/test/java/org/thingsboard/server/controller/AlarmCommentControllerTest.java b/application/src/test/java/org/thingsboard/server/controller/AlarmCommentControllerTest.java index 89eb140c8b..c778916d02 100644 --- a/application/src/test/java/org/thingsboard/server/controller/AlarmCommentControllerTest.java +++ b/application/src/test/java/org/thingsboard/server/controller/AlarmCommentControllerTest.java @@ -161,6 +161,25 @@ public class AlarmCommentControllerTest extends AbstractControllerTest { testLogEntityActionEntityEqClass(alarm, alarm.getId(), tenantId, customerId, tenantAdminUserId, TENANT_ADMIN_EMAIL, ActionType.UPDATED_COMMENT, 1, updatedAlarmComment); } + @Test + public void testEditOthersAlarmCommentIsProhibited() throws Exception { + loginCustomerUser(); + AlarmComment alarmComment = createAlarmComment(alarm.getId()); + + JsonNode newComment = JacksonUtil.newObjectNode().set("text", new TextNode("Second customer rewrite")); + alarmComment.setComment(newComment); + + loginSecondCustomerUser(); + doPost("/api/alarm/" + alarm.getId() + "/comment", alarmComment) + .andExpect(status().isForbidden()) + .andExpect(statusReason(containsString("User is not allowed to edit other user's comment"))); + + loginTenantAdmin(); + doPost("/api/alarm/" + alarm.getId() + "/comment", alarmComment) + .andExpect(status().isForbidden()) + .andExpect(statusReason(containsString("User is not allowed to edit other user's comment"))); + } + @Test public void testUpdateAlarmViaDifferentTenant() throws Exception { loginTenantAdmin(); @@ -218,6 +237,32 @@ public class AlarmCommentControllerTest extends AbstractControllerTest { testLogEntityActionEntityEqClass(alarm, alarm.getId(), tenantId, customerId, customerUserId, CUSTOMER_USER_EMAIL, ActionType.DELETED_COMMENT, 1, expectedAlarmComment); } + @Test + public void testDeleteOthersAlarmCommentIsAllowedForAuthorOrTenantAdmin() throws Exception { + loginCustomerUser(); + AlarmComment alarmComment = createAlarmComment(alarm.getId()); + + loginSecondCustomerUser(); + Mockito.reset(tbClusterService, auditLogService); + + doDelete("/api/alarm/" + alarm.getId() + "/comment/" + alarmComment.getId()) + .andExpect(status().isForbidden()) + .andExpect(statusReason(containsString("User is not allowed to delete other user's comment"))); + + loginTenantAdmin(); + doDelete("/api/alarm/" + alarm.getId() + "/comment/" + alarmComment.getId()) + .andExpect(status().isOk()); + AlarmComment expectedAlarmComment = AlarmComment.builder() + .alarmId(alarm.getId()) + .type(AlarmCommentType.SYSTEM) + .comment(JacksonUtil.newObjectNode() + .put("text", String.format(COMMENT_DELETED.getText(), TENANT_ADMIN_EMAIL)) + .put("subtype", COMMENT_DELETED.name()) + .put("userName", TENANT_ADMIN_EMAIL)) + .build(); + testLogEntityActionEntityEqClass(alarm, alarm.getId(), tenantId, customerId, tenantAdminUserId, TENANT_ADMIN_EMAIL, ActionType.DELETED_COMMENT, 1, expectedAlarmComment); + } + @Test public void testDeleteAlarmViaTenant() throws Exception { loginTenantAdmin(); @@ -237,7 +282,7 @@ public class AlarmCommentControllerTest extends AbstractControllerTest { assertThat(systemComment.getId()).isEqualTo(alarmComment.getId()); assertThat(systemComment.getType()).isEqualTo(AlarmCommentType.SYSTEM); - assertThat(systemComment.getComment().get("text").asText()).isEqualTo(String.format("User %s deleted his comment", + assertThat(systemComment.getComment().get("text").asText()).isEqualTo(String.format("Comment was deleted by user %s", TENANT_ADMIN_EMAIL)); AlarmComment expectedAlarmComment = AlarmComment.builder() diff --git a/application/src/test/java/org/thingsboard/server/controller/HomePageApiTest.java b/application/src/test/java/org/thingsboard/server/controller/HomePageApiTest.java index efe553e8f2..2f37ed653d 100644 --- a/application/src/test/java/org/thingsboard/server/controller/HomePageApiTest.java +++ b/application/src/test/java/org/thingsboard/server/controller/HomePageApiTest.java @@ -410,7 +410,7 @@ public class HomePageApiTest extends AbstractControllerTest { Assert.assertEquals(1, usageInfo.getCustomers()); Assert.assertEquals(configuration.getMaxCustomers(), usageInfo.getMaxCustomers()); - Assert.assertEquals(2, usageInfo.getUsers()); + Assert.assertEquals(3, usageInfo.getUsers()); Assert.assertEquals(configuration.getMaxUsers(), usageInfo.getMaxUsers()); Assert.assertEquals(DEFAULT_DASHBOARDS_COUNT, usageInfo.getDashboards()); @@ -476,7 +476,7 @@ public class HomePageApiTest extends AbstractControllerTest { } usageInfo = doGet("/api/usage", UsageInfo.class); - Assert.assertEquals(users.size() + 2, usageInfo.getUsers()); + Assert.assertEquals(users.size() + 3, usageInfo.getUsers()); List dashboards = new ArrayList<>(); for (int i = 0; i < 97; i++) { diff --git a/application/src/test/java/org/thingsboard/server/controller/UserControllerTest.java b/application/src/test/java/org/thingsboard/server/controller/UserControllerTest.java index 751631aec2..998187da31 100644 --- a/application/src/test/java/org/thingsboard/server/controller/UserControllerTest.java +++ b/application/src/test/java/org/thingsboard/server/controller/UserControllerTest.java @@ -717,6 +717,7 @@ public class UserControllerTest extends AbstractControllerTest { String email = "testEmail1"; List expectedCustomerUserIds = new ArrayList<>(); expectedCustomerUserIds.add(customerUserId); + expectedCustomerUserIds.add(secondCustomerUserId); for (int i = 0; i < 45; i++) { User customerUser = createCustomerUser(customerId); customerUser.setEmail(email + StringUtils.randomAlphanumeric((int) (5 + Math.random() * 10)) + "@thingsboard.org"); diff --git a/application/src/test/java/org/thingsboard/server/service/ai/Langchain4jChatModelConfigurerImplTest.java b/application/src/test/java/org/thingsboard/server/service/ai/Langchain4jChatModelConfigurerImplTest.java index fb9807a2a8..c2f7c39ce5 100644 --- a/application/src/test/java/org/thingsboard/server/service/ai/Langchain4jChatModelConfigurerImplTest.java +++ b/application/src/test/java/org/thingsboard/server/service/ai/Langchain4jChatModelConfigurerImplTest.java @@ -15,20 +15,29 @@ */ package org.thingsboard.server.service.ai; -import com.google.cloud.vertexai.api.GenerationConfig; +import dev.langchain4j.model.ModelProvider; import dev.langchain4j.model.chat.ChatModel; +import dev.langchain4j.model.chat.request.ChatRequestParameters; import org.junit.jupiter.api.AfterEach; -import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.parallel.ResourceLock; -import org.springframework.test.util.ReflectionTestUtils; import org.thingsboard.common.util.SsrfProtectionValidator; +import org.thingsboard.server.common.data.ai.model.chat.AmazonBedrockChatModelConfig; +import org.thingsboard.server.common.data.ai.model.chat.AnthropicChatModelConfig; import org.thingsboard.server.common.data.ai.model.chat.AzureOpenAiChatModelConfig; +import org.thingsboard.server.common.data.ai.model.chat.GitHubModelsChatModelConfig; +import org.thingsboard.server.common.data.ai.model.chat.GoogleAiGeminiChatModelConfig; import org.thingsboard.server.common.data.ai.model.chat.GoogleVertexAiGeminiChatModelConfig; +import org.thingsboard.server.common.data.ai.model.chat.MistralAiChatModelConfig; 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.AmazonBedrockProviderConfig; +import org.thingsboard.server.common.data.ai.provider.AnthropicProviderConfig; import org.thingsboard.server.common.data.ai.provider.AzureOpenAiProviderConfig; +import org.thingsboard.server.common.data.ai.provider.GitHubModelsProviderConfig; +import org.thingsboard.server.common.data.ai.provider.GoogleAiGeminiProviderConfig; import org.thingsboard.server.common.data.ai.provider.GoogleVertexAiGeminiProviderConfig; +import org.thingsboard.server.common.data.ai.provider.MistralAiProviderConfig; import org.thingsboard.server.common.data.ai.provider.OllamaProviderConfig; import org.thingsboard.server.common.data.ai.provider.OpenAiProviderConfig; @@ -53,18 +62,280 @@ class Langchain4jChatModelConfigurerImplTest { private final Langchain4jChatModelConfigurerImpl configurer = new Langchain4jChatModelConfigurerImpl(); - @BeforeEach - void enableSsrfProtection() { - SsrfProtectionValidator.setEnabled(true); - } - @AfterEach - void disableSsrfProtection() { + void resetSsrfProtection() { SsrfProtectionValidator.setEnabled(false); } + // ============================== Configuration correctness (one per provider) ============================== + // For each provider we feed a fully populated config and assert that the returned ChatModel carries the same + // values, using only the public ChatModel surface (provider() and defaultRequestParameters()) — no reflection. + + @Test + void shouldConfigureOpenAiModel_whenGivenOpenAiConfig() { + // GIVEN + var config = OpenAiChatModelConfig.builder() + .providerConfig(OpenAiProviderConfig.builder() + .baseUrl("https://api.openai.com/v1") + .apiKey("test-key") + .build()) + .modelId("gpt-4o") + .temperature(0.7) + .topP(0.9) + .frequencyPenalty(0.5) + .presencePenalty(0.25) + .maxOutputTokens(500) + .timeoutSeconds(60) + .maxRetries(3) + .build(); + + // WHEN + ChatModel chatModel = configurer.configureChatModel(config); + + // THEN + assertThat(chatModel.provider()).isEqualTo(ModelProvider.OPEN_AI); + ChatRequestParameters params = chatModel.defaultRequestParameters(); + assertThat(params.modelName()).isEqualTo("gpt-4o"); + assertThat(params.temperature()).isEqualTo(0.7); + assertThat(params.topP()).isEqualTo(0.9); + assertThat(params.frequencyPenalty()).isEqualTo(0.5); + assertThat(params.presencePenalty()).isEqualTo(0.25); + assertThat(params.maxOutputTokens()).isEqualTo(500); + } + + @Test + void shouldConfigureAzureOpenAiModel_whenGivenAzureOpenAiConfig() { + // GIVEN + var config = AzureOpenAiChatModelConfig.builder() + .providerConfig(new AzureOpenAiProviderConfig( + "https://my-resource.openai.azure.com/", "2024-05-01-preview", "test-key")) + .modelId("gpt-4o") + .temperature(0.7) + .topP(0.9) + .frequencyPenalty(0.5) + .presencePenalty(0.25) + .maxOutputTokens(500) + .timeoutSeconds(60) + .maxRetries(3) + .build(); + + // WHEN + ChatModel chatModel = configurer.configureChatModel(config); + + // THEN + assertThat(chatModel.provider()).isEqualTo(ModelProvider.AZURE_OPEN_AI); + ChatRequestParameters params = chatModel.defaultRequestParameters(); + assertThat(params.modelName()).isEqualTo("gpt-4o"); // deployment name maps to modelName + assertThat(params.temperature()).isEqualTo(0.7); + assertThat(params.topP()).isEqualTo(0.9); + assertThat(params.frequencyPenalty()).isEqualTo(0.5); + assertThat(params.presencePenalty()).isEqualTo(0.25); + assertThat(params.maxOutputTokens()).isEqualTo(500); + } + + @Test + void shouldConfigureGoogleAiGeminiModel_whenGivenGoogleAiGeminiConfig() { + // GIVEN + var config = GoogleAiGeminiChatModelConfig.builder() + .providerConfig(new GoogleAiGeminiProviderConfig("test-key")) + .modelId("gemini-2.5-flash") + .temperature(0.7) + .topP(0.9) + .topK(40) + .maxOutputTokens(500) + .timeoutSeconds(60) + .maxRetries(3) + .build(); + + // WHEN + ChatModel chatModel = configurer.configureChatModel(config); + + // THEN + assertThat(chatModel.provider()).isEqualTo(ModelProvider.GOOGLE_GENAI); + ChatRequestParameters params = chatModel.defaultRequestParameters(); + assertThat(params.modelName()).isEqualTo("gemini-2.5-flash"); + assertThat(params.temperature()).isEqualTo(0.7); + assertThat(params.topP()).isEqualTo(0.9); + assertThat(params.topK()).isEqualTo(40); + assertThat(params.maxOutputTokens()).isEqualTo(500); + } + + @Test + void shouldConfigureGoogleVertexAiGeminiModel_whenGivenGoogleVertexAiGeminiConfig() { + // GIVEN + var config = GoogleVertexAiGeminiChatModelConfig.builder() + .providerConfig(new GoogleVertexAiGeminiProviderConfig( + "key.json", "test-project", "us-central1", TEST_SERVICE_ACCOUNT_KEY)) + .modelId("gemini-2.5-flash") + .temperature(0.7) + .topP(0.9) + .topK(40) + .maxOutputTokens(500) + .timeoutSeconds(60) + .maxRetries(3) + .build(); + + // WHEN + ChatModel chatModel = configurer.configureChatModel(config); + + // THEN + assertThat(chatModel.provider()).isEqualTo(ModelProvider.GOOGLE_GENAI); + ChatRequestParameters params = chatModel.defaultRequestParameters(); + assertThat(params.modelName()).isEqualTo("gemini-2.5-flash"); + assertThat(params.temperature()).isEqualTo(0.7); + assertThat(params.topP()).isEqualTo(0.9); + assertThat(params.topK()).isEqualTo(40); + assertThat(params.maxOutputTokens()).isEqualTo(500); + } + + @Test + void shouldConfigureMistralAiModel_whenGivenMistralAiConfig() { + // GIVEN + var config = MistralAiChatModelConfig.builder() + .providerConfig(new MistralAiProviderConfig("test-key")) + .modelId("mistral-large-latest") + .temperature(0.7) + .topP(0.9) + .frequencyPenalty(0.5) + .presencePenalty(0.25) + .maxOutputTokens(500) + .timeoutSeconds(60) + .maxRetries(3) + .build(); + + // WHEN + ChatModel chatModel = configurer.configureChatModel(config); + + // THEN + assertThat(chatModel.provider()).isEqualTo(ModelProvider.MISTRAL_AI); + ChatRequestParameters params = chatModel.defaultRequestParameters(); + assertThat(params.modelName()).isEqualTo("mistral-large-latest"); + assertThat(params.temperature()).isEqualTo(0.7); + assertThat(params.topP()).isEqualTo(0.9); + assertThat(params.frequencyPenalty()).isEqualTo(0.5); + assertThat(params.presencePenalty()).isEqualTo(0.25); + assertThat(params.maxOutputTokens()).isEqualTo(500); + } + + @Test + void shouldConfigureAnthropicModel_whenGivenAnthropicConfig() { + // GIVEN + var config = AnthropicChatModelConfig.builder() + .providerConfig(new AnthropicProviderConfig("test-key")) + .modelId("claude-opus-4-8") + .temperature(0.7) + .topP(0.9) + .topK(40) + .maxOutputTokens(500) + .timeoutSeconds(60) + .maxRetries(3) + .build(); + + // WHEN + ChatModel chatModel = configurer.configureChatModel(config); + + // THEN + assertThat(chatModel.provider()).isEqualTo(ModelProvider.ANTHROPIC); + ChatRequestParameters params = chatModel.defaultRequestParameters(); + assertThat(params.modelName()).isEqualTo("claude-opus-4-8"); + assertThat(params.temperature()).isEqualTo(0.7); + assertThat(params.topP()).isEqualTo(0.9); + assertThat(params.topK()).isEqualTo(40); + assertThat(params.maxOutputTokens()).isEqualTo(500); + } + @Test - void configureChatModel_openAi_withPrivateIp_shouldThrow() { + void shouldConfigureAmazonBedrockModel_whenGivenAmazonBedrockConfig() { + // GIVEN + var config = AmazonBedrockChatModelConfig.builder() + .providerConfig(new AmazonBedrockProviderConfig( + "us-east-1", "test-access-key-id", "test-secret-access-key")) + .modelId("anthropic.claude-3-5-sonnet-20240620-v1:0") + .temperature(0.7) + .topP(0.9) + .maxOutputTokens(500) + .timeoutSeconds(60) + .maxRetries(3) + .build(); + + // WHEN + ChatModel chatModel = configurer.configureChatModel(config); + + // THEN + assertThat(chatModel.provider()).isEqualTo(ModelProvider.AMAZON_BEDROCK); + ChatRequestParameters params = chatModel.defaultRequestParameters(); + assertThat(params.modelName()).isEqualTo("anthropic.claude-3-5-sonnet-20240620-v1:0"); + assertThat(params.temperature()).isEqualTo(0.7); + assertThat(params.topP()).isEqualTo(0.9); + assertThat(params.maxOutputTokens()).isEqualTo(500); + } + + @Test + void shouldConfigureGitHubModelsModel_whenGivenGitHubModelsConfig() { + // GIVEN + var config = GitHubModelsChatModelConfig.builder() + .providerConfig(new GitHubModelsProviderConfig("ghp-test-token")) + .modelId("gpt-4o") + .temperature(0.7) + .topP(0.9) + .frequencyPenalty(0.5) + .presencePenalty(0.25) + .maxOutputTokens(500) + .timeoutSeconds(60) + .maxRetries(3) + .build(); + + // WHEN + ChatModel chatModel = configurer.configureChatModel(config); + + // THEN + assertThat(chatModel.provider()).isEqualTo(ModelProvider.GITHUB_MODELS); + ChatRequestParameters params = chatModel.defaultRequestParameters(); + assertThat(params.modelName()).isEqualTo("gpt-4o"); + assertThat(params.temperature()).isEqualTo(0.7); + assertThat(params.topP()).isEqualTo(0.9); + assertThat(params.frequencyPenalty()).isEqualTo(0.5); + assertThat(params.presencePenalty()).isEqualTo(0.25); + assertThat(params.maxOutputTokens()).isEqualTo(500); // maxCompletionTokens maps to maxOutputTokens + } + + @Test + void shouldConfigureOllamaModel_whenGivenOllamaConfig() { + // GIVEN + var config = OllamaChatModelConfig.builder() + .providerConfig(new OllamaProviderConfig( + "http://localhost:11434", new OllamaProviderConfig.OllamaAuth.None())) + .modelId("llama3") + .temperature(0.7) + .topP(0.9) + .topK(40) + .contextLength(4096) + .maxOutputTokens(500) + .timeoutSeconds(60) + .maxRetries(3) + .build(); + + // WHEN + ChatModel chatModel = configurer.configureChatModel(config); + + // THEN + assertThat(chatModel.provider()).isEqualTo(ModelProvider.OLLAMA); + ChatRequestParameters params = chatModel.defaultRequestParameters(); + assertThat(params.modelName()).isEqualTo("llama3"); + assertThat(params.temperature()).isEqualTo(0.7); + assertThat(params.topP()).isEqualTo(0.9); + assertThat(params.topK()).isEqualTo(40); + assertThat(params.maxOutputTokens()).isEqualTo(500); // numPredict maps to maxOutputTokens + } + + // ============================== Base URL SSRF validation ============================== + // Providers that accept a user-supplied base URL must reject hosts that resolve to private/loopback addresses + // when SSRF protection is enabled. + + @Test + void shouldThrow_whenOpenAiBaseUrlIsPrivateIp() { + // GIVEN + SsrfProtectionValidator.setEnabled(true); var config = OpenAiChatModelConfig.builder() .providerConfig(OpenAiProviderConfig.builder() .baseUrl("http://172.17.0.1:8080/") @@ -73,13 +344,16 @@ class Langchain4jChatModelConfigurerImplTest { .modelId("gpt-4o") .build(); + // WHEN / THEN assertThatThrownBy(() -> configurer.configureChatModel(config)) .isInstanceOf(RuntimeException.class) .hasMessageContaining("URI is invalid"); } @Test - void configureChatModel_openAi_withLocalhostUrl_shouldThrow() { + void shouldThrow_whenOpenAiBaseUrlIsLocalhost() { + // GIVEN + SsrfProtectionValidator.setEnabled(true); var config = OpenAiChatModelConfig.builder() .providerConfig(OpenAiProviderConfig.builder() .baseUrl("http://localhost:22/") @@ -88,57 +362,42 @@ class Langchain4jChatModelConfigurerImplTest { .modelId("gpt-4o") .build(); + // WHEN / THEN assertThatThrownBy(() -> configurer.configureChatModel(config)) .isInstanceOf(RuntimeException.class) .hasMessageContaining("URI is invalid"); } @Test - void configureChatModel_azureOpenAi_withPrivateIp_shouldThrow() { + void shouldThrow_whenAzureOpenAiEndpointIsPrivateIp() { + // GIVEN + SsrfProtectionValidator.setEnabled(true); var config = AzureOpenAiChatModelConfig.builder() .providerConfig(new AzureOpenAiProviderConfig( "http://10.0.0.1:8080/", null, "test-key")) .modelId("gpt-4o") .build(); + // WHEN / THEN assertThatThrownBy(() -> configurer.configureChatModel(config)) .isInstanceOf(RuntimeException.class) .hasMessageContaining("URI is invalid"); } @Test - void configureChatModel_ollama_withPrivateIp_shouldThrow() { + void shouldThrow_whenOllamaBaseUrlIsPrivateIp() { + // GIVEN + SsrfProtectionValidator.setEnabled(true); var config = OllamaChatModelConfig.builder() .providerConfig(new OllamaProviderConfig( "http://192.168.1.100:11434/", new OllamaProviderConfig.OllamaAuth.None())) .modelId("llama3") .build(); + // WHEN / THEN assertThatThrownBy(() -> configurer.configureChatModel(config)) .isInstanceOf(RuntimeException.class) .hasMessageContaining("URI is invalid"); } - @Test - void configureChatModel_vertexAi_setsFrequencyAndPresencePenaltyFromCorrectConfigFields() { - // GIVEN - var providerConfig = new GoogleVertexAiGeminiProviderConfig( - "test.json", "test-project", "us-central1", TEST_SERVICE_ACCOUNT_KEY - ); - var chatModelConfig = GoogleVertexAiGeminiChatModelConfig.builder() - .providerConfig(providerConfig) - .modelId("gemini-2.0-flash") - .frequencyPenalty(0.3) - .presencePenalty(0.7) - .build(); - - // WHEN - ChatModel chatModel = configurer.configureChatModel(chatModelConfig); - - // THEN - var generationConfig = (GenerationConfig) ReflectionTestUtils.getField(chatModel, "generationConfig"); - assertThat(generationConfig.getFrequencyPenalty()).isEqualTo(0.3f); - assertThat(generationConfig.getPresencePenalty()).isEqualTo(0.7f); - } - } diff --git a/application/src/test/java/org/thingsboard/server/service/cf/ctx/state/SimpleCalculatedFieldStateTest.java b/application/src/test/java/org/thingsboard/server/service/cf/ctx/state/SimpleCalculatedFieldStateTest.java index 4454d41e6e..35f637dc09 100644 --- a/application/src/test/java/org/thingsboard/server/service/cf/ctx/state/SimpleCalculatedFieldStateTest.java +++ b/application/src/test/java/org/thingsboard/server/service/cf/ctx/state/SimpleCalculatedFieldStateTest.java @@ -154,7 +154,7 @@ public class SimpleCalculatedFieldStateTest { Output output = getCalculatedFieldConfig().getOutput(); assertThat(result.getType()).isEqualTo(output.getType()); assertThat(result.getScope()).isEqualTo(output.getScope()); - assertThat(result.getResult()).isEqualTo(JacksonUtil.valueToTree(Map.of("output", 49))); + assertThat(result.getResult()).isEqualTo(JacksonUtil.valueToTree(Map.of("output", 49L))); } @Test @@ -184,7 +184,7 @@ public class SimpleCalculatedFieldStateTest { Output output = getCalculatedFieldConfig().getOutput(); assertThat(result.getType()).isEqualTo(output.getType()); assertThat(result.getScope()).isEqualTo(output.getScope()); - assertThat(result.getResult()).isEqualTo(JacksonUtil.valueToTree(Map.of("output", 35))); + assertThat(result.getResult()).isEqualTo(JacksonUtil.valueToTree(Map.of("output", 35L))); } @Test diff --git a/application/src/test/java/org/thingsboard/server/service/entitiy/queue/DefaultTbQueueServiceTest.java b/application/src/test/java/org/thingsboard/server/service/entitiy/queue/DefaultTbQueueServiceTest.java new file mode 100644 index 0000000000..81cc325bdb --- /dev/null +++ b/application/src/test/java/org/thingsboard/server/service/entitiy/queue/DefaultTbQueueServiceTest.java @@ -0,0 +1,141 @@ +/** + * Copyright © 2016-2026 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.service.entitiy.queue; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.test.util.ReflectionTestUtils; +import org.thingsboard.server.cluster.TbClusterService; +import org.thingsboard.server.common.data.id.QueueId; +import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.common.data.queue.Queue; +import org.thingsboard.server.dao.queue.QueueService; +import org.thingsboard.server.queue.TbQueueAdmin; +import org.thingsboard.server.queue.discovery.TopicService; + +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyBoolean; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +public class DefaultTbQueueServiceTest { + + @Mock + private QueueService queueServiceMock; + @Mock + private TbClusterService tbClusterServiceMock; + @Mock + private TbQueueAdmin tbQueueAdminMock; + + private TopicService topicService; + private DefaultTbQueueService tbQueueService; + + private final TenantId tenantId = TenantId.SYS_TENANT_ID; + + @BeforeEach + public void setUp() { + topicService = new TopicService(); + tbQueueService = new DefaultTbQueueService(queueServiceMock, tbClusterServiceMock, tbQueueAdminMock, topicService); + } + + private Queue newQueue(int partitions) { + Queue queue = new Queue(); + queue.setTenantId(tenantId); + queue.setName("testQueue"); + queue.setTopic("tb_rule_engine.testQueue"); + queue.setPartitions(partitions); + return queue; + } + + @Test + public void givenQueuePrefix_whenSaveQueue_thenCreatesPrefixedTopics() { + // queue.prefix = "thingsboard" (TB_QUEUE_PREFIX set) + ReflectionTestUtils.setField(topicService, "prefix", "thingsboard"); + + Queue queue = newQueue(2); + when(queueServiceMock.saveQueue(queue)).thenReturn(queue); + + tbQueueService.saveQueue(queue); + + ArgumentCaptor topicCaptor = ArgumentCaptor.forClass(String.class); + verify(tbQueueAdminMock, times(2)).createTopicIfNotExists(topicCaptor.capture(), any(), anyBoolean()); + + // All created topics must carry the prefix - this is the fix. + assertThat(topicCaptor.getAllValues()) + .containsExactlyInAnyOrder( + "thingsboard.tb_rule_engine.testQueue.0", + "thingsboard.tb_rule_engine.testQueue.1"); + // No unprefixed (orphan-prone) topic must ever be created. + assertThat(topicCaptor.getAllValues()) + .noneMatch(topic -> topic.equals("tb_rule_engine.testQueue.0") + || topic.equals("tb_rule_engine.testQueue.1")); + } + + @Test + public void givenNoQueuePrefix_whenSaveQueue_thenCreatesUnprefixedTopics() { + // queue.prefix blank (TB_QUEUE_PREFIX not set) - default behavior preserved + ReflectionTestUtils.setField(topicService, "prefix", ""); + + Queue queue = newQueue(2); + when(queueServiceMock.saveQueue(queue)).thenReturn(queue); + + tbQueueService.saveQueue(queue); + + ArgumentCaptor topicCaptor = ArgumentCaptor.forClass(String.class); + verify(tbQueueAdminMock, times(2)).createTopicIfNotExists(topicCaptor.capture(), any(), anyBoolean()); + + assertThat(topicCaptor.getAllValues()) + .containsExactlyInAnyOrder( + "tb_rule_engine.testQueue.0", + "tb_rule_engine.testQueue.1"); + } + + @Test + public void givenQueuePrefix_whenIncreasePartitions_thenOnlyNewPartitionsCreatedPrefixed() { + ReflectionTestUtils.setField(topicService, "prefix", "thingsboard"); + + Queue oldQueue = newQueue(2); + oldQueue.setId(new QueueId(UUID.randomUUID())); + Queue updatedQueue = newQueue(4); + updatedQueue.setId(oldQueue.getId()); + + when(queueServiceMock.findQueueById(tenantId, updatedQueue.getId())).thenReturn(oldQueue); + when(queueServiceMock.saveQueue(updatedQueue)).thenReturn(updatedQueue); + + tbQueueService.saveQueue(updatedQueue); + + ArgumentCaptor topicCaptor = ArgumentCaptor.forClass(String.class); + verify(tbQueueAdminMock, times(2)).createTopicIfNotExists(topicCaptor.capture(), any(), anyBoolean()); + + assertThat(topicCaptor.getAllValues()) + .containsExactlyInAnyOrder( + "thingsboard.tb_rule_engine.testQueue.2", + "thingsboard.tb_rule_engine.testQueue.3"); + verify(tbQueueAdminMock, never()).createTopicIfNotExists(eq("thingsboard.tb_rule_engine.testQueue.0"), any(), anyBoolean()); + } + +} diff --git a/application/src/test/java/org/thingsboard/server/service/queue/DefaultTbCoreConsumerServiceTest.java b/application/src/test/java/org/thingsboard/server/service/queue/DefaultTbCoreConsumerServiceTest.java index 86b5ae2cf8..53c855aa4d 100644 --- a/application/src/test/java/org/thingsboard/server/service/queue/DefaultTbCoreConsumerServiceTest.java +++ b/application/src/test/java/org/thingsboard/server/service/queue/DefaultTbCoreConsumerServiceTest.java @@ -27,8 +27,11 @@ import org.mockito.junit.jupiter.MockitoExtension; import org.springframework.test.util.ReflectionTestUtils; import org.thingsboard.server.common.data.id.DeviceId; import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.common.data.rpc.RpcError; import org.thingsboard.server.common.msg.queue.TbCallback; +import org.thingsboard.server.common.msg.rpc.FromDeviceRpcResponse; import org.thingsboard.server.gen.transport.TransportProtos; +import org.thingsboard.server.service.rpc.TbCoreDeviceRpcService; import org.thingsboard.server.service.ruleengine.RuleEngineCallService; import org.thingsboard.server.service.state.DeviceStateService; @@ -51,6 +54,8 @@ public class DefaultTbCoreConsumerServiceTest { private TbCoreConsumerStats statsMock; @Mock private RuleEngineCallService ruleEngineCallServiceMock; + @Mock + private TbCoreDeviceRpcService tbCoreDeviceRpcServiceMock; @Mock private TbCallback tbCallbackMock; @@ -638,4 +643,31 @@ public class DefaultTbCoreConsumerServiceTest { then(ruleEngineCallServiceMock).should().onQueueMsg(restApiCallResponseMsgProto, tbCallbackMock); } + @Test + public void givenNotFoundErrorAndNoResponse_whenForwardToCoreRpcService_thenNotFoundAndNullResponseAreRecovered() { + // GIVEN + ReflectionTestUtils.setField(defaultTbCoreConsumerServiceMock, "tbCoreDeviceRpcService", tbCoreDeviceRpcServiceMock); + var requestId = UUID.randomUUID(); + // error = NOT_FOUND.ordinal() (0) and response left unset: the previously broken combination + // ('error > 0' dropped NOT_FOUND, proto3 default collapsed a null response to ""). + var proto = TransportProtos.FromDeviceRPCResponseProto.newBuilder() + .setRequestIdMSB(requestId.getMostSignificantBits()) + .setRequestIdLSB(requestId.getLeastSignificantBits()) + .setError(RpcError.NOT_FOUND.ordinal()) + .build(); + doCallRealMethod().when(defaultTbCoreConsumerServiceMock).forwardToCoreRpcService(proto, tbCallbackMock); + + // WHEN + defaultTbCoreConsumerServiceMock.forwardToCoreRpcService(proto, tbCallbackMock); + + // THEN + var responseCaptor = ArgumentCaptor.forClass(FromDeviceRpcResponse.class); + then(tbCoreDeviceRpcServiceMock).should().processRpcResponseFromRuleEngine(responseCaptor.capture()); + var response = responseCaptor.getValue(); + assertThat(response.getId()).isEqualTo(requestId); + assertThat(response.getError()).contains(RpcError.NOT_FOUND); + assertThat(response.getResponse()).isEmpty(); + then(tbCallbackMock).should().onSuccess(); + } + } diff --git a/application/src/test/java/org/thingsboard/server/service/queue/DefaultTbRuleEngineConsumerServiceTest.java b/application/src/test/java/org/thingsboard/server/service/queue/DefaultTbRuleEngineConsumerServiceTest.java new file mode 100644 index 0000000000..1cf41eca0c --- /dev/null +++ b/application/src/test/java/org/thingsboard/server/service/queue/DefaultTbRuleEngineConsumerServiceTest.java @@ -0,0 +1,78 @@ +/** + * Copyright © 2016-2026 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.service.queue; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.test.util.ReflectionTestUtils; +import org.thingsboard.server.common.data.rpc.RpcError; +import org.thingsboard.server.common.msg.queue.TbCallback; +import org.thingsboard.server.common.msg.rpc.FromDeviceRpcResponse; +import org.thingsboard.server.gen.transport.TransportProtos; +import org.thingsboard.server.gen.transport.TransportProtos.ToRuleEngineNotificationMsg; +import org.thingsboard.server.queue.common.TbProtoQueueMsg; +import org.thingsboard.server.service.rpc.TbRuleEngineDeviceRpcService; + +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.BDDMockito.then; +import static org.mockito.Mockito.doCallRealMethod; + +@ExtendWith(MockitoExtension.class) +public class DefaultTbRuleEngineConsumerServiceTest { + + @Mock + private TbRuleEngineDeviceRpcService tbDeviceRpcServiceMock; + @Mock + private TbCallback tbCallbackMock; + + @Mock + private DefaultTbRuleEngineConsumerService defaultTbRuleEngineConsumerServiceMock; + + @Test + public void givenNotFoundErrorAndNoResponse_whenHandleFromDeviceRpcResponse_thenNotFoundAndNullResponseAreRecovered() { + // GIVEN + ReflectionTestUtils.setField(defaultTbRuleEngineConsumerServiceMock, "tbDeviceRpcService", tbDeviceRpcServiceMock); + var requestId = UUID.randomUUID(); + // error = NOT_FOUND.ordinal() (0) and response left unset: the previously broken combination + // ('error > 0' dropped NOT_FOUND, proto3 default collapsed a null response to ""). + var proto = TransportProtos.FromDeviceRPCResponseProto.newBuilder() + .setRequestIdMSB(requestId.getMostSignificantBits()) + .setRequestIdLSB(requestId.getLeastSignificantBits()) + .setError(RpcError.NOT_FOUND.ordinal()) + .build(); + var nfMsg = ToRuleEngineNotificationMsg.newBuilder().setFromDeviceRpcResponse(proto).build(); + var queueMsg = new TbProtoQueueMsg<>(requestId, nfMsg); + doCallRealMethod().when(defaultTbRuleEngineConsumerServiceMock).handleNotification(requestId, queueMsg, tbCallbackMock); + + // WHEN + defaultTbRuleEngineConsumerServiceMock.handleNotification(requestId, queueMsg, tbCallbackMock); + + // THEN + var responseCaptor = ArgumentCaptor.forClass(FromDeviceRpcResponse.class); + then(tbDeviceRpcServiceMock).should().processRpcResponseFromDevice(responseCaptor.capture()); + var response = responseCaptor.getValue(); + assertThat(response.getId()).isEqualTo(requestId); + assertThat(response.getError()).contains(RpcError.NOT_FOUND); + assertThat(response.getResponse()).isEmpty(); + then(tbCallbackMock).should().onSuccess(); + } + +} diff --git a/common/actor/pom.xml b/common/actor/pom.xml index 6d46c10198..b3f04d31a1 100644 --- a/common/actor/pom.xml +++ b/common/actor/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard - 4.3.1.2-SNAPSHOT + 4.3.1.3-SNAPSHOT common org.thingsboard.common diff --git a/common/cache/pom.xml b/common/cache/pom.xml index aa1438d97a..e95bb301b4 100644 --- a/common/cache/pom.xml +++ b/common/cache/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard - 4.3.1.2-SNAPSHOT + 4.3.1.3-SNAPSHOT common org.thingsboard.common diff --git a/common/cluster-api/pom.xml b/common/cluster-api/pom.xml index e8968e2a2c..63dc714000 100644 --- a/common/cluster-api/pom.xml +++ b/common/cluster-api/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard - 4.3.1.2-SNAPSHOT + 4.3.1.3-SNAPSHOT common org.thingsboard.common diff --git a/common/coap-server/pom.xml b/common/coap-server/pom.xml index 5314510d52..d713c6d065 100644 --- a/common/coap-server/pom.xml +++ b/common/coap-server/pom.xml @@ -22,7 +22,7 @@ 4.0.0 org.thingsboard - 4.3.1.2-SNAPSHOT + 4.3.1.3-SNAPSHOT common org.thingsboard.common diff --git a/common/coap-server/src/main/java/org/thingsboard/server/coapserver/DefaultCoapServerService.java b/common/coap-server/src/main/java/org/thingsboard/server/coapserver/DefaultCoapServerService.java index 3b7248ee72..8ffa489f7b 100644 --- a/common/coap-server/src/main/java/org/thingsboard/server/coapserver/DefaultCoapServerService.java +++ b/common/coap-server/src/main/java/org/thingsboard/server/coapserver/DefaultCoapServerService.java @@ -85,7 +85,9 @@ public class DefaultCoapServerService implements CoapServerService, SmartInitial dtlsSessionsExecutor.shutdownNow(); } log.info("Stopping CoAP server!"); - server.destroy(); + if (server != null) { + server.destroy(); + } log.info("CoAP server stopped!"); } @@ -105,27 +107,47 @@ public class DefaultCoapServerService implements CoapServerService, SmartInitial private CoapServer createCoapServer() throws UnknownHostException { Configuration networkConfig = createNetworkConfiguration(); - server = new CoapServer(networkConfig); - - CoapEndpoint.Builder noSecCoapEndpointBuilder = new CoapEndpoint.Builder(); - InetAddress addr = InetAddress.getByName(coapServerContext.getHost()); - InetSocketAddress sockAddr = new InetSocketAddress(addr, coapServerContext.getPort()); - noSecCoapEndpointBuilder.setInetSocketAddress(sockAddr); + try { + server = new CoapServer(networkConfig); + CoapEndpoint.Builder noSecCoapEndpointBuilder = new CoapEndpoint.Builder(); + InetAddress addr = InetAddress.getByName(coapServerContext.getHost()); + InetSocketAddress sockAddr = new InetSocketAddress(addr, coapServerContext.getPort()); + noSecCoapEndpointBuilder.setInetSocketAddress(sockAddr); + + noSecCoapEndpointBuilder.setConfiguration(networkConfig); + CoapEndpoint noSecCoapEndpoint = noSecCoapEndpointBuilder.build(); + server.addEndpoint(noSecCoapEndpoint); + if (isDtlsEnabled()) { + createDtlsEndpoint(networkConfig); + dtlsSessionsExecutor = ThingsBoardExecutors.newSingleThreadScheduledExecutor(getClass().getSimpleName()); + dtlsSessionsExecutor.scheduleAtFixedRate(this::evictTimeoutSessions, new Random().nextInt((int) getDtlsSessionReportTimeout()), getDtlsSessionReportTimeout(), TimeUnit.MILLISECONDS); + } + Resource root = server.getRoot(); + TbCoapServerMessageDeliverer messageDeliverer = new TbCoapServerMessageDeliverer(root); + server.setMessageDeliverer(messageDeliverer); - noSecCoapEndpointBuilder.setConfiguration(networkConfig); - CoapEndpoint noSecCoapEndpoint = noSecCoapEndpointBuilder.build(); - server.addEndpoint(noSecCoapEndpoint); - if (isDtlsEnabled()) { - createDtlsEndpoint(networkConfig); - dtlsSessionsExecutor = ThingsBoardExecutors.newSingleThreadScheduledExecutor(getClass().getSimpleName()); - dtlsSessionsExecutor.scheduleAtFixedRate(this::evictTimeoutSessions, new Random().nextInt((int) getDtlsSessionReportTimeout()), getDtlsSessionReportTimeout(), TimeUnit.MILLISECONDS); + server.start(); + return server; + } catch (RuntimeException | UnknownHostException e) { + log.error("Failed to start CoAP server, releasing resources", e); + try { + if (dtlsSessionsExecutor != null) { + dtlsSessionsExecutor.shutdownNow(); + } + if (server != null) { + server.destroy(); + } + } catch (Exception suppressed) { + e.addSuppressed(suppressed); + } finally { + server = null; + dtlsSessionsExecutor = null; + dtlsConnector = null; + dtlsCoapEndpoint = null; + tbDtlsCertificateVerifier = null; + } + throw e; } - Resource root = server.getRoot(); - TbCoapServerMessageDeliverer messageDeliverer = new TbCoapServerMessageDeliverer(root); - server.setMessageDeliverer(messageDeliverer); - - server.start(); - return server; } private boolean isDtlsEnabled() { diff --git a/common/coap-server/src/test/java/org/thingsboard/server/coapserver/DefaultCoapServerServiceTest.java b/common/coap-server/src/test/java/org/thingsboard/server/coapserver/DefaultCoapServerServiceTest.java new file mode 100644 index 0000000000..0c9e9fc29d --- /dev/null +++ b/common/coap-server/src/test/java/org/thingsboard/server/coapserver/DefaultCoapServerServiceTest.java @@ -0,0 +1,145 @@ +/** + * 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.coapserver; + +import org.eclipse.californium.core.CoapServer; +import org.eclipse.californium.core.network.CoapEndpoint; +import org.eclipse.californium.core.server.resources.Resource; +import org.eclipse.californium.scandium.DTLSConnector; +import org.eclipse.californium.scandium.config.DtlsConnectorConfig; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.MockedConstruction; +import org.mockito.MockedStatic; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.test.util.ReflectionTestUtils; +import org.thingsboard.common.util.ThingsBoardExecutors; + +import java.net.DatagramSocket; +import java.net.InetAddress; +import java.net.InetSocketAddress; +import java.util.concurrent.ScheduledExecutorService; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.mockConstruction; +import static org.mockito.Mockito.mockStatic; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +public class DefaultCoapServerServiceTest { + + private static final String HOST = "127.0.0.1"; + + @Mock + private CoapServerContext mockCoapServerContext; + + private DefaultCoapServerService service; + private DatagramSocket occupiedSocket; + private int occupiedPort; + + @BeforeEach + public void setUp() throws Exception { + occupiedSocket = new DatagramSocket(new InetSocketAddress(InetAddress.getByName(HOST), 0)); + occupiedPort = occupiedSocket.getLocalPort(); + + service = new DefaultCoapServerService(); + ReflectionTestUtils.setField(service, "coapServerContext", mockCoapServerContext); + + when(mockCoapServerContext.getHost()).thenReturn(HOST); + when(mockCoapServerContext.getPort()).thenReturn(occupiedPort); + when(mockCoapServerContext.getDtlsSettings()).thenReturn(null); + } + + @AfterEach + public void tearDown() { + if (occupiedSocket != null && !occupiedSocket.isClosed()) { + occupiedSocket.close(); + } + } + + @Test + public void whenPlainBindFails_thenInitThrowsAndReleasesCoapServer() { + assertThatThrownBy(() -> service.init()) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("None of the server endpoints could be started"); + + assertThat(ReflectionTestUtils.getField(service, "server")).isNull(); + assertThat(ReflectionTestUtils.getField(service, "dtlsSessionsExecutor")).isNull(); + assertThat(ReflectionTestUtils.getField(service, "dtlsConnector")).isNull(); + assertThat(ReflectionTestUtils.getField(service, "dtlsCoapEndpoint")).isNull(); + assertThat(ReflectionTestUtils.getField(service, "tbDtlsCertificateVerifier")).isNull(); + } + + @Test + public void whenDtlsEnabledAndStartFails_thenInitShutsDownDtlsExecutorAndReleasesCoapServer() throws Exception { + // DTLS enabled: the DTLS endpoint is created and dtlsSessionsExecutor is scheduled before server.start(). + // This exercises the catch's dtlsSessionsExecutor.shutdownNow() branch, which the plain-bind test does not. + TbCoapDtlsSettings mockDtlsSettings = mock(TbCoapDtlsSettings.class); + when(mockCoapServerContext.getDtlsSettings()).thenReturn(mockDtlsSettings); + + DtlsConnectorConfig mockDtlsConfig = mock(DtlsConnectorConfig.class); + when(mockDtlsConfig.getAddress()).thenReturn(new InetSocketAddress(InetAddress.getByName(HOST), occupiedPort + 1)); + TbCoapDtlsCertificateVerifier mockVerifier = mock(TbCoapDtlsCertificateVerifier.class); + when(mockVerifier.getDtlsSessionReportTimeout()).thenReturn(1800000L); + when(mockDtlsConfig.getAdvancedCertificateVerifier()).thenReturn(mockVerifier); + when(mockDtlsSettings.dtlsConnectorConfig(any())).thenReturn(mockDtlsConfig); + + ScheduledExecutorService mockExecutor = mock(ScheduledExecutorService.class); + Resource mockRoot = mock(Resource.class); + + try (MockedStatic executorsStatic = mockStatic(ThingsBoardExecutors.class); + MockedConstruction serverMock = mockConstruction(CoapServer.class, (server, ctx) -> { + when(server.getRoot()).thenReturn(mockRoot); + doThrow(new IllegalStateException("None of the server endpoints could be started")).when(server).start(); + }); + MockedConstruction dtlsMock = mockConstruction(DTLSConnector.class); + MockedConstruction builderMock = mockConstruction(CoapEndpoint.Builder.class, (builder, ctx) -> { + when(builder.setInetSocketAddress(any())).thenReturn(builder); + when(builder.setConfiguration(any())).thenReturn(builder); + when(builder.setConnector(any(DTLSConnector.class))).thenReturn(builder); + when(builder.build()).thenReturn(mock(CoapEndpoint.class)); + })) { + + executorsStatic.when(() -> ThingsBoardExecutors.newSingleThreadScheduledExecutor(anyString())).thenReturn(mockExecutor); + + assertThatThrownBy(() -> service.init()) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("None of the server endpoints could be started"); + + // DTLS branch was actually entered and the executor was created... + verify(mockDtlsSettings).dtlsConnectorConfig(any()); + // ...and the cleanup branch shut it down and destroyed the server. + verify(mockExecutor).shutdownNow(); + verify(serverMock.constructed().get(0)).destroy(); + } + + assertThat(ReflectionTestUtils.getField(service, "server")).isNull(); + assertThat(ReflectionTestUtils.getField(service, "dtlsSessionsExecutor")).isNull(); + assertThat(ReflectionTestUtils.getField(service, "dtlsConnector")).isNull(); + assertThat(ReflectionTestUtils.getField(service, "dtlsCoapEndpoint")).isNull(); + assertThat(ReflectionTestUtils.getField(service, "tbDtlsCertificateVerifier")).isNull(); + } + +} diff --git a/common/dao-api/pom.xml b/common/dao-api/pom.xml index 30eb360490..87b217e205 100644 --- a/common/dao-api/pom.xml +++ b/common/dao-api/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard - 4.3.1.2-SNAPSHOT + 4.3.1.3-SNAPSHOT common org.thingsboard.common diff --git a/common/data/pom.xml b/common/data/pom.xml index 8579103da7..0e9d306b85 100644 --- a/common/data/pom.xml +++ b/common/data/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard - 4.3.1.2-SNAPSHOT + 4.3.1.3-SNAPSHOT common org.thingsboard.common diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/StringUtils.java b/common/data/src/main/java/org/thingsboard/server/common/data/StringUtils.java index 3cb6e024fc..54ce580e66 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/StringUtils.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/StringUtils.java @@ -26,6 +26,7 @@ import java.util.Base64; import java.util.List; import java.util.Objects; import java.util.function.Function; +import java.util.regex.Pattern; import static org.apache.commons.lang3.StringUtils.repeat; @@ -39,6 +40,12 @@ public class StringUtils { public static final int INDEX_NOT_FOUND = -1; + public static final Pattern CONTROL_CHARS = Pattern.compile("[\\x00-\\x1F\\x7F]"); + + public static boolean containsControlChars(String source) { + return source != null && CONTROL_CHARS.matcher(source).find(); + } + public static boolean isEmpty(String source) { return source == null || source.isEmpty(); } diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/SystemParams.java b/common/data/src/main/java/org/thingsboard/server/common/data/SystemParams.java index 3cd2ada38f..888df32219 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/SystemParams.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/SystemParams.java @@ -45,4 +45,6 @@ public class SystemParams { long minAllowedAggregationIntervalInSecForCF; long intermediateAggregationIntervalInSecForCF; TrendzSettings trendzSettings; + String nullsOrderStrategy; + boolean edqsEnabled; } diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/AiChatModelConfig.java b/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/AiChatModelConfig.java index 5f7772ed8b..abd25769d6 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/AiChatModelConfig.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/AiChatModelConfig.java @@ -42,6 +42,8 @@ public sealed interface AiChatModelConfig> extend C withMaxRetries(Integer maxRetries); - boolean supportsJsonMode(); + boolean supportsSchemalessJsonOutput(); + + boolean supportsJsonSchemaOutput(); } diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/AmazonBedrockChatModelConfig.java b/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/AmazonBedrockChatModelConfig.java index 490ae67eb2..738e27a3d1 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/AmazonBedrockChatModelConfig.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/AmazonBedrockChatModelConfig.java @@ -52,8 +52,13 @@ public record AmazonBedrockChatModelConfig( } @Override - public boolean supportsJsonMode() { + public boolean supportsSchemalessJsonOutput() { return false; } + @Override + public boolean supportsJsonSchemaOutput() { + return true; + } + } diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/AnthropicChatModelConfig.java b/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/AnthropicChatModelConfig.java index 77352b06c3..49cebe3e25 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/AnthropicChatModelConfig.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/AnthropicChatModelConfig.java @@ -53,8 +53,13 @@ public record AnthropicChatModelConfig( } @Override - public boolean supportsJsonMode() { + public boolean supportsSchemalessJsonOutput() { return false; } + @Override + public boolean supportsJsonSchemaOutput() { + return true; + } + } diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/AzureOpenAiChatModelConfig.java b/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/AzureOpenAiChatModelConfig.java index d4df82d237..11fc5bcf3e 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/AzureOpenAiChatModelConfig.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/AzureOpenAiChatModelConfig.java @@ -54,7 +54,12 @@ public record AzureOpenAiChatModelConfig( } @Override - public boolean supportsJsonMode() { + public boolean supportsSchemalessJsonOutput() { + return true; + } + + @Override + public boolean supportsJsonSchemaOutput() { return true; } diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/GitHubModelsChatModelConfig.java b/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/GitHubModelsChatModelConfig.java index 264683f7b4..578dfa6aa3 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/GitHubModelsChatModelConfig.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/GitHubModelsChatModelConfig.java @@ -54,8 +54,13 @@ public record GitHubModelsChatModelConfig( } @Override - public boolean supportsJsonMode() { - return false; + public boolean supportsSchemalessJsonOutput() { + return true; + } + + @Override + public boolean supportsJsonSchemaOutput() { + return true; } } diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/GoogleAiGeminiChatModelConfig.java b/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/GoogleAiGeminiChatModelConfig.java index 82ba3a0295..472e1629dd 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/GoogleAiGeminiChatModelConfig.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/GoogleAiGeminiChatModelConfig.java @@ -55,7 +55,12 @@ public record GoogleAiGeminiChatModelConfig( } @Override - public boolean supportsJsonMode() { + public boolean supportsSchemalessJsonOutput() { + return true; + } + + @Override + public boolean supportsJsonSchemaOutput() { return true; } diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/GoogleVertexAiGeminiChatModelConfig.java b/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/GoogleVertexAiGeminiChatModelConfig.java index 4d20a240e6..80502ee2f4 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/GoogleVertexAiGeminiChatModelConfig.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/GoogleVertexAiGeminiChatModelConfig.java @@ -55,7 +55,12 @@ public record GoogleVertexAiGeminiChatModelConfig( } @Override - public boolean supportsJsonMode() { + public boolean supportsSchemalessJsonOutput() { + return true; + } + + @Override + public boolean supportsJsonSchemaOutput() { return true; } diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/MistralAiChatModelConfig.java b/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/MistralAiChatModelConfig.java index 60c208c181..492ca6967f 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/MistralAiChatModelConfig.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/MistralAiChatModelConfig.java @@ -54,7 +54,12 @@ public record MistralAiChatModelConfig( } @Override - public boolean supportsJsonMode() { + public boolean supportsSchemalessJsonOutput() { + return true; + } + + @Override + public boolean supportsJsonSchemaOutput() { return true; } diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/OllamaChatModelConfig.java b/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/OllamaChatModelConfig.java index 5f7776a0a6..758487a956 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/OllamaChatModelConfig.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/OllamaChatModelConfig.java @@ -54,7 +54,12 @@ public record OllamaChatModelConfig( } @Override - public boolean supportsJsonMode() { + public boolean supportsSchemalessJsonOutput() { + return true; + } + + @Override + public boolean supportsJsonSchemaOutput() { return true; } diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/OpenAiChatModelConfig.java b/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/OpenAiChatModelConfig.java index 3845aaa543..ab7835c7f2 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/OpenAiChatModelConfig.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/OpenAiChatModelConfig.java @@ -54,7 +54,12 @@ public record OpenAiChatModelConfig( } @Override - public boolean supportsJsonMode() { + public boolean supportsSchemalessJsonOutput() { + return true; + } + + @Override + public boolean supportsJsonSchemaOutput() { return true; } diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/alarm/AlarmCommentSubType.java b/common/data/src/main/java/org/thingsboard/server/common/data/alarm/AlarmCommentSubType.java index 80d08a594d..44ee9744de 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/alarm/AlarmCommentSubType.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/alarm/AlarmCommentSubType.java @@ -24,7 +24,7 @@ public enum AlarmCommentSubType { ASSIGNED_TO_USER("Alarm was assigned by user %s to user %s"), UNASSIGNED_BY_USER("Alarm was unassigned by user %s"), UNASSIGNED_FROM_DELETED_USER("Alarm was unassigned because user %s - was deleted"), - COMMENT_DELETED("User %s deleted his comment"), + COMMENT_DELETED("Comment was deleted by user %s"), SEVERITY_CHANGED("Alarm severity was updated from %s to %s"); @Getter diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/rpc/RpcError.java b/common/data/src/main/java/org/thingsboard/server/common/data/rpc/RpcError.java index 03cd1d69cb..c5575b53be 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/rpc/RpcError.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/rpc/RpcError.java @@ -20,4 +20,16 @@ package org.thingsboard.server.common.data.rpc; */ public enum RpcError { NOT_FOUND, FORBIDDEN, NO_ACTIVE_CONNECTION, TIMEOUT, INTERNAL; + + private static final RpcError[] VALUES = values(); + + /** + * Resolves an {@link RpcError} from the proto {@code error} ordinal. + * Returns {@code null} both for the "no error" sentinel (negative value) and for unknown ordinals + * that a newer node in a mixed-version cluster might emit, so callers never hit an + * {@link ArrayIndexOutOfBoundsException}. + */ + public static RpcError fromProtoErrorCode(int errorCode) { + return errorCode >= 0 && errorCode < VALUES.length ? VALUES[errorCode] : null; + } } diff --git a/common/discovery-api/pom.xml b/common/discovery-api/pom.xml index f74163642d..275d7762d5 100644 --- a/common/discovery-api/pom.xml +++ b/common/discovery-api/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard - 4.3.1.2-SNAPSHOT + 4.3.1.3-SNAPSHOT common org.thingsboard.common diff --git a/common/edge-api/pom.xml b/common/edge-api/pom.xml index 2bff3e0b18..647accd360 100644 --- a/common/edge-api/pom.xml +++ b/common/edge-api/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard - 4.3.1.2-SNAPSHOT + 4.3.1.3-SNAPSHOT common org.thingsboard.common diff --git a/common/edge-api/src/main/proto/edge.proto b/common/edge-api/src/main/proto/edge.proto index 311c345b7a..11de63a977 100644 --- a/common/edge-api/src/main/proto/edge.proto +++ b/common/edge-api/src/main/proto/edge.proto @@ -49,10 +49,12 @@ enum EdgeVersion { V_4_2_2 = 4220; V_4_2_2_1 = 4221; V_4_2_2_2 = 4222; + V_4_2_2_3 = 4223; V_4_3_0_1 = 15; V_4_3_1 = 4310; V_4_3_1_1 = 4311; V_4_3_1_2 = 4312; + V_4_3_1_3 = 4313; V_LATEST = 99999; } diff --git a/common/edqs/pom.xml b/common/edqs/pom.xml index 854ed34c54..769606efad 100644 --- a/common/edqs/pom.xml +++ b/common/edqs/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard - 4.3.1.2-SNAPSHOT + 4.3.1.3-SNAPSHOT common org.thingsboard.common diff --git a/common/message/pom.xml b/common/message/pom.xml index 48a6471014..ee6d296262 100644 --- a/common/message/pom.xml +++ b/common/message/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard - 4.3.1.2-SNAPSHOT + 4.3.1.3-SNAPSHOT common org.thingsboard.common diff --git a/common/pom.xml b/common/pom.xml index 0759821ed4..c8f3935110 100644 --- a/common/pom.xml +++ b/common/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard - 4.3.1.2-SNAPSHOT + 4.3.1.3-SNAPSHOT thingsboard common diff --git a/common/proto/pom.xml b/common/proto/pom.xml index 433b84ceff..bee053364f 100644 --- a/common/proto/pom.xml +++ b/common/proto/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard - 4.3.1.2-SNAPSHOT + 4.3.1.3-SNAPSHOT common org.thingsboard.common diff --git a/common/proto/src/main/java/org/thingsboard/server/common/util/ProtoUtils.java b/common/proto/src/main/java/org/thingsboard/server/common/util/ProtoUtils.java index 784bd97c79..8627de539e 100644 --- a/common/proto/src/main/java/org/thingsboard/server/common/util/ProtoUtils.java +++ b/common/proto/src/main/java/org/thingsboard/server/common/util/ProtoUtils.java @@ -585,10 +585,11 @@ public class ProtoUtils { } private static ToDeviceActorNotificationMsg fromProto(TransportProtos.FromDeviceRpcResponseActorMsgProto proto) { + TransportProtos.FromDeviceRPCResponseProto rpcResponse = proto.getRpcResponse(); FromDeviceRpcResponse fromDeviceRpcResponse = new FromDeviceRpcResponse( - new UUID(proto.getRpcResponse().getRequestIdMSB(), proto.getRpcResponse().getRequestIdLSB()), - proto.getRpcResponse().getResponse(), - proto.getRpcResponse().getError() >= 0 ? RpcError.values()[proto.getRpcResponse().getError()] : null); + new UUID(rpcResponse.getRequestIdMSB(), rpcResponse.getRequestIdLSB()), + rpcResponse.hasResponse() ? rpcResponse.getResponse() : null, + RpcError.fromProtoErrorCode(rpcResponse.getError())); return new FromDeviceRpcResponseActorMsg( proto.getRequestId(), TenantId.fromUUID(new UUID(proto.getTenantIdMSB(), proto.getTenantIdLSB())), diff --git a/common/proto/src/main/proto/queue.proto b/common/proto/src/main/proto/queue.proto index 68f7052c69..2e5a382185 100644 --- a/common/proto/src/main/proto/queue.proto +++ b/common/proto/src/main/proto/queue.proto @@ -1273,7 +1273,7 @@ message LocalSubscriptionServiceMsgProto { message FromDeviceRPCResponseProto { int64 requestIdMSB = 1; int64 requestIdLSB = 2; - string response = 3; + optional string response = 3; int32 error = 4; } diff --git a/common/proto/src/test/java/org/thingsboard/server/common/util/ProtoUtilsTest.java b/common/proto/src/test/java/org/thingsboard/server/common/util/ProtoUtilsTest.java index 78e30aa480..cf376365b5 100644 --- a/common/proto/src/test/java/org/thingsboard/server/common/util/ProtoUtilsTest.java +++ b/common/proto/src/test/java/org/thingsboard/server/common/util/ProtoUtilsTest.java @@ -228,6 +228,17 @@ class ProtoUtilsTest { assertThat(ProtoUtils.fromProto(serializedMsg)).as("deserialized").isEqualTo(msg); } + @Test + void protoFromDeviceRpcResponseOnewaySerialization() { + // Oneway RPC success: response and error are both null. Relies on the proto + // 'optional string response' presence bit so the receiver round-trips null + // rather than seeing the proto3 default "". + FromDeviceRpcResponseActorMsg msg = new FromDeviceRpcResponseActorMsg(23, tenantId, deviceId, new FromDeviceRpcResponse(id, null, null)); + TransportProtos.ToDeviceActorNotificationMsgProto serializedMsg = ProtoUtils.toProto(msg); + Assertions.assertNotNull(serializedMsg); + assertThat(ProtoUtils.fromProto(serializedMsg)).as("deserialized").isEqualTo(msg); + } + @Test void protoRemoveRpcActorSerialization() { RemoveRpcActorMsg msg = new RemoveRpcActorMsg(tenantId, deviceId, id); diff --git a/common/queue/pom.xml b/common/queue/pom.xml index 16a508ee43..b24edd37d3 100644 --- a/common/queue/pom.xml +++ b/common/queue/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard - 4.3.1.2-SNAPSHOT + 4.3.1.3-SNAPSHOT common org.thingsboard.common diff --git a/common/script/pom.xml b/common/script/pom.xml index 278799abee..db477115cb 100644 --- a/common/script/pom.xml +++ b/common/script/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard - 4.3.1.2-SNAPSHOT + 4.3.1.3-SNAPSHOT common org.thingsboard.common diff --git a/common/script/remote-js-client/pom.xml b/common/script/remote-js-client/pom.xml index 4de100d423..8add60dd90 100644 --- a/common/script/remote-js-client/pom.xml +++ b/common/script/remote-js-client/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard.common - 4.3.1.2-SNAPSHOT + 4.3.1.3-SNAPSHOT script org.thingsboard.common.script diff --git a/common/script/script-api/pom.xml b/common/script/script-api/pom.xml index 2455dc3423..45adffcceb 100644 --- a/common/script/script-api/pom.xml +++ b/common/script/script-api/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard.common - 4.3.1.2-SNAPSHOT + 4.3.1.3-SNAPSHOT script org.thingsboard.common.script diff --git a/common/stats/pom.xml b/common/stats/pom.xml index a89ee61a62..28930308ce 100644 --- a/common/stats/pom.xml +++ b/common/stats/pom.xml @@ -22,7 +22,7 @@ 4.0.0 org.thingsboard - 4.3.1.2-SNAPSHOT + 4.3.1.3-SNAPSHOT common org.thingsboard.common diff --git a/common/transport/coap/pom.xml b/common/transport/coap/pom.xml index a44fd7334f..0608a957ab 100644 --- a/common/transport/coap/pom.xml +++ b/common/transport/coap/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard.common - 4.3.1.2-SNAPSHOT + 4.3.1.3-SNAPSHOT transport org.thingsboard.common.transport diff --git a/common/transport/http/pom.xml b/common/transport/http/pom.xml index 9e51de98b4..77949bb51e 100644 --- a/common/transport/http/pom.xml +++ b/common/transport/http/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard.common - 4.3.1.2-SNAPSHOT + 4.3.1.3-SNAPSHOT transport org.thingsboard.common.transport diff --git a/common/transport/lwm2m/pom.xml b/common/transport/lwm2m/pom.xml index 9bf13bb33d..e71e0d9a9d 100644 --- a/common/transport/lwm2m/pom.xml +++ b/common/transport/lwm2m/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard.common - 4.3.1.2-SNAPSHOT + 4.3.1.3-SNAPSHOT transport org.thingsboard.common.transport diff --git a/common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/bootstrap/LwM2MTransportBootstrapService.java b/common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/bootstrap/LwM2MTransportBootstrapService.java index 9b370d0b71..639e0bf74a 100644 --- a/common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/bootstrap/LwM2MTransportBootstrapService.java +++ b/common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/bootstrap/LwM2MTransportBootstrapService.java @@ -82,13 +82,32 @@ public class LwM2MTransportBootstrapService implements SmartInitializingSingleto @PostConstruct public void init() { log.info("Starting LwM2M transport bootstrap server..."); - this.server = getLhBootstrapServer(); - this.server.start(); - log.info("Started LwM2M transport bootstrap server."); + LeshanBootstrapServer bootstrapServer = null; + try { + bootstrapServer = getLhBootstrapServer(); + this.server = bootstrapServer; + bootstrapServer.start(); + log.info("Started LwM2M transport bootstrap server."); + } catch (RuntimeException e) { + log.error("Failed to start LwM2M transport bootstrap server, releasing resources", e); + try { + if (bootstrapServer != null) { + bootstrapServer.destroy(); + } + } catch (Exception suppressed) { + e.addSuppressed(suppressed); + } finally { + this.server = null; + } + throw e; + } } @PreDestroy public void shutdown() { + if (server == null) { + return; + } try { log.info("Stopping LwM2M transport bootstrap server!"); server.destroy(); diff --git a/common/transport/lwm2m/src/test/java/org/thingsboard/server/transport/lwm2m/bootstrap/LwM2MTransportBootstrapServiceTest.java b/common/transport/lwm2m/src/test/java/org/thingsboard/server/transport/lwm2m/bootstrap/LwM2MTransportBootstrapServiceTest.java new file mode 100644 index 0000000000..67e8265361 --- /dev/null +++ b/common/transport/lwm2m/src/test/java/org/thingsboard/server/transport/lwm2m/bootstrap/LwM2MTransportBootstrapServiceTest.java @@ -0,0 +1,115 @@ +/** + * 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.lwm2m.bootstrap; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.mockito.junit.jupiter.MockitoSettings; +import org.mockito.quality.Strictness; +import org.springframework.test.util.ReflectionTestUtils; +import org.thingsboard.server.common.transport.TransportService; +import org.thingsboard.server.transport.lwm2m.bootstrap.secure.TbLwM2MDtlsBootstrapCertificateVerifier; +import org.thingsboard.server.transport.lwm2m.bootstrap.store.LwM2MBootstrapSecurityStore; +import org.thingsboard.server.transport.lwm2m.bootstrap.store.LwM2MInMemoryBootstrapConfigStore; +import org.thingsboard.server.transport.lwm2m.config.LwM2MTransportBootstrapConfig; +import org.thingsboard.server.transport.lwm2m.config.LwM2MTransportServerConfig; + +import java.net.DatagramSocket; +import java.net.InetAddress; +import java.net.InetSocketAddress; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +@MockitoSettings(strictness = Strictness.LENIENT) +public class LwM2MTransportBootstrapServiceTest { + + private static final String HOST = "127.0.0.1"; + + @Mock + private LwM2MTransportServerConfig serverConfig; + + @Mock + private LwM2MTransportBootstrapConfig bootstrapConfig; + + @Mock + private LwM2MBootstrapSecurityStore lwM2MBootstrapSecurityStore; + + @Mock + private LwM2MInMemoryBootstrapConfigStore lwM2MInMemoryBootstrapConfigStore; + + @Mock + private TransportService transportService; + + @Mock + private TbLwM2MDtlsBootstrapCertificateVerifier certificateVerifier; + + private LwM2MTransportBootstrapService service; + private DatagramSocket occupiedPlain; + private DatagramSocket occupiedSecure; + + @BeforeEach + public void setUp() throws Exception { + occupiedPlain = new DatagramSocket(new InetSocketAddress(InetAddress.getByName(HOST), 0)); + occupiedSecure = new DatagramSocket(new InetSocketAddress(InetAddress.getByName(HOST), 0)); + + when(bootstrapConfig.getHost()).thenReturn(HOST); + when(bootstrapConfig.getPort()).thenReturn(occupiedPlain.getLocalPort()); + when(bootstrapConfig.getSecureHost()).thenReturn(HOST); + when(bootstrapConfig.getSecurePort()).thenReturn(occupiedSecure.getLocalPort()); + when(bootstrapConfig.getSslCredentials()).thenReturn(null); + + when(serverConfig.isRecommendedCiphers()).thenReturn(false); + when(serverConfig.isRecommendedSupportedGroups()).thenReturn(false); + when(serverConfig.getDtlsRetransmissionTimeout()).thenReturn(9000); + when(serverConfig.getDtlsCidLength()).thenReturn(null); + + service = new LwM2MTransportBootstrapService( + serverConfig, + bootstrapConfig, + lwM2MBootstrapSecurityStore, + lwM2MInMemoryBootstrapConfigStore, + transportService, + certificateVerifier + ); + } + + @AfterEach + public void tearDown() { + if (occupiedPlain != null && !occupiedPlain.isClosed()) { + occupiedPlain.close(); + } + if (occupiedSecure != null && !occupiedSecure.isClosed()) { + occupiedSecure.close(); + } + } + + @Test + public void whenEndpointsFailToStart_thenInitThrowsAndReleasesBootstrapServer() { + assertThatThrownBy(() -> service.init()) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("None of the server endpoints could be started"); + + assertThat(ReflectionTestUtils.getField(service, "server")).isNull(); + } + +} diff --git a/common/transport/mqtt/pom.xml b/common/transport/mqtt/pom.xml index 87ca63fcba..2533ec2b0f 100644 --- a/common/transport/mqtt/pom.xml +++ b/common/transport/mqtt/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard.common - 4.3.1.2-SNAPSHOT + 4.3.1.3-SNAPSHOT transport org.thingsboard.common.transport diff --git a/common/transport/pom.xml b/common/transport/pom.xml index 12f0c87e2c..1629826eae 100644 --- a/common/transport/pom.xml +++ b/common/transport/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard - 4.3.1.2-SNAPSHOT + 4.3.1.3-SNAPSHOT common org.thingsboard.common diff --git a/common/transport/snmp/pom.xml b/common/transport/snmp/pom.xml index 2f4e2dae76..bb607f4b35 100644 --- a/common/transport/snmp/pom.xml +++ b/common/transport/snmp/pom.xml @@ -21,7 +21,7 @@ org.thingsboard.common - 4.3.1.2-SNAPSHOT + 4.3.1.3-SNAPSHOT transport diff --git a/common/transport/transport-api/pom.xml b/common/transport/transport-api/pom.xml index d02278a5cc..6019b3b17c 100644 --- a/common/transport/transport-api/pom.xml +++ b/common/transport/transport-api/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard.common - 4.3.1.2-SNAPSHOT + 4.3.1.3-SNAPSHOT transport org.thingsboard.common.transport diff --git a/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/limits/DefaultTransportRateLimitService.java b/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/limits/DefaultTransportRateLimitService.java index e6c3cab1ba..e7ef634c72 100644 --- a/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/limits/DefaultTransportRateLimitService.java +++ b/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/limits/DefaultTransportRateLimitService.java @@ -107,11 +107,12 @@ public class DefaultTransportRateLimitService implements TransportRateLimitServi @Override public void update(TenantProfileUpdateResult update) { - log.info("Received tenant profile update: {}", update.getProfile()); - EntityTransportRateLimits tenantRateLimitPrototype = createRateLimits(update.getProfile(), TENANT_LIMITS); - EntityTransportRateLimits deviceRateLimitPrototype = createRateLimits(update.getProfile(), DEVICE_LIMITS); - EntityTransportRateLimits gatewayRateLimitPrototype = createRateLimits(update.getProfile(), GATEWAY_LIMITS); - EntityTransportRateLimits gatewayDeviceRateLimitPrototype = createRateLimits(update.getProfile(), GATEWAY_DEVICE_LIMITS); + TenantProfile profile = update.getProfile(); + log.info("Received tenant profile update: {}", profile); + EntityTransportRateLimits tenantRateLimitPrototype = createRateLimits(profile, TENANT_LIMITS); + EntityTransportRateLimits deviceRateLimitPrototype = createRateLimits(profile, DEVICE_LIMITS); + EntityTransportRateLimits gatewayRateLimitPrototype = createRateLimits(profile, GATEWAY_LIMITS); + EntityTransportRateLimits gatewayDeviceRateLimitPrototype = createRateLimits(profile, GATEWAY_DEVICE_LIMITS); for (TenantId tenantId : update.getAffectedTenants()) { update(tenantId, tenantRateLimitPrototype, deviceRateLimitPrototype, gatewayRateLimitPrototype, gatewayDeviceRateLimitPrototype); } @@ -119,11 +120,13 @@ public class DefaultTransportRateLimitService implements TransportRateLimitServi @Override public void update(TenantId tenantId) { - EntityTransportRateLimits tenantRateLimitPrototype = createRateLimits(tenantProfileCache.get(tenantId), TENANT_LIMITS); - EntityTransportRateLimits deviceRateLimitPrototype = createRateLimits(tenantProfileCache.get(tenantId), DEVICE_LIMITS); - EntityTransportRateLimits gatewayRateLimitPrototype = createRateLimits(tenantProfileCache.get(tenantId), GATEWAY_LIMITS); - EntityTransportRateLimits gatewayDeviceRateLimitPrototype = createRateLimits(tenantProfileCache.get(tenantId), GATEWAY_DEVICE_LIMITS); - update(tenantId, tenantRateLimitPrototype, deviceRateLimitPrototype, gatewayRateLimitPrototype, gatewayDeviceRateLimitPrototype); + TenantProfile profile = tenantProfileCache.get(tenantId); + update(tenantId, + createRateLimits(profile, TENANT_LIMITS), + createRateLimits(profile, DEVICE_LIMITS), + createRateLimits(profile, GATEWAY_LIMITS), + createRateLimits(profile, GATEWAY_DEVICE_LIMITS) + ); } private void update(TenantId tenantId, EntityTransportRateLimits tenantRateLimitPrototype, EntityTransportRateLimits deviceRateLimitPrototype, @@ -231,25 +234,26 @@ public class DefaultTransportRateLimitService implements TransportRateLimitServi BiConsumer putFunction) { EntityTransportRateLimits oldRateLimits = getFunction.apply(entityId); if (oldRateLimits == null) { - if (EntityType.TENANT.equals(entityId.getEntityType())) { - log.info("[{}] New rate limits: {}", entityId, newRateLimits); - } else { - log.debug("[{}] New rate limits: {}", entityId, newRateLimits); - } + logLimits(entityId, "New", newRateLimits); putFunction.accept(entityId, newRateLimits); } else { EntityTransportRateLimits updated = merge(oldRateLimits, newRateLimits); if (updated != null) { - if (EntityType.TENANT.equals(entityId.getEntityType())) { - log.info("[{}] Updated rate limits: {}", entityId, updated); - } else { - log.debug("[{}] Updated rate limits: {}", entityId, updated); - } + logLimits(entityId, "Updated", updated); putFunction.accept(entityId, updated); } } } + private void logLimits(EntityId entityId, String action, EntityTransportRateLimits limits) { + // Tenant-level changes are logged at INFO; the much noisier per-device/gateway ones at DEBUG. + if (EntityType.TENANT.equals(entityId.getEntityType())) { + log.info("[{}] {} rate limits: {}", entityId, action, limits); + } else { + log.debug("[{}] {} rate limits: {}", entityId, action, limits); + } + } + private EntityTransportRateLimits merge(EntityTransportRateLimits oldRateLimits, EntityTransportRateLimits newRateLimits) { boolean regularUpdate = !oldRateLimits.getRegularMsgRateLimit().getConfiguration().equals(newRateLimits.getRegularMsgRateLimit().getConfiguration()); boolean telemetryMsgRateUpdate = !oldRateLimits.getTelemetryMsgRateLimit().getConfiguration().equals(newRateLimits.getTelemetryMsgRateLimit().getConfiguration()); @@ -269,36 +273,12 @@ public class DefaultTransportRateLimitService implements TransportRateLimitServi DefaultTenantProfileConfiguration profile = (DefaultTenantProfileConfiguration) profileData.getConfiguration(); if (profile == null) { return new EntityTransportRateLimits(ALLOW, ALLOW, ALLOW); - } else { - TransportRateLimit regularMsgRateLimit; - TransportRateLimit telemetryMsgRateLimit; - TransportRateLimit telemetryDpRateLimit; - switch (limitsType) { - case TENANT_LIMITS -> { - regularMsgRateLimit = newLimit(profile.getTransportTenantMsgRateLimit()); - telemetryMsgRateLimit = newLimit(profile.getTransportTenantTelemetryMsgRateLimit()); - telemetryDpRateLimit = newLimit(profile.getTransportTenantTelemetryDataPointsRateLimit()); - } - case DEVICE_LIMITS -> { - regularMsgRateLimit = newLimit(profile.getTransportDeviceMsgRateLimit()); - telemetryMsgRateLimit = newLimit(profile.getTransportDeviceTelemetryMsgRateLimit()); - telemetryDpRateLimit = newLimit(profile.getTransportDeviceTelemetryDataPointsRateLimit()); - } - case GATEWAY_LIMITS -> { - regularMsgRateLimit = newLimit(profile.getTransportGatewayMsgRateLimit()); - telemetryMsgRateLimit = newLimit(profile.getTransportGatewayTelemetryMsgRateLimit()); - telemetryDpRateLimit = newLimit(profile.getTransportGatewayTelemetryDataPointsRateLimit()); - } - case GATEWAY_DEVICE_LIMITS -> { - regularMsgRateLimit = newLimit(profile.getTransportGatewayDeviceMsgRateLimit()); - telemetryMsgRateLimit = newLimit(profile.getTransportGatewayDeviceTelemetryMsgRateLimit()); - telemetryDpRateLimit = newLimit(profile.getTransportGatewayDeviceTelemetryDataPointsRateLimit()); - } - default -> throw new IllegalStateException("Unknown limits type: " + limitsType); - } - - return new EntityTransportRateLimits(regularMsgRateLimit, telemetryMsgRateLimit, telemetryDpRateLimit); } + return new EntityTransportRateLimits( + newLimit(limitsType.getRegularMsgRateLimit().apply(profile)), + newLimit(limitsType.getTelemetryMsgRateLimit().apply(profile)), + newLimit(limitsType.getTelemetryDataPointsRateLimit().apply(profile)) + ); } private static TransportRateLimit newLimit(String config) { @@ -306,31 +286,36 @@ public class DefaultTransportRateLimitService implements TransportRateLimitServi } private EntityTransportRateLimits getTenantRateLimits(TenantId tenantId) { - return perTenantLimits.computeIfAbsent(tenantId, k -> createRateLimits(tenantProfileCache.get(tenantId), TENANT_LIMITS)); + return getRateLimits(perTenantLimits, tenantId, tenantId, TENANT_LIMITS, null); } private EntityTransportRateLimits getDeviceRateLimits(TenantId tenantId, DeviceId deviceId) { - return perDeviceLimits.computeIfAbsent(deviceId, k -> { - EntityTransportRateLimits limits = createRateLimits(tenantProfileCache.get(tenantId), DEVICE_LIMITS); - getTenantDevices(tenantId).add(deviceId); - return limits; - }); + return getRateLimits(perDeviceLimits, tenantId, deviceId, DEVICE_LIMITS, () -> getTenantDevices(tenantId).add(deviceId)); } private EntityTransportRateLimits getGatewayRateLimits(TenantId tenantId, DeviceId gatewayId) { - return perGatewayLimits.computeIfAbsent(gatewayId, k -> { - EntityTransportRateLimits limits = createRateLimits(tenantProfileCache.get(tenantId), GATEWAY_LIMITS); - getTenantGateways(tenantId).add(gatewayId); - return limits; - }); + return getRateLimits(perGatewayLimits, tenantId, gatewayId, GATEWAY_LIMITS, () -> getTenantGateways(tenantId).add(gatewayId)); } private EntityTransportRateLimits getGatewayDeviceRateLimits(TenantId tenantId, DeviceId gatewayId) { - return perGatewayDeviceLimits.computeIfAbsent(gatewayId, k -> { - EntityTransportRateLimits limits = createRateLimits(tenantProfileCache.get(tenantId), GATEWAY_DEVICE_LIMITS); - getTenantGatewayDevices(tenantId).add(gatewayId); - return limits; - }); + return getRateLimits(perGatewayDeviceLimits, tenantId, gatewayId, GATEWAY_DEVICE_LIMITS, () -> getTenantGatewayDevices(tenantId).add(gatewayId)); + } + + private EntityTransportRateLimits getRateLimits(ConcurrentMap limitsMap, TenantId tenantId, + T entityId, TransportLimitsType limitsType, Runnable onMiss) { + EntityTransportRateLimits limits = limitsMap.get(entityId); + if (limits == null) { + // Resolve the tenant profile WITHOUT holding the ConcurrentHashMap bin lock: the fetch may + // block on a cross-service round-trip, so it must run before computeIfAbsent's mapping function. + TenantProfile tenantProfile = tenantProfileCache.get(tenantId); + limits = limitsMap.computeIfAbsent(entityId, k -> createRateLimits(tenantProfile, limitsType)); + // Runs on every observed miss, including callers that lost the computeIfAbsent race and got an + // existing value back - NOT only on actual creation, so the callback must be idempotent. + if (onMiss != null) { + onMiss.run(); + } + } + return limits; } private Set getTenantDevices(TenantId tenantId) { diff --git a/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/limits/TransportLimitsType.java b/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/limits/TransportLimitsType.java index 29077877a8..3a124da2b6 100644 --- a/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/limits/TransportLimitsType.java +++ b/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/limits/TransportLimitsType.java @@ -15,6 +15,39 @@ */ package org.thingsboard.server.common.transport.limits; +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import org.thingsboard.server.common.data.tenant.profile.DefaultTenantProfileConfiguration; + +import java.util.function.Function; + +@Getter +@RequiredArgsConstructor public enum TransportLimitsType { - TENANT_LIMITS, DEVICE_LIMITS, GATEWAY_LIMITS, GATEWAY_DEVICE_LIMITS + + TENANT_LIMITS( + DefaultTenantProfileConfiguration::getTransportTenantMsgRateLimit, + DefaultTenantProfileConfiguration::getTransportTenantTelemetryMsgRateLimit, + DefaultTenantProfileConfiguration::getTransportTenantTelemetryDataPointsRateLimit + ), + DEVICE_LIMITS( + DefaultTenantProfileConfiguration::getTransportDeviceMsgRateLimit, + DefaultTenantProfileConfiguration::getTransportDeviceTelemetryMsgRateLimit, + DefaultTenantProfileConfiguration::getTransportDeviceTelemetryDataPointsRateLimit + ), + GATEWAY_LIMITS( + DefaultTenantProfileConfiguration::getTransportGatewayMsgRateLimit, + DefaultTenantProfileConfiguration::getTransportGatewayTelemetryMsgRateLimit, + DefaultTenantProfileConfiguration::getTransportGatewayTelemetryDataPointsRateLimit + ), + GATEWAY_DEVICE_LIMITS( + DefaultTenantProfileConfiguration::getTransportGatewayDeviceMsgRateLimit, + DefaultTenantProfileConfiguration::getTransportGatewayDeviceTelemetryMsgRateLimit, + DefaultTenantProfileConfiguration::getTransportGatewayDeviceTelemetryDataPointsRateLimit + ); + + private final Function regularMsgRateLimit; + private final Function telemetryMsgRateLimit; + private final Function telemetryDataPointsRateLimit; + } diff --git a/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/service/CertificateReloadManager.java b/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/service/CertificateReloadManager.java index 63f2247aba..82b7981f8f 100644 --- a/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/service/CertificateReloadManager.java +++ b/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/service/CertificateReloadManager.java @@ -27,7 +27,6 @@ import org.thingsboard.server.common.transport.config.ssl.SslCredentials; import org.thingsboard.server.common.transport.config.ssl.SslCredentialsConfig; import org.thingsboard.server.queue.util.TbTransportComponent; -import java.io.IOException; import java.io.InputStream; import java.nio.file.Files; import java.nio.file.Path; @@ -165,7 +164,6 @@ public class CertificateReloadManager implements SmartInitializingSingleton, Dis static class CertificateWatcher { private final List paths; private final Runnable reloadCallback; - private final Map lastModifiedMap; private final Map lastChecksumMap; private int consecutiveFailures; private String failedCombinedChecksum; @@ -173,60 +171,23 @@ public class CertificateReloadManager implements SmartInitializingSingleton, Dis CertificateWatcher(List paths, Runnable reloadCallback) { this.paths = paths; this.reloadCallback = reloadCallback; - this.lastModifiedMap = new HashMap<>(); this.lastChecksumMap = new HashMap<>(); for (Path path : paths) { - lastModifiedMap.put(path, getLastModifiedTime(path)); lastChecksumMap.put(path, calculateChecksum(path)); } this.consecutiveFailures = 0; } synchronized void checkAndReload(String name) { - boolean anyModifiedChanged = false; - for (Path path : paths) { - long currentModified = getLastModifiedTime(path); - Long lastModified = lastModifiedMap.getOrDefault(path, 0L); - if (currentModified != lastModified) { - anyModifiedChanged = true; - break; - } - } - if (!anyModifiedChanged) { - return; - } - - // Capture mtimes and checksums together before the callback runs. - // Pairing a post-callback mtime with a pre-callback checksum would let a write-during-reload be missed on the next poll. - Map currentModifiedTimes = new HashMap<>(); Map currentChecksums = new HashMap<>(); - StringBuilder combined = new StringBuilder(); for (Path path : paths) { - currentModifiedTimes.put(path, getLastModifiedTime(path)); - String checksum = calculateChecksum(path); - currentChecksums.put(path, checksum); - if (!combined.isEmpty()) { - combined.append("|"); - } - combined.append(path).append("=").append(checksum); + currentChecksums.put(path, calculateChecksum(path)); } - String combinedChecksum = combined.toString(); - - // Build old combined checksum for comparison - StringBuilder oldCombined = new StringBuilder(); - for (Path path : paths) { - if (!oldCombined.isEmpty()) { - oldCombined.append("|"); - } - oldCombined.append(path).append("=").append(lastChecksumMap.getOrDefault(path, "")); - } - String oldCombinedChecksum = oldCombined.toString(); + String combinedChecksum = combinedChecksum(currentChecksums); + String oldCombinedChecksum = combinedChecksum(lastChecksumMap); if (combinedChecksum.equals(oldCombinedChecksum)) { - // Content unchanged, just update modification times - for (Path path : paths) { - lastModifiedMap.put(path, currentModifiedTimes.get(path)); - } + // Content unchanged return; } @@ -237,41 +198,34 @@ public class CertificateReloadManager implements SmartInitializingSingleton, Dis } if (consecutiveFailures >= MAX_CONSECUTIVE_FAILURES) { - // Update modification times to avoid re-checking mtime and re-computing checksums every poll cycle - for (Path path : paths) { - lastModifiedMap.put(path, currentModifiedTimes.get(path)); - } return; } try { log.info("Certificate change detected for: {}. Triggering reload...", name); reloadCallback.run(); - for (Path path : paths) { - lastModifiedMap.put(path, currentModifiedTimes.get(path)); - lastChecksumMap.put(path, currentChecksums.get(path)); - } + lastChecksumMap.putAll(currentChecksums); consecutiveFailures = 0; failedCombinedChecksum = null; } catch (Exception e) { consecutiveFailures++; failedCombinedChecksum = combinedChecksum; - // Deliberately NOT updating the lastModifiedMap here, so the next poll cycle retries - // (mtime mismatch passes the early gate, checksum matches failedCombinedChecksum). + // Deliberately NOT updating lastChecksumMap here, so the next poll cycle still sees a differing + // checksum, re-enters this method, and retries the same content. log.error("Failed to reload certificate for {} (attempt {}/{}): {}", name, consecutiveFailures, MAX_CONSECUTIVE_FAILURES, e.getMessage(), e); } } - private long getLastModifiedTime(Path path) { - try { - if (!Files.exists(path)) { - return 0; + private String combinedChecksum(Map checksums) { + StringBuilder combined = new StringBuilder(); + for (Path path : paths) { + if (!combined.isEmpty()) { + combined.append("|"); } - return Files.getLastModifiedTime(path).toMillis(); - } catch (IOException e) { - return 0; + combined.append(path).append("=").append(checksums.getOrDefault(path, "")); } + return combined.toString(); } private String calculateChecksum(Path path) { diff --git a/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/service/DefaultTransportService.java b/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/service/DefaultTransportService.java index c0d862ec10..d74d73aa1d 100644 --- a/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/service/DefaultTransportService.java +++ b/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/service/DefaultTransportService.java @@ -153,6 +153,8 @@ public class DefaultTransportService extends TransportActivityManager implements private int notificationsPollDuration; @Value("${transport.stats.enabled:false}") private boolean statsEnabled; + @Value("${transport.callback_thread_pool_size:20}") + private int callbackThreadPoolSize; @Autowired @Lazy @@ -198,7 +200,7 @@ public class DefaultTransportService extends TransportActivityManager implements this.ruleEngineProducerStats = statsFactory.createMessagesStats(StatsType.RULE_ENGINE.getName() + ".producer"); this.tbCoreProducerStats = statsFactory.createMessagesStats(StatsType.CORE.getName() + ".producer"); this.transportApiStats = statsFactory.createMessagesStats(StatsType.TRANSPORT.getName() + ".producer"); - this.transportCallbackExecutor = ThingsBoardExecutors.newWorkStealingPool(20, getClass()); + this.transportCallbackExecutor = ThingsBoardExecutors.newWorkStealingPool(callbackThreadPoolSize, getClass()); this.scheduler.scheduleAtFixedRate(this::invalidateRateLimits, new Random().nextInt((int) sessionReportTimeout), sessionReportTimeout, TimeUnit.MILLISECONDS); transportApiRequestTemplate = queueProvider.createTransportApiRequestTemplate(); transportApiRequestTemplate.setMessagesStats(transportApiStats); diff --git a/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/service/DefaultTransportTenantProfileCache.java b/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/service/DefaultTransportTenantProfileCache.java index 4923bcd9a2..ac2fd4c28a 100644 --- a/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/service/DefaultTransportTenantProfileCache.java +++ b/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/service/DefaultTransportTenantProfileCache.java @@ -15,6 +15,7 @@ */ package org.thingsboard.server.common.transport.service; +import com.google.common.util.concurrent.Striped; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.context.annotation.Lazy; @@ -37,14 +38,20 @@ import java.util.Set; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ConcurrentMap; import java.util.concurrent.locks.Lock; -import java.util.concurrent.locks.ReentrantLock; @Component @TbTransportComponent @Slf4j public class DefaultTransportTenantProfileCache implements TransportTenantProfileCache { - private final Lock tenantProfileFetchLock = new ReentrantLock(); + // Number of stripes for the per-tenant fetch locks. Only contended during concurrent cold-cache + // misses (cached tenants never take the lock), and concurrent fetches are already bounded by the + // transport callback pool, so this comfortably over-provisions the realistic concurrency. + private static final int TENANT_PROFILE_FETCH_LOCK_STRIPES = 1024; + + // Bounded set of per-tenant locks: de-duplicates concurrent misses for the same tenant while + // letting different tenants fetch concurrently (eager array - no weak-ref overhead at this size). + private final Striped tenantProfileFetchLocks = Striped.lock(TENANT_PROFILE_FETCH_LOCK_STRIPES); private final ConcurrentMap profiles = new ConcurrentHashMap<>(); private final ConcurrentMap tenantIds = new ConcurrentHashMap<>(); private final ConcurrentMap> tenantProfileIds = new ConcurrentHashMap<>(); @@ -103,43 +110,52 @@ public class DefaultTransportTenantProfileCache implements TransportTenantProfil } private TenantProfile getTenantProfile(TenantId tenantId) { - TenantProfile profile = null; - TenantProfileId tenantProfileId = tenantIds.get(tenantId); - if (tenantProfileId != null) { - profile = profiles.get(tenantProfileId); - } + TenantProfile profile = lookupCached(tenantId); if (profile == null) { - tenantProfileFetchLock.lock(); + // Per-tenant lock: de-duplicates concurrent misses for the SAME tenant while allowing + // different tenants to resolve their profiles concurrently. + Lock lock = tenantProfileFetchLocks.get(tenantId); + lock.lock(); try { - tenantProfileId = tenantIds.get(tenantId); - if (tenantProfileId != null) { - profile = profiles.get(tenantProfileId); - } + profile = lookupCached(tenantId); if (profile == null) { - TransportProtos.GetEntityProfileRequestMsg msg = TransportProtos.GetEntityProfileRequestMsg.newBuilder() - .setEntityType(EntityType.TENANT.name()) - .setEntityIdMSB(tenantId.getId().getMostSignificantBits()) - .setEntityIdLSB(tenantId.getId().getLeastSignificantBits()) - .build(); - TransportProtos.GetEntityProfileResponseMsg entityProfileMsg = transportService.getEntityProfile(msg); - profile = ProtoUtils.fromProto(entityProfileMsg.getTenantProfile()); - TenantProfile existingProfile = profiles.get(profile.getId()); - if (existingProfile != null) { - profile = existingProfile; - } else { - profiles.put(profile.getId(), profile); - } - tenantProfileIds.computeIfAbsent(profile.getId(), id -> ConcurrentHashMap.newKeySet()).add(tenantId); - tenantIds.put(tenantId, profile.getId()); - ApiUsageState apiUsageState = ProtoUtils.fromProto(entityProfileMsg.getApiState()); - rateLimitService.update(tenantId, apiUsageState.isTransportEnabled()); + profile = fetchAndCacheTenantProfile(tenantId); } } finally { - tenantProfileFetchLock.unlock(); + lock.unlock(); } } return profile; } + private TenantProfile lookupCached(TenantId tenantId) { + TenantProfileId tenantProfileId = tenantIds.get(tenantId); + if (tenantProfileId != null) { + return profiles.get(tenantProfileId); + } + return null; + } + + private TenantProfile fetchAndCacheTenantProfile(TenantId tenantId) { + TransportProtos.GetEntityProfileRequestMsg msg = TransportProtos.GetEntityProfileRequestMsg.newBuilder() + .setEntityType(EntityType.TENANT.name()) + .setEntityIdMSB(tenantId.getId().getMostSignificantBits()) + .setEntityIdLSB(tenantId.getId().getLeastSignificantBits()) + .build(); + TransportProtos.GetEntityProfileResponseMsg entityProfileMsg = transportService.getEntityProfile(msg); + TenantProfile profile = ProtoUtils.fromProto(entityProfileMsg.getTenantProfile()); + TenantProfile existingProfile = profiles.get(profile.getId()); + if (existingProfile != null) { + profile = existingProfile; + } else { + profiles.put(profile.getId(), profile); + } + tenantProfileIds.computeIfAbsent(profile.getId(), id -> ConcurrentHashMap.newKeySet()).add(tenantId); + tenantIds.put(tenantId, profile.getId()); + ApiUsageState apiUsageState = ProtoUtils.fromProto(entityProfileMsg.getApiState()); + rateLimitService.update(tenantId, apiUsageState.isTransportEnabled()); + return profile; + } + } diff --git a/common/transport/transport-api/src/test/java/org/thingsboard/server/common/transport/limits/DefaultTransportRateLimitServiceTest.java b/common/transport/transport-api/src/test/java/org/thingsboard/server/common/transport/limits/DefaultTransportRateLimitServiceTest.java new file mode 100644 index 0000000000..b7b6a81abf --- /dev/null +++ b/common/transport/transport-api/src/test/java/org/thingsboard/server/common/transport/limits/DefaultTransportRateLimitServiceTest.java @@ -0,0 +1,202 @@ +/** + * 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.common.transport.limits; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.EnumSource; +import org.thingsboard.server.common.data.TenantProfile; +import org.thingsboard.server.common.data.id.DeviceId; +import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.common.data.id.TenantProfileId; +import org.thingsboard.server.common.data.tenant.profile.DefaultTenantProfileConfiguration; +import org.thingsboard.server.common.data.tenant.profile.TenantProfileData; +import org.thingsboard.server.common.transport.TransportTenantProfileCache; +import org.thingsboard.server.common.transport.profile.TenantProfileUpdateResult; + +import java.util.Set; +import java.util.UUID; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.TimeUnit; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +class DefaultTransportRateLimitServiceTest { + + private TransportTenantProfileCache tenantProfileCache; + private ExecutorService executor; + + private final TenantId tenant = TenantId.fromUUID(UUID.randomUUID()); + + @BeforeEach + void setUp() { + tenantProfileCache = mock(TransportTenantProfileCache.class); + executor = Executors.newCachedThreadPool(); + } + + @AfterEach + void tearDown() { + executor.shutdownNow(); + } + + @Test + void checkLimitsDoesNotHoldMapBinLockAcrossProfileFetch() throws Exception { + // Two concurrent rate-limit checks for the SAME tenant must both be able to reach + // the (blocking) tenant-profile fetch concurrently. If the blocking fetch runs inside + // ConcurrentHashMap.computeIfAbsent, the second caller is stuck on the bin reservation + // node and never reaches the fetch -> the latch never reaches zero. + CountDownLatch bothCallersReachedFetch = new CountDownLatch(2); + CountDownLatch releaseFetch = new CountDownLatch(1); + + when(tenantProfileCache.get(tenant)).thenAnswer(invocation -> { + bothCallersReachedFetch.countDown(); + releaseFetch.await(5, TimeUnit.SECONDS); + return tenantProfile(); + }); + + DefaultTransportRateLimitService service = new DefaultTransportRateLimitService(tenantProfileCache); + + Runnable check = () -> service.checkLimits(tenant, null, null, 1, false); + executor.submit(check); + executor.submit(check); + + boolean bothReached = bothCallersReachedFetch.await(3, TimeUnit.SECONDS); + releaseFetch.countDown(); + + assertThat(bothReached) + .as("both checkLimits calls should reach the profile fetch concurrently (no bin lock across I/O)") + .isTrue(); + } + + @ParameterizedTest + @EnumSource(TransportLimitsType.class) + void eachLimitsTypeReadsItsOwnProfileFields(TransportLimitsType type) { + // Distinct sentinel per profile field so a transposed method reference (e.g. GATEWAY_DEVICE_LIMITS + // wired to the plain gateway getters) resolves to the wrong value and fails the assertion. + DefaultTenantProfileConfiguration config = new DefaultTenantProfileConfiguration(); + config.setTransportTenantMsgRateLimit("tenant-msg"); + config.setTransportTenantTelemetryMsgRateLimit("tenant-tele-msg"); + config.setTransportTenantTelemetryDataPointsRateLimit("tenant-tele-dp"); + config.setTransportDeviceMsgRateLimit("device-msg"); + config.setTransportDeviceTelemetryMsgRateLimit("device-tele-msg"); + config.setTransportDeviceTelemetryDataPointsRateLimit("device-tele-dp"); + config.setTransportGatewayMsgRateLimit("gateway-msg"); + config.setTransportGatewayTelemetryMsgRateLimit("gateway-tele-msg"); + config.setTransportGatewayTelemetryDataPointsRateLimit("gateway-tele-dp"); + config.setTransportGatewayDeviceMsgRateLimit("gateway-device-msg"); + config.setTransportGatewayDeviceTelemetryMsgRateLimit("gateway-device-tele-msg"); + config.setTransportGatewayDeviceTelemetryDataPointsRateLimit("gateway-device-tele-dp"); + + String prefix = switch (type) { + case TENANT_LIMITS -> "tenant"; + case DEVICE_LIMITS -> "device"; + case GATEWAY_LIMITS -> "gateway"; + case GATEWAY_DEVICE_LIMITS -> "gateway-device"; + }; + + assertThat(type.getRegularMsgRateLimit().apply(config)).isEqualTo(prefix + "-msg"); + assertThat(type.getTelemetryMsgRateLimit().apply(config)).isEqualTo(prefix + "-tele-msg"); + assertThat(type.getTelemetryDataPointsRateLimit().apply(config)).isEqualTo(prefix + "-tele-dp"); + } + + @ParameterizedTest + @EnumSource(EntityLevel.class) + void profileUpdateReachesEntityTrackedDuringFirstCheck(EntityLevel level) { + DeviceId entity = new DeviceId(UUID.randomUUID()); + when(tenantProfileCache.get(tenant)).thenReturn(profileWithRegularMsgLimit(level, "100:600")); + DefaultTransportRateLimitService service = new DefaultTransportRateLimitService(tenantProfileCache); + + // First check resolves the (permissive) limit and must register the entity into the per-tenant + // tracking set via the onMiss callback - otherwise a later update(tenantId) can't reach it. + assertThat(level.check(service, tenant, entity)) + .as("permissive limit should allow the first %s check", level).isNull(); + + // Tighten the limit to a single message and push a profile update for this tenant. + service.update(new TenantProfileUpdateResult(profileWithRegularMsgLimit(level, "1:600"), Set.of(tenant))); + + // The freshly merged "1:600" bucket allows exactly one message... + assertThat(level.check(service, tenant, entity)).isNull(); + // ...and blocks the next one. This only happens if update(tenantId) reached the tracked entity. + assertThat(level.check(service, tenant, entity)) + .as("update(tenantId) must reach the tracked %s so the tightened limit applies", level).isNotNull(); + } + + private TenantProfile tenantProfile() { + return profileWith(new DefaultTenantProfileConfiguration()); + } + + private TenantProfile profileWithRegularMsgLimit(EntityLevel level, String regularMsgRateLimit) { + DefaultTenantProfileConfiguration config = new DefaultTenantProfileConfiguration(); + level.setRegularMsgRateLimit(config, regularMsgRateLimit); + return profileWith(config); + } + + private TenantProfile profileWith(DefaultTenantProfileConfiguration config) { + TenantProfile profile = new TenantProfile(new TenantProfileId(UUID.randomUUID())); + profile.setName("test-profile"); + TenantProfileData profileData = new TenantProfileData(); + profileData.setConfiguration(config); + profile.setProfileData(profileData); + return profile; + } + + private enum EntityLevel { + DEVICE { + @Override + void setRegularMsgRateLimit(DefaultTenantProfileConfiguration config, String value) { + config.setTransportDeviceMsgRateLimit(value); + } + + @Override + Object check(DefaultTransportRateLimitService service, TenantId tenantId, DeviceId entityId) { + return service.checkLimits(tenantId, null, entityId, 0, false); + } + }, + GATEWAY { + @Override + void setRegularMsgRateLimit(DefaultTenantProfileConfiguration config, String value) { + config.setTransportGatewayMsgRateLimit(value); + } + + @Override + Object check(DefaultTransportRateLimitService service, TenantId tenantId, DeviceId entityId) { + return service.checkLimits(tenantId, entityId, null, 0, false); + } + }, + GATEWAY_DEVICE { + @Override + void setRegularMsgRateLimit(DefaultTenantProfileConfiguration config, String value) { + config.setTransportGatewayDeviceMsgRateLimit(value); + } + + @Override + Object check(DefaultTransportRateLimitService service, TenantId tenantId, DeviceId entityId) { + return service.checkLimits(tenantId, null, entityId, 0, true); + } + }; + + abstract void setRegularMsgRateLimit(DefaultTenantProfileConfiguration config, String value); + + abstract Object check(DefaultTransportRateLimitService service, TenantId tenantId, DeviceId entityId); + } + +} diff --git a/common/transport/transport-api/src/test/java/org/thingsboard/server/common/transport/service/CertificateReloadManagerTest.java b/common/transport/transport-api/src/test/java/org/thingsboard/server/common/transport/service/CertificateReloadManagerTest.java index 6f78eaefae..8894937177 100644 --- a/common/transport/transport-api/src/test/java/org/thingsboard/server/common/transport/service/CertificateReloadManagerTest.java +++ b/common/transport/transport-api/src/test/java/org/thingsboard/server/common/transport/service/CertificateReloadManagerTest.java @@ -31,10 +31,7 @@ import java.util.concurrent.ScheduledExecutorService; import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicInteger; -import static java.util.concurrent.TimeUnit.MILLISECONDS; -import static java.util.concurrent.TimeUnit.SECONDS; import static org.assertj.core.api.Assertions.assertThat; -import static org.awaitility.Awaitility.await; public class CertificateReloadManagerTest { @@ -59,11 +56,10 @@ public class CertificateReloadManagerTest { } } - private void writeFileAndAwaitMtimeChange(Path path, String content, long baselineMtime) throws IOException { + private void writeFileAndBumpMtime(Path path, String content, long baselineMtime) throws IOException { Files.writeString(path, content); - await().atMost(2, SECONDS) - .pollInterval(10, MILLISECONDS) - .until(() -> Files.getLastModifiedTime(path).toMillis() != baselineMtime); + // Force a strictly newer mtime: back-to-back writes can share a millisecond, hiding the change from the watcher. + Files.setLastModifiedTime(path, FileTime.fromMillis(baselineMtime + 1000)); } private long mtime(Path path) throws IOException { @@ -77,7 +73,7 @@ public class CertificateReloadManagerTest { certificateReloadManager.registerWatcher("test-cert", certFile, reloadCount::incrementAndGet); long baseline = mtime(certFile); - writeFileAndAwaitMtimeChange(certFile, "-----BEGIN CERTIFICATE-----\nTEST_CERT_V2_MODIFIED\n-----END CERTIFICATE-----\n", baseline); + writeFileAndBumpMtime(certFile, "-----BEGIN CERTIFICATE-----\nTEST_CERT_V2_MODIFIED\n-----END CERTIFICATE-----\n", baseline); ReflectionTestUtils.invokeMethod(certificateReloadManager, "checkCertificates"); @@ -85,7 +81,7 @@ public class CertificateReloadManagerTest { } @Test - public void givenCertificateFileUnchanged_whenCheckForChanges_thenShouldNotTriggerReload() throws Exception { + public void givenCertificateFileUnchanged_whenCheckForChanges_thenShouldNotTriggerReload() { AtomicInteger reloadCount = new AtomicInteger(0); certificateReloadManager.registerWatcher("test-cert", certFile, reloadCount::incrementAndGet); @@ -147,7 +143,7 @@ public class CertificateReloadManagerTest { certificateReloadManager.registerWatcher("test-key", keyFile, keyReloadCount::incrementAndGet); long baseline = mtime(keyFile); - writeFileAndAwaitMtimeChange(keyFile, "-----BEGIN PRIVATE KEY-----\nTEST_KEY_V2_MODIFIED\n-----END PRIVATE KEY-----\n", baseline); + writeFileAndBumpMtime(keyFile, "-----BEGIN PRIVATE KEY-----\nTEST_KEY_V2_MODIFIED\n-----END PRIVATE KEY-----\n", baseline); ReflectionTestUtils.invokeMethod(certificateReloadManager, "checkCertificates"); @@ -168,8 +164,8 @@ public class CertificateReloadManagerTest { long baseline1 = mtime(certFile); long baseline2 = mtime(cert2File); - writeFileAndAwaitMtimeChange(certFile, "-----BEGIN CERTIFICATE-----\nMODIFIED1\n-----END CERTIFICATE-----\n", baseline1); - writeFileAndAwaitMtimeChange(cert2File, "-----BEGIN CERTIFICATE-----\nMODIFIED2\n-----END CERTIFICATE-----\n", baseline2); + writeFileAndBumpMtime(certFile, "-----BEGIN CERTIFICATE-----\nMODIFIED1\n-----END CERTIFICATE-----\n", baseline1); + writeFileAndBumpMtime(cert2File, "-----BEGIN CERTIFICATE-----\nMODIFIED2\n-----END CERTIFICATE-----\n", baseline2); ReflectionTestUtils.invokeMethod(certificateReloadManager, "checkCertificates"); @@ -191,8 +187,8 @@ public class CertificateReloadManagerTest { long baseline1 = mtime(certFile); long baseline2 = mtime(cert2File); - writeFileAndAwaitMtimeChange(certFile, "-----BEGIN CERTIFICATE-----\nMODIFIED1\n-----END CERTIFICATE-----\n", baseline1); - writeFileAndAwaitMtimeChange(cert2File, "-----BEGIN CERTIFICATE-----\nMODIFIED2\n-----END CERTIFICATE-----\n", baseline2); + writeFileAndBumpMtime(certFile, "-----BEGIN CERTIFICATE-----\nMODIFIED1\n-----END CERTIFICATE-----\n", baseline1); + writeFileAndBumpMtime(cert2File, "-----BEGIN CERTIFICATE-----\nMODIFIED2\n-----END CERTIFICATE-----\n", baseline2); ReflectionTestUtils.invokeMethod(certificateReloadManager, "checkCertificates"); @@ -224,9 +220,7 @@ public class CertificateReloadManagerTest { for (int i = 0; i < 5; i++) { Files.writeString(certFile, "-----BEGIN CERTIFICATE-----\nCERT_VERSION_" + i + "\n-----END CERTIFICATE-----\n"); } - await().atMost(2, SECONDS) - .pollInterval(10, MILLISECONDS) - .until(() -> mtime(certFile) != baseline); + Files.setLastModifiedTime(certFile, FileTime.fromMillis(baseline + 1000)); ReflectionTestUtils.invokeMethod(certificateReloadManager, "checkCertificates"); @@ -242,7 +236,7 @@ public class CertificateReloadManagerTest { certificateReloadManager.registerWatcher("test-cert", certFile, reloadCount::incrementAndGet); long baseline = mtime(certFile); - writeFileAndAwaitMtimeChange(certFile, "-----BEGIN CERTIFICATE-----\nMODIFIED\n-----END CERTIFICATE-----\n", baseline); + writeFileAndBumpMtime(certFile, "-----BEGIN CERTIFICATE-----\nMODIFIED\n-----END CERTIFICATE-----\n", baseline); for (int i = 0; i < 5; i++) { new Thread(() -> { @@ -272,7 +266,7 @@ public class CertificateReloadManagerTest { certificateReloadManager.registerWatcher("test-cert", certFile, reloadCount::incrementAndGet); long baseline = mtime(certFile); - writeFileAndAwaitMtimeChange(certFile, originalContent, baseline); + writeFileAndBumpMtime(certFile, originalContent, baseline); ReflectionTestUtils.invokeMethod(certificateReloadManager, "checkCertificates"); @@ -289,7 +283,7 @@ public class CertificateReloadManagerTest { }); long baseline = mtime(certFile); - writeFileAndAwaitMtimeChange(certFile, "-----BEGIN CERTIFICATE-----\nBAD_CERT\n-----END CERTIFICATE-----\n", baseline); + writeFileAndBumpMtime(certFile, "-----BEGIN CERTIFICATE-----\nBAD_CERT\n-----END CERTIFICATE-----\n", baseline); for (int i = 0; i < 15; i++) { ReflectionTestUtils.invokeMethod(certificateReloadManager, "checkCertificates"); @@ -311,19 +305,41 @@ public class CertificateReloadManagerTest { }); long baseline = mtime(certFile); - writeFileAndAwaitMtimeChange(certFile, "-----BEGIN CERTIFICATE-----\nBAD_CERT\n-----END CERTIFICATE-----\n", baseline); + writeFileAndBumpMtime(certFile, "-----BEGIN CERTIFICATE-----\nBAD_CERT\n-----END CERTIFICATE-----\n", baseline); ReflectionTestUtils.invokeMethod(certificateReloadManager, "checkCertificates"); assertThat(reloadAttempts.get()).isEqualTo(1); shouldFail.set(0); long baseline2 = mtime(certFile); - writeFileAndAwaitMtimeChange(certFile, "-----BEGIN CERTIFICATE-----\nGOOD_CERT\n-----END CERTIFICATE-----\n", baseline2); + writeFileAndBumpMtime(certFile, "-----BEGIN CERTIFICATE-----\nGOOD_CERT\n-----END CERTIFICATE-----\n", baseline2); ReflectionTestUtils.invokeMethod(certificateReloadManager, "checkCertificates"); assertThat(reloadAttempts.get()).isEqualTo(2); } + @Test + public void givenContentChangedButMtimeUnchanged_whenCheckForChanges_thenShouldTriggerReload() throws Exception { + // Bug fingerprint: a cert-manager rotation that lands in the same wall-clock millisecond as the + // watcher's recorded baseline mtime. Files.getLastModifiedTime().toMillis() truncates to the ms, + // so the rotated content shares the baseline mtime and an mtime-only gate would never re-hash it. + AtomicInteger reloadCount = new AtomicInteger(0); + + certificateReloadManager.registerWatcher("test-cert", certFile, reloadCount::incrementAndGet); + + long baseline = mtime(certFile); + Files.writeString(certFile, "-----BEGIN CERTIFICATE-----\nROTATED_SAME_MS\n-----END CERTIFICATE-----\n"); + // Force the mtime back to the exact baseline millisecond — content changed, timestamp did not. + Files.setLastModifiedTime(certFile, FileTime.fromMillis(baseline)); + + // Sanity guard: the watcher observes a timestamp identical to its recorded baseline. + assertThat(mtime(certFile)).isEqualTo(baseline); + + ReflectionTestUtils.invokeMethod(certificateReloadManager, "checkCertificates"); + + assertThat(reloadCount.get()).isEqualTo(1); + } + @Test public void givenCallbackHitMaxFailures_whenFileChangesToNewContent_thenShouldResetAndRetry() throws Exception { AtomicInteger reloadAttempts = new AtomicInteger(0); @@ -337,7 +353,7 @@ public class CertificateReloadManagerTest { }); long baseline = mtime(certFile); - writeFileAndAwaitMtimeChange(certFile, "-----BEGIN CERTIFICATE-----\nBAD_CERT\n-----END CERTIFICATE-----\n", baseline); + writeFileAndBumpMtime(certFile, "-----BEGIN CERTIFICATE-----\nBAD_CERT\n-----END CERTIFICATE-----\n", baseline); for (int i = 0; i < 15; i++) { ReflectionTestUtils.invokeMethod(certificateReloadManager, "checkCertificates"); @@ -346,7 +362,7 @@ public class CertificateReloadManagerTest { shouldFail.set(0); long baseline2 = mtime(certFile); - writeFileAndAwaitMtimeChange(certFile, "-----BEGIN CERTIFICATE-----\nFIXED_CERT\n-----END CERTIFICATE-----\n", baseline2); + writeFileAndBumpMtime(certFile, "-----BEGIN CERTIFICATE-----\nFIXED_CERT\n-----END CERTIFICATE-----\n", baseline2); ReflectionTestUtils.invokeMethod(certificateReloadManager, "checkCertificates"); assertThat(reloadAttempts.get()).isEqualTo(11); diff --git a/common/transport/transport-api/src/test/java/org/thingsboard/server/common/transport/service/DefaultTransportTenantProfileCacheTest.java b/common/transport/transport-api/src/test/java/org/thingsboard/server/common/transport/service/DefaultTransportTenantProfileCacheTest.java new file mode 100644 index 0000000000..d2b4544f53 --- /dev/null +++ b/common/transport/transport-api/src/test/java/org/thingsboard/server/common/transport/service/DefaultTransportTenantProfileCacheTest.java @@ -0,0 +1,191 @@ +/** + * 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.common.transport.service; + +import com.google.common.util.concurrent.Striped; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.thingsboard.server.common.data.ApiUsageState; +import org.thingsboard.server.common.data.ApiUsageStateValue; +import org.thingsboard.server.common.data.EntityType; +import org.thingsboard.server.common.data.TenantProfile; +import org.thingsboard.server.common.data.id.ApiUsageStateId; +import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.common.data.id.TenantProfileId; +import org.thingsboard.server.common.transport.TransportService; +import org.thingsboard.server.common.transport.limits.TransportRateLimitService; +import org.thingsboard.server.common.util.ProtoUtils; +import org.thingsboard.server.gen.transport.TransportProtos.GetEntityProfileRequestMsg; +import org.thingsboard.server.gen.transport.TransportProtos.GetEntityProfileResponseMsg; + +import java.util.ArrayList; +import java.util.List; +import java.util.UUID; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.locks.Lock; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyBoolean; +import static org.mockito.Mockito.doNothing; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +class DefaultTransportTenantProfileCacheTest { + + private DefaultTransportTenantProfileCache cache; + private TransportService transportService; + private TransportRateLimitService rateLimitService; + private ExecutorService executor; + + // Must match DefaultTransportTenantProfileCache.TENANT_PROFILE_FETCH_LOCK_STRIPES. + private static final int STRIPE_COUNT = 1024; + + private final TenantId tenantA = TenantId.fromUUID(UUID.randomUUID()); + // Deterministically pick a tenant that maps to a DIFFERENT stripe than tenantA, so the cross-tenant + // test below cannot flake on the ~1/1024 chance two random UUIDs hash to the same stripe. + private final TenantId tenantB = differentStripeFrom(tenantA); + + private static TenantId differentStripeFrom(TenantId other) { + Striped probe = Striped.lock(STRIPE_COUNT); + TenantId candidate = TenantId.fromUUID(UUID.randomUUID()); + while (probe.get(candidate) == probe.get(other)) { + candidate = TenantId.fromUUID(UUID.randomUUID()); + } + return candidate; + } + + @BeforeEach + void setUp() { + cache = new DefaultTransportTenantProfileCache(); + transportService = mock(TransportService.class); + rateLimitService = mock(TransportRateLimitService.class); + doNothing().when(rateLimitService).update(any(TenantId.class), anyBoolean()); + cache.setTransportService(transportService); + cache.setRateLimitService(rateLimitService); + executor = Executors.newCachedThreadPool(); + } + + @AfterEach + void tearDown() { + executor.shutdownNow(); + } + + @Test + void fetchForOneTenantDoesNotBlockResolutionOfAnotherTenant() throws Exception { + CountDownLatch tenantAFetchStarted = new CountDownLatch(1); + CountDownLatch releaseTenantA = new CountDownLatch(1); + + GetEntityProfileResponseMsg responseA = responseFor(tenantA); + GetEntityProfileResponseMsg responseB = responseFor(tenantB); + + when(transportService.getEntityProfile(any())).thenAnswer(invocation -> { + GetEntityProfileRequestMsg msg = invocation.getArgument(0); + TenantId requested = TenantId.fromUUID(new UUID(msg.getEntityIdMSB(), msg.getEntityIdLSB())); + if (requested.equals(tenantA)) { + tenantAFetchStarted.countDown(); + releaseTenantA.await(5, TimeUnit.SECONDS); + return responseA; + } + return responseB; + }); + + // T1 starts fetching tenantA's profile and blocks inside the cross-service round-trip. + Future tenantAResult = executor.submit(() -> cache.get(tenantA)); + assertThat(tenantAFetchStarted.await(5, TimeUnit.SECONDS)) + .as("tenantA fetch should have started").isTrue(); + + // T2 resolves a different tenant - it must NOT wait for tenantA's in-flight fetch. + // Fails today (single global lock); passes once locking is per-tenant. + TenantProfile tenantBProfile = CompletableFuture + .supplyAsync(() -> cache.get(tenantB), executor) + .get(2, TimeUnit.SECONDS); + assertThat(tenantBProfile).isNotNull(); + + releaseTenantA.countDown(); + assertThat(tenantAResult.get(5, TimeUnit.SECONDS)).isNotNull(); + } + + @Test + void concurrentMissesForSameTenantDedupeToSingleFetch() throws Exception { + // The per-tenant lock exists precisely so that concurrent cold misses for the SAME tenant collapse + // into a single cross-service fetch (the rest are served from cache). Assert that contract directly. + int callers = 8; + CountDownLatch fetchStarted = new CountDownLatch(1); + CountDownLatch releaseFetch = new CountDownLatch(1); + + when(transportService.getEntityProfile(any())).thenAnswer(invocation -> { + fetchStarted.countDown(); + // Hold the (single) in-flight fetch open while the other callers pile up on the per-tenant lock. + releaseFetch.await(5, TimeUnit.SECONDS); + return responseFor(tenantA); + }); + + CountDownLatch allSubmitted = new CountDownLatch(callers); + List> results = new ArrayList<>(); + for (int i = 0; i < callers; i++) { + results.add(executor.submit(() -> { + allSubmitted.countDown(); + return cache.get(tenantA); + })); + } + + assertThat(allSubmitted.await(5, TimeUnit.SECONDS)).as("all callers should start").isTrue(); + assertThat(fetchStarted.await(5, TimeUnit.SECONDS)).as("the first fetch should start").isTrue(); + releaseFetch.countDown(); + + for (Future result : results) { + assertThat(result.get(5, TimeUnit.SECONDS)).isNotNull(); + } + // All 8 callers resolved the same tenant, but only one of them hit the backend. + verify(transportService, times(1)).getEntityProfile(any()); + } + + private GetEntityProfileResponseMsg responseFor(TenantId tenantId) { + TenantProfile profile = new TenantProfile(new TenantProfileId(UUID.randomUUID())); + profile.setName("profile-" + tenantId.getId()); + return GetEntityProfileResponseMsg.newBuilder() + .setEntityType(EntityType.TENANT.name()) + .setTenantProfile(ProtoUtils.toProto(profile)) + .setApiState(ProtoUtils.toProto(enabledApiUsageState(tenantId))) + .build(); + } + + private ApiUsageState enabledApiUsageState(TenantId tenantId) { + ApiUsageState state = new ApiUsageState(new ApiUsageStateId(UUID.randomUUID())); + state.setTenantId(tenantId); + state.setEntityId(tenantId); + state.setTransportState(ApiUsageStateValue.ENABLED); + state.setDbStorageState(ApiUsageStateValue.ENABLED); + state.setReExecState(ApiUsageStateValue.ENABLED); + state.setJsExecState(ApiUsageStateValue.ENABLED); + state.setTbelExecState(ApiUsageStateValue.ENABLED); + state.setEmailExecState(ApiUsageStateValue.ENABLED); + state.setSmsExecState(ApiUsageStateValue.ENABLED); + state.setAlarmExecState(ApiUsageStateValue.ENABLED); + state.setVersion(1L); + return state; + } + +} diff --git a/common/util/pom.xml b/common/util/pom.xml index 1460119f02..ae6dd29420 100644 --- a/common/util/pom.xml +++ b/common/util/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard - 4.3.1.2-SNAPSHOT + 4.3.1.3-SNAPSHOT common org.thingsboard.common diff --git a/common/util/src/main/java/org/thingsboard/common/util/NumberUtils.java b/common/util/src/main/java/org/thingsboard/common/util/NumberUtils.java index 6681557c87..293120954d 100644 --- a/common/util/src/main/java/org/thingsboard/common/util/NumberUtils.java +++ b/common/util/src/main/java/org/thingsboard/common/util/NumberUtils.java @@ -36,12 +36,16 @@ public class NumberUtils { return BigDecimal.valueOf(value).setScale(0, RoundingMode.HALF_UP).intValue(); } + public static long toLong(double value) { + return BigDecimal.valueOf(value).setScale(0, RoundingMode.HALF_UP).longValue(); + } + public static Object roundResult(double value, Integer precision) { if (precision == null) { return value; } if (precision.equals(0)) { - return toInt(value); + return toLong(value); } return toFixed(value, precision); } diff --git a/common/util/src/test/java/org/thingsboard/common/util/NumberUtilsTest.java b/common/util/src/test/java/org/thingsboard/common/util/NumberUtilsTest.java index 1a1bf326e6..dabfe9cffc 100644 --- a/common/util/src/test/java/org/thingsboard/common/util/NumberUtilsTest.java +++ b/common/util/src/test/java/org/thingsboard/common/util/NumberUtilsTest.java @@ -51,11 +51,20 @@ public class NumberUtilsTest { assertThat(NumberUtils.toInt(28.0)).isEqualTo(28); } + @Test + public void toLong() { + assertThat(NumberUtils.toLong(doubleVal)).isEqualTo(1729L); + assertThat(NumberUtils.toLong(12.8)).isEqualTo(13L); + assertThat(NumberUtils.toLong(28.0)).isEqualTo(28L); + assertThat(NumberUtils.toLong(3_980_173_734.0)).isEqualTo(3_980_173_734L); + } + @Test public void roundResult() { assertThat(NumberUtils.roundResult(doubleVal, null)).isEqualTo(1729.1729); - assertThat(NumberUtils.roundResult(doubleVal, 0)).isEqualTo(1729); + assertThat(NumberUtils.roundResult(doubleVal, 0)).isEqualTo(1729L); assertThat(NumberUtils.roundResult(doubleVal, 2)).isEqualTo(1729.17); + assertThat(NumberUtils.roundResult(3_980_173_734.0, 0)).isEqualTo(3_980_173_734L); } } diff --git a/common/version-control/pom.xml b/common/version-control/pom.xml index 1ac78d2db2..d955a57537 100644 --- a/common/version-control/pom.xml +++ b/common/version-control/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard - 4.3.1.2-SNAPSHOT + 4.3.1.3-SNAPSHOT common org.thingsboard.common diff --git a/dao/pom.xml b/dao/pom.xml index 7b76c664d8..ae9f1a5998 100644 --- a/dao/pom.xml +++ b/dao/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard - 4.3.1.2-SNAPSHOT + 4.3.1.3-SNAPSHOT thingsboard dao diff --git a/dao/src/main/java/org/thingsboard/server/dao/service/validator/DashboardDataValidator.java b/dao/src/main/java/org/thingsboard/server/dao/service/validator/DashboardDataValidator.java index 12c4c62f94..a953e07197 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/service/validator/DashboardDataValidator.java +++ b/dao/src/main/java/org/thingsboard/server/dao/service/validator/DashboardDataValidator.java @@ -32,6 +32,10 @@ public class DashboardDataValidator extends DataValidator { @Override protected void validateCreate(TenantId tenantId, Dashboard data) { + validateMaxDashboardsPerTenant(tenantId); + } + + public void validateMaxDashboardsPerTenant(TenantId tenantId) { validateNumberOfEntitiesPerTenant(tenantId, EntityType.DASHBOARD); } diff --git a/dao/src/main/java/org/thingsboard/server/dao/service/validator/DeviceCredentialsDataValidator.java b/dao/src/main/java/org/thingsboard/server/dao/service/validator/DeviceCredentialsDataValidator.java index 7035fcfd9b..c053f36d5b 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/service/validator/DeviceCredentialsDataValidator.java +++ b/dao/src/main/java/org/thingsboard/server/dao/service/validator/DeviceCredentialsDataValidator.java @@ -18,10 +18,13 @@ package org.thingsboard.server.dao.service.validator; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.context.annotation.Lazy; import org.springframework.stereotype.Component; +import org.thingsboard.common.util.JacksonUtil; import org.thingsboard.server.common.data.Device; import org.thingsboard.server.common.data.StringUtils; +import org.thingsboard.server.common.data.device.credentials.BasicMqttCredentials; import org.thingsboard.server.common.data.id.TenantId; import org.thingsboard.server.common.data.security.DeviceCredentials; +import org.thingsboard.server.common.data.security.DeviceCredentialsType; import org.thingsboard.server.dao.device.DeviceCredentialsDao; import org.thingsboard.server.dao.device.DeviceService; import org.thingsboard.server.dao.exception.DeviceCredentialsValidationException; @@ -69,9 +72,24 @@ public class DeviceCredentialsDataValidator extends DataValidator validator.validateMaxDashboardsPerTenant(tenantId)); + } + + @Test + void validateMaxDashboardsPerTenant_throwsEntitiesLimitExceeded_whenLimitReached() { + long limit = 5; + willReturn(false).given(apiLimitService).checkEntitiesLimit(tenantId, EntityType.DASHBOARD); + willReturn(limit).given(apiLimitService).getLimit(eq(tenantId), any()); + + assertThatThrownBy(() -> validator.validateMaxDashboardsPerTenant(tenantId)) + .isInstanceOfSatisfying(EntitiesLimitExceededException.class, ex -> { + assertThat(ex.getTenantId()).isEqualTo(tenantId); + assertThat(ex.getEntityType()).isEqualTo(EntityType.DASHBOARD); + assertThat(ex.getLimit()).isEqualTo(limit); + }); + } + } diff --git a/dao/src/test/java/org/thingsboard/server/dao/service/validator/DeviceCredentialsDataValidatorTest.java b/dao/src/test/java/org/thingsboard/server/dao/service/validator/DeviceCredentialsDataValidatorTest.java new file mode 100644 index 0000000000..5eca84a466 --- /dev/null +++ b/dao/src/test/java/org/thingsboard/server/dao/service/validator/DeviceCredentialsDataValidatorTest.java @@ -0,0 +1,138 @@ +/** + * 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.dao.service.validator; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.thingsboard.common.util.JacksonUtil; +import org.thingsboard.server.common.data.Device; +import org.thingsboard.server.common.data.device.credentials.BasicMqttCredentials; +import org.thingsboard.server.common.data.id.DeviceId; +import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.common.data.security.DeviceCredentials; +import org.thingsboard.server.common.data.security.DeviceCredentialsType; +import org.thingsboard.server.dao.device.DeviceCredentialsDao; +import org.thingsboard.server.dao.device.DeviceService; +import org.thingsboard.server.dao.exception.DeviceCredentialsValidationException; + +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThatCode; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.BDDMockito.willReturn; + +@ExtendWith(MockitoExtension.class) +class DeviceCredentialsDataValidatorTest { + + @Mock + DeviceCredentialsDao deviceCredentialsDao; + @Mock + DeviceService deviceService; + @InjectMocks + DeviceCredentialsDataValidator validator; + + final TenantId tenantId = TenantId.fromUUID(UUID.fromString("9ef79cdf-37a8-4119-b682-2e7ed4e018da")); + final DeviceId deviceId = new DeviceId(UUID.fromString("11111111-1111-1111-1111-111111111111")); + + @Test + void rejectsNewlineInAccessToken() { + DeviceCredentials creds = accessToken("safe_token\nentrypoint: [\"/bin/sh\"]"); + + assertThatThrownBy(() -> validator.validateDataImpl(tenantId, creds)) + .isInstanceOf(DeviceCredentialsValidationException.class) + .hasMessageContaining("credentialsId") + .hasMessageContaining("control characters"); + } + + @Test + void rejectsCarriageReturnInAccessToken() { + DeviceCredentials creds = accessToken("token\rprivileged: true"); + + assertThatThrownBy(() -> validator.validateDataImpl(tenantId, creds)) + .isInstanceOf(DeviceCredentialsValidationException.class) + .hasMessageContaining("control characters"); + } + + @Test + void rejectsNewlineInMqttClientId() { + DeviceCredentials creds = mqttBasic("cid\nentrypoint: x", "user", "pwd"); + + assertThatThrownBy(() -> validator.validateDataImpl(tenantId, creds)) + .isInstanceOf(DeviceCredentialsValidationException.class) + .hasMessageContaining("clientId"); + } + + @Test + void rejectsNewlineInMqttUserName() { + DeviceCredentials creds = mqttBasic("cid", "user\nprivileged: true", "pwd"); + + assertThatThrownBy(() -> validator.validateDataImpl(tenantId, creds)) + .isInstanceOf(DeviceCredentialsValidationException.class) + .hasMessageContaining("userName"); + } + + @Test + void rejectsNewlineInMqttPassword() { + DeviceCredentials creds = mqttBasic("cid", "user", "pwd\nentrypoint: x"); + + assertThatThrownBy(() -> validator.validateDataImpl(tenantId, creds)) + .isInstanceOf(DeviceCredentialsValidationException.class) + .hasMessageContaining("password"); + } + + @Test + void acceptsValidCredentials() { + willReturn(new Device()).given(deviceService).findDeviceById(tenantId, deviceId); + DeviceCredentials creds = accessToken("safe_token_123"); + + assertThatCode(() -> validator.validateDataImpl(tenantId, creds)) + .doesNotThrowAnyException(); + } + + @Test + void acceptsValidMqttBasicCredentials() { + willReturn(new Device()).given(deviceService).findDeviceById(tenantId, deviceId); + DeviceCredentials creds = mqttBasic("client-1", "user-1", "pwd-1"); + + assertThatCode(() -> validator.validateDataImpl(tenantId, creds)) + .doesNotThrowAnyException(); + } + + private DeviceCredentials accessToken(String token) { + DeviceCredentials c = new DeviceCredentials(); + c.setDeviceId(deviceId); + c.setCredentialsType(DeviceCredentialsType.ACCESS_TOKEN); + c.setCredentialsId(token); + return c; + } + + private DeviceCredentials mqttBasic(String clientId, String userName, String password) { + BasicMqttCredentials inner = new BasicMqttCredentials(); + inner.setClientId(clientId); + inner.setUserName(userName); + inner.setPassword(password); + DeviceCredentials c = new DeviceCredentials(); + c.setDeviceId(deviceId); + c.setCredentialsType(DeviceCredentialsType.MQTT_BASIC); + c.setCredentialsId("mqtt-credentials-id"); + c.setCredentialsValue(JacksonUtil.toString(inner)); + return c; + } + +} diff --git a/dao/src/test/java/org/thingsboard/server/dao/util/DeviceConnectivityUtilTest.java b/dao/src/test/java/org/thingsboard/server/dao/util/DeviceConnectivityUtilTest.java index d4c93a1d5f..2c6fee2fcf 100644 --- a/dao/src/test/java/org/thingsboard/server/dao/util/DeviceConnectivityUtilTest.java +++ b/dao/src/test/java/org/thingsboard/server/dao/util/DeviceConnectivityUtilTest.java @@ -16,6 +16,13 @@ package org.thingsboard.server.dao.util; import org.junit.jupiter.api.Test; +import org.thingsboard.common.util.JacksonUtil; +import org.thingsboard.server.common.data.device.credentials.BasicMqttCredentials; +import org.thingsboard.server.common.data.security.DeviceCredentials; +import org.thingsboard.server.common.data.security.DeviceCredentialsType; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; import static org.assertj.core.api.Assertions.assertThat; @@ -29,4 +36,120 @@ class DeviceConnectivityUtilTest { assertThat(DeviceConnectivityUtil.CA_ROOT_CERT_PEM).doesNotContainAnyWhitespaces(); } + @Test + void validAccessTokenIsRenderedAsIs() throws Exception { + String yaml = renderCompose(accessToken("safe_token_123")); + + assertThat(yaml).contains("- TB_GW_ACCESS_TOKEN=safe_token_123\n"); + assertNoInjectedSiblingKeys(yaml); + } + + @Test + void newlineInAccessTokenIsSanitized() throws Exception { + String malicious = "safe_token\n entrypoint: [\"/bin/bash\",\"-c\",\"id\"]"; + + String yaml = renderCompose(accessToken(malicious)); + + assertNoInjectedSiblingKeys(yaml); + } + + @Test + void carriageReturnInAccessTokenIsSanitized() throws Exception { + String yaml = renderCompose(accessToken("token\rprivileged: true")); + + assertNoInjectedSiblingKeys(yaml); + } + + @Test + void newlineInMqttClientIdIsSanitized() throws Exception { + String yaml = renderCompose(mqttBasic("cid\n entrypoint: [\"/bin/sh\"]", "user", "pwd")); + + assertNoInjectedSiblingKeys(yaml); + } + + @Test + void newlineInMqttUserNameIsSanitized() throws Exception { + String yaml = renderCompose(mqttBasic("cid", "user\n privileged: true", "pwd")); + + assertNoInjectedSiblingKeys(yaml); + } + + @Test + void newlineInMqttPasswordIsSanitized() throws Exception { + String yaml = renderCompose(mqttBasic("cid", "user", "pwd\n entrypoint: [\"/bin/sh\"]")); + + assertNoInjectedSiblingKeys(yaml); + } + + @Test + void mqttBasicQuoteInUserNameIsEscapedInPublishCommand() { + String command = DeviceConnectivityUtil.getMqttPublishCommand( + "mqtt", "localhost", "1883", "v1/devices/me/telemetry", + mqttBasic("cid", "u\";touch pwned;echo \"", "pwd")); + + // the double quote must be backslash-escaped so it cannot terminate the -u "..." argument + assertThat(command).contains("-u \"u\\\";touch pwned;echo \\\"\""); + assertThat(command).doesNotContain("-u \"u\";"); + } + + @Test + void controlCharsInMqttClientIdAreStrippedInPublishCommand() { + String command = DeviceConnectivityUtil.getMqttPublishCommand( + "mqtt", "localhost", "1883", "v1/devices/me/telemetry", + mqttBasic("c\nid", "user", "pwd")); + + assertThat(command).doesNotContain("\n"); + assertThat(command).contains("-i \"c_id\""); + } + + @Test + void controlCharsInAccessTokenAreStrippedInHttpAndCoapCommands() { + DeviceCredentials creds = accessToken("tok\nen"); + + assertThat(DeviceConnectivityUtil.getHttpPublishCommand("http", "localhost", ":8080", creds)) + .doesNotContain("\n") + .contains("/api/v1/tok_en/telemetry"); + assertThat(DeviceConnectivityUtil.getCoapPublishCommand("coap", "localhost", ":5683", creds)) + .doesNotContain("\n") + .contains("/api/v1/tok_en/telemetry"); + } + + private static String renderCompose(DeviceCredentials credentials) throws Exception { + var resource = DeviceConnectivityUtil.getGatewayDockerComposeFile( + "host.docker.internal", "3.8-stable", credentials); + try (var in = resource.getInputStream()) { + return new String(in.readAllBytes(), StandardCharsets.UTF_8); + } + } + + private static DeviceCredentials accessToken(String token) { + DeviceCredentials c = new DeviceCredentials(); + c.setCredentialsType(DeviceCredentialsType.ACCESS_TOKEN); + c.setCredentialsId(token); + return c; + } + + private static DeviceCredentials mqttBasic(String clientId, String userName, String password) { + BasicMqttCredentials inner = new BasicMqttCredentials(); + inner.setClientId(clientId); + inner.setUserName(userName); + inner.setPassword(password); + DeviceCredentials c = new DeviceCredentials(); + c.setCredentialsType(DeviceCredentialsType.MQTT_BASIC); + c.setCredentialsId("mqtt-credentials-id"); + c.setCredentialsValue(JacksonUtil.toString(inner)); + return c; + } + + private static void assertNoInjectedSiblingKeys(String yaml) throws IOException { + for (String line : yaml.split("\n")) { + String trimmed = line.replaceFirst("^\\s+", ""); + assertThat(trimmed) + .as("unexpected sibling key — possible YAML injection: %s", line) + .doesNotStartWith("entrypoint:") + .doesNotStartWith("privileged:") + .doesNotStartWith("command:"); + } + } + } diff --git a/edqs/pom.xml b/edqs/pom.xml index 32c6df82f2..4d13edbc22 100644 --- a/edqs/pom.xml +++ b/edqs/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard - 4.3.1.2-SNAPSHOT + 4.3.1.3-SNAPSHOT thingsboard edqs diff --git a/monitoring/pom.xml b/monitoring/pom.xml index 5bc1fb5baa..2786e1b2c8 100644 --- a/monitoring/pom.xml +++ b/monitoring/pom.xml @@ -21,7 +21,7 @@ 4.0.0 org.thingsboard - 4.3.1.2-SNAPSHOT + 4.3.1.3-SNAPSHOT thingsboard diff --git a/msa/black-box-tests/pom.xml b/msa/black-box-tests/pom.xml index b871933d9f..69a78c72bd 100644 --- a/msa/black-box-tests/pom.xml +++ b/msa/black-box-tests/pom.xml @@ -21,7 +21,7 @@ org.thingsboard - 4.3.1.2-SNAPSHOT + 4.3.1.3-SNAPSHOT msa org.thingsboard.msa diff --git a/msa/edqs/pom.xml b/msa/edqs/pom.xml index c9fcd7beb7..69cff7d0e6 100644 --- a/msa/edqs/pom.xml +++ b/msa/edqs/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard - 4.3.1.2-SNAPSHOT + 4.3.1.3-SNAPSHOT msa org.thingsboard.msa diff --git a/msa/js-executor/package.json b/msa/js-executor/package.json index 5e0c41fe85..0843ef2dbe 100644 --- a/msa/js-executor/package.json +++ b/msa/js-executor/package.json @@ -1,7 +1,7 @@ { "name": "thingsboard-js-executor", "private": true, - "version": "4.3.1.2", + "version": "4.3.1.3", "description": "ThingsBoard JavaScript Executor Microservice", "main": "server.ts", "bin": "server.js", diff --git a/msa/js-executor/pom.xml b/msa/js-executor/pom.xml index 4396f4fb96..f57e06f4f2 100644 --- a/msa/js-executor/pom.xml +++ b/msa/js-executor/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard - 4.3.1.2-SNAPSHOT + 4.3.1.3-SNAPSHOT msa org.thingsboard.msa diff --git a/msa/monitoring/pom.xml b/msa/monitoring/pom.xml index 2b4a0bd6da..9502552a52 100644 --- a/msa/monitoring/pom.xml +++ b/msa/monitoring/pom.xml @@ -22,7 +22,7 @@ 4.0.0 org.thingsboard - 4.3.1.2-SNAPSHOT + 4.3.1.3-SNAPSHOT msa diff --git a/msa/pom.xml b/msa/pom.xml index a3494dfc3e..1498847ce3 100644 --- a/msa/pom.xml +++ b/msa/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard - 4.3.1.2-SNAPSHOT + 4.3.1.3-SNAPSHOT thingsboard msa diff --git a/msa/tb-node/pom.xml b/msa/tb-node/pom.xml index e4ff8f4951..df53fda9c1 100644 --- a/msa/tb-node/pom.xml +++ b/msa/tb-node/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard - 4.3.1.2-SNAPSHOT + 4.3.1.3-SNAPSHOT msa org.thingsboard.msa diff --git a/msa/tb/pom.xml b/msa/tb/pom.xml index 6b14bb92fb..327668587f 100644 --- a/msa/tb/pom.xml +++ b/msa/tb/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard - 4.3.1.2-SNAPSHOT + 4.3.1.3-SNAPSHOT msa org.thingsboard.msa diff --git a/msa/transport/coap/pom.xml b/msa/transport/coap/pom.xml index 27a0210adf..c496c8c8d0 100644 --- a/msa/transport/coap/pom.xml +++ b/msa/transport/coap/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard.msa - 4.3.1.2-SNAPSHOT + 4.3.1.3-SNAPSHOT transport org.thingsboard.msa.transport diff --git a/msa/transport/http/pom.xml b/msa/transport/http/pom.xml index fb22d39da3..e15e74c8b7 100644 --- a/msa/transport/http/pom.xml +++ b/msa/transport/http/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard.msa - 4.3.1.2-SNAPSHOT + 4.3.1.3-SNAPSHOT transport org.thingsboard.msa.transport diff --git a/msa/transport/lwm2m/pom.xml b/msa/transport/lwm2m/pom.xml index 916dc57712..7fe35560b3 100644 --- a/msa/transport/lwm2m/pom.xml +++ b/msa/transport/lwm2m/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard.msa - 4.3.1.2-SNAPSHOT + 4.3.1.3-SNAPSHOT transport org.thingsboard.msa.transport diff --git a/msa/transport/mqtt/pom.xml b/msa/transport/mqtt/pom.xml index 1dc673ce47..dcceba1e43 100644 --- a/msa/transport/mqtt/pom.xml +++ b/msa/transport/mqtt/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard.msa - 4.3.1.2-SNAPSHOT + 4.3.1.3-SNAPSHOT transport org.thingsboard.msa.transport diff --git a/msa/transport/pom.xml b/msa/transport/pom.xml index a1e867fb05..cf2411cf3e 100644 --- a/msa/transport/pom.xml +++ b/msa/transport/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard - 4.3.1.2-SNAPSHOT + 4.3.1.3-SNAPSHOT msa org.thingsboard.msa diff --git a/msa/transport/snmp/pom.xml b/msa/transport/snmp/pom.xml index 9ec7400a2c..b83b035b76 100644 --- a/msa/transport/snmp/pom.xml +++ b/msa/transport/snmp/pom.xml @@ -21,7 +21,7 @@ org.thingsboard.msa transport - 4.3.1.2-SNAPSHOT + 4.3.1.3-SNAPSHOT org.thingsboard.msa.transport diff --git a/msa/vc-executor-docker/pom.xml b/msa/vc-executor-docker/pom.xml index 5f37f101f4..fc1de84048 100644 --- a/msa/vc-executor-docker/pom.xml +++ b/msa/vc-executor-docker/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard - 4.3.1.2-SNAPSHOT + 4.3.1.3-SNAPSHOT msa org.thingsboard.msa diff --git a/msa/vc-executor/pom.xml b/msa/vc-executor/pom.xml index f5beccabf6..0da1dbbbf4 100644 --- a/msa/vc-executor/pom.xml +++ b/msa/vc-executor/pom.xml @@ -21,7 +21,7 @@ org.thingsboard - 4.3.1.2-SNAPSHOT + 4.3.1.3-SNAPSHOT msa org.thingsboard.msa diff --git a/msa/web-ui/package.json b/msa/web-ui/package.json index ce18b0b063..6dc75cc9dd 100644 --- a/msa/web-ui/package.json +++ b/msa/web-ui/package.json @@ -1,7 +1,7 @@ { "name": "thingsboard-web-ui", "private": true, - "version": "4.3.1.2", + "version": "4.3.1.3", "description": "ThingsBoard Web UI Microservice", "main": "server.ts", "bin": "server.js", diff --git a/msa/web-ui/pom.xml b/msa/web-ui/pom.xml index a6b6fb6e8e..02ac55d4a8 100644 --- a/msa/web-ui/pom.xml +++ b/msa/web-ui/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard - 4.3.1.2-SNAPSHOT + 4.3.1.3-SNAPSHOT msa org.thingsboard.msa diff --git a/netty-mqtt/pom.xml b/netty-mqtt/pom.xml index 9e06b2d975..92766bb38d 100644 --- a/netty-mqtt/pom.xml +++ b/netty-mqtt/pom.xml @@ -19,11 +19,11 @@ 4.0.0 org.thingsboard - 4.3.1.2-SNAPSHOT + 4.3.1.3-SNAPSHOT thingsboard netty-mqtt - 4.3.1.2-SNAPSHOT + 4.3.1.3-SNAPSHOT jar Netty MQTT Client diff --git a/pom.xml b/pom.xml index 306879f4fa..4c36e79bff 100755 --- a/pom.xml +++ b/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard thingsboard - 4.3.1.2-SNAPSHOT + 4.3.1.3-SNAPSHOT pom Thingsboard @@ -70,7 +70,7 @@ 3.5.13 3.18.0 42.7.11 - 4.1.133.Final + 4.1.134.Final 10.1.55 2.4.0-b180830.0359 0.12.5 @@ -143,14 +143,12 @@ 4.0.2 1.7.5 3.8.0 - 1.8.0-TB - 2.5.9 - 2.38.0 + 1.16.1-TB1 + 2.43.0 1.24 1.11.0 - 3.49.3 + 3.42.0 0.27.0 - 1.7.0 2.7.3 1.5.6 @@ -1383,11 +1381,6 @@ postgresql ${postgresql.version}
- - org.apache.opennlp - opennlp-tools - ${opennlp-tools.version} - commons-io commons-io @@ -2032,11 +2025,6 @@ perfmark-api ${perfmark-api.version} - - org.threeten - threetenbp - ${threetenbp.version} - diff --git a/rest-client/pom.xml b/rest-client/pom.xml index fe159973cf..cc0e1009d5 100644 --- a/rest-client/pom.xml +++ b/rest-client/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard - 4.3.1.2-SNAPSHOT + 4.3.1.3-SNAPSHOT thingsboard rest-client diff --git a/rule-engine/pom.xml b/rule-engine/pom.xml index 3368f77cdc..976dd842b5 100644 --- a/rule-engine/pom.xml +++ b/rule-engine/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard - 4.3.1.2-SNAPSHOT + 4.3.1.3-SNAPSHOT thingsboard rule-engine diff --git a/rule-engine/rule-engine-api/pom.xml b/rule-engine/rule-engine-api/pom.xml index b4643ece39..85b305f1f2 100644 --- a/rule-engine/rule-engine-api/pom.xml +++ b/rule-engine/rule-engine-api/pom.xml @@ -22,7 +22,7 @@ 4.0.0 org.thingsboard - 4.3.1.2-SNAPSHOT + 4.3.1.3-SNAPSHOT rule-engine org.thingsboard.rule-engine diff --git a/rule-engine/rule-engine-components/pom.xml b/rule-engine/rule-engine-components/pom.xml index 1f25541bd9..4a3aaa5323 100644 --- a/rule-engine/rule-engine-components/pom.xml +++ b/rule-engine/rule-engine-components/pom.xml @@ -22,7 +22,7 @@ 4.0.0 org.thingsboard - 4.3.1.2-SNAPSHOT + 4.3.1.3-SNAPSHOT rule-engine org.thingsboard.rule-engine diff --git a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/ai/TbAiNode.java b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/ai/TbAiNode.java index 7495276723..d6a5f26b4e 100644 --- a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/ai/TbAiNode.java +++ b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/ai/TbAiNode.java @@ -52,10 +52,9 @@ import org.thingsboard.server.common.data.id.AiModelId; import org.thingsboard.server.common.data.id.TbResourceId; import org.thingsboard.server.common.data.id.TenantId; import org.thingsboard.server.common.data.plugin.ComponentType; -import org.thingsboard.server.common.data.rule.RuleChainType; import org.thingsboard.server.common.msg.TbMsg; -import org.thingsboard.server.exception.DataValidationException; import org.thingsboard.server.dao.resource.TbResourceDataCache; +import org.thingsboard.server.exception.DataValidationException; import java.nio.charset.StandardCharsets; import java.util.ArrayList; @@ -69,7 +68,6 @@ import java.util.Set; import java.util.UUID; import static com.google.common.util.concurrent.MoreExecutors.directExecutor; -import static org.thingsboard.rule.engine.ai.TbResponseFormat.TbResponseFormatType; import static org.thingsboard.server.dao.service.ConstraintValidator.validateFields; @Slf4j @@ -92,7 +90,7 @@ import static org.thingsboard.server.dao.service.ConstraintValidator.validateFie configClazz = TbAiNodeConfiguration.class, configDirective = "tbExternalNodeAiConfig", iconUrl = "data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iNDkiIGhlaWdodD0iNDgiIHZpZXdCb3g9IjAgMCA0OSA0OCIgZmlsbD0ibm9uZSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj4KPHBhdGggZmlsbC1ydWxlPSJldmVub2RkIiBjbGlwLXJ1bGU9ImV2ZW5vZGQiIGQ9Ik0zOC42MzExIDE3LjA3OTVDNDAuMTcwNSAxNy4wNzk2IDQxLjY1MTggMTcuNjg3MiA0Mi43NDc4IDE4Ljc3NjNDNDMuODQ0OCAxOS44NjYzIDQ0LjQ2NTkgMjEuMzUwMSA0NC40NjU5IDIyLjkwMjlWMzUuNDY1MkM0NC40NjU5IDM2LjM1MDkgNDQuMzU2NyAzNy4wNzY5IDQ0LjA5NzMgMzcuNzUxN0M0My44NDE0IDM4LjQxNjcgNDMuNDY1MSAzOC45NjE0IDQzLjA0NDggMzkuNTAyOEM0Mi40NjY3IDQwLjI0NzIgNDEuNjU2MyA0MC42ODU5IDQwLjg5MTkgNDAuOTM4OEM0MC4xMjExIDQxLjE5MzcgMzkuMzE0MyA0MS4yODg1IDM4LjYzMTEgNDEuMjg4NUgzMS4wMjU5TDIzLjM4MTIgNDUuODQ2NEMyMy4wNDMxIDQ2LjA0NzggMjIuNjI0MSA0Ni4wNTA3IDIyLjI4MzkgNDUuODUyOUMyMS45NDM3IDQ1LjY1NDcgMjEuNzMzOCA0NS4yODU5IDIxLjczMzcgNDQuODg3MlY0MS4yODg1SDE5LjY2NjNDMTguMTI2OSA0MS4yODg0IDE2LjY0NTUgNDAuNjgwOSAxNS41NDk2IDM5LjU5MThDMTQuNDUyNyAzOC41MDE5IDEzLjgzMTUgMzcuMDE3OSAxMy44MzE1IDM1LjQ2NTJWMjIuOTAyOUMxMy44MzE1IDIyLjMyMDIgMTMuOTE4NSAyMS43NDY4IDE0LjA4NTggMjEuMjAwN0wxNi4yODg5IDIxLjgxMDFMMTcuMjA5OSAyNS4yNTAyQzE3Ljk0MTYgMjcuOTg0NSAyMS43NTYyIDI3Ljk4NDQgMjIuNDg4IDI1LjI1MDJMMjMuNDA3OSAyMS44MTAxTDI2Ljc5MTcgMjAuODc0OUMyOC41NzkxIDIwLjM4MDUgMjkuMTc3IDE4LjUwMjYgMjguNTg4OCAxNy4wNzk1SDM4LjYzMTFaTTIyLjU4NDIgMzEuNTM5NUMyMS45OCAzMS41Mzk3IDIxLjQ5MDEgMzIuMDM3NiAyMS40OTAxIDMyLjY1MTlDMjEuNDkwMiAzMy4yNjYgMjEuOTgwMSAzMy43NjQgMjIuNTg0MiAzMy43NjQySDM0LjYxOTFDMzUuMjIzMyAzMy43NjQyIDM1LjcxMzEgMzMuMjY2MSAzNS43MTMyIDMyLjY1MTlDMzUuNzEzMiAzMi4wMzc1IDM1LjIyMzQgMzEuNTM5NSAzNC42MTkxIDMxLjUzOTVIMjIuNTg0MlpNMjQuNzcyMyAyNC44NjU3QzI0LjE2ODIgMjQuODY1OCAyMy42NzgzIDI1LjM2MzggMjMuNjc4MyAyNS45NzhDMjMuNjc4NCAyNi41OTIyIDI0LjE2ODMgMjcuMDkwMiAyNC43NzIzIDI3LjA5MDNIMzcuOTAxNEMzOC41MDU1IDI3LjA5MDMgMzguOTk1MyAyNi41OTIyIDM4Ljk5NTQgMjUuOTc4QzM4Ljk5NTQgMjUuMzYzNyAzOC41MDU2IDI0Ljg2NTcgMzcuOTAxNCAyNC44NjU3SDI0Ljc3MjNaIiBmaWxsPSJibGFjayIgZmlsbC1vcGFjaXR5PSIwLjc2Ii8+CjxwYXRoIGQ9Ik0xOC43ODkxIDExLjI5NzVDMTkuMDY5MSAxMC4xODA4IDIwLjYyOTkgMTAuMTgwOCAyMC45MDk5IDExLjI5NzVMMjEuOTE0MyAxNS4zMDM2QzIyLjAxMTYgMTUuNjkxOCAyMi4zMDY1IDE1Ljk5NzggMjIuNjg2NyAxNi4xMDNMMjYuMzYxMSAxNy4xMTg3QzI3LjQzNyAxNy40MTYyIDI3LjQzNyAxOC45Njc2IDI2LjM2MTEgMTkuMjY1MUwyMi42NzYxIDIwLjI4NEMyMi4zMDE4IDIwLjM4NzQgMjIuMDA4NyAyMC42ODQ1IDIxLjkwNjggMjEuMDY1TDIwLjkwNDYgMjQuODEyNUMyMC42MTE3IDI1LjkwNTggMTkuMDg2MSAyNS45MDU5IDE4Ljc5MzMgMjQuODEyNUwxNy43OTExIDIxLjA2NUMxNy42ODkzIDIwLjY4NDcgMTcuMzk3IDIwLjM4NzUgMTcuMDIyOSAyMC4yODRMMTMuMzM2OCAxOS4yNjUxQzEyLjI2MTQgMTguOTY3MyAxMi4yNjE1IDE3LjQxNjUgMTMuMzM2OCAxNy4xMTg3TDE3LjAxMTIgMTYuMTAzQzE3LjM5MTYgMTUuOTk3OCAxNy42ODc0IDE1LjY5MTkgMTcuNzg0NyAxNS4zMDM2TDE4Ljc4OTEgMTEuMjk3NVoiIGZpbGw9ImJsYWNrIiBmaWxsLW9wYWNpdHk9IjAuNzYiLz4KPHBhdGggZD0iTTEwLjAzNDMgNy4wMjQyNUMxMC4zMDY4IDUuODk0NDQgMTEuODg2OCA1Ljg5NDQ0IDEyLjE1OTQgNy4wMjQyNUwxMi42OTg5IDkuMjYyOThDMTIuNzkyNyA5LjY1MTc0IDEzLjA4NTEgOS45NTg4NyAxMy40NjQgMTAuMDY3OUwxNS41NzczIDEwLjY3NTFDMTYuNjM5MyAxMC45ODAzIDE2LjYzOTMgMTIuNTEwOSAxNS41NzczIDEyLjgxNjFMMTMuNDUzMyAxMy40MjY1QzEzLjA4MDIgMTMuNTMzOCAxMi43OTA4IDEzLjgzMzkgMTIuNjkyNSAxNC4yMTUxTDEyLjE1NTEgMTYuMzA0QzExLjg3IDE3LjQxMTYgMTAuMzIzNiAxNy40MTE2IDEwLjAzODUgMTYuMzA0TDkuNTAwMDMgMTQuMjE1MUM5LjQwMTczIDEzLjgzMzkgOS4xMTIzNSAxMy41MzM3IDguNzM5MyAxMy40MjY1TDYuNjE1MjQgMTIuODE2MUM1LjU1Mzc4IDEyLjUxMDYgNS41NTM2NCAxMC45ODA0IDYuNjE1MjQgMTAuNjc1MUw4LjcyODYyIDEwLjA2NzlDOS4xMDc2IDkuOTU4OTggOS4zOTk3OCA5LjY1MTg0IDkuNDkzNjIgOS4yNjI5OEwxMC4wMzQzIDcuMDI0MjVaIiBmaWxsPSJibGFjayIgZmlsbC1vcGFjaXR5PSIwLjc2Ii8+CjxwYXRoIGQ9Ik0yNS45MDI4IDYuNzMzMTNDMjYuMTg3OCA1LjYyNTQxIDI3LjczNDMgNS42MjU0MSAyOC4wMTkzIDYuNzMzMTNMMjguMjAzMSA3LjQ0Njc5QzI4LjMwMyA3LjgzNDMxIDI4LjYwMDEgOC4xMzcwNSAyOC45ODA5IDguMjM5NzVMMjkuNTM0NCA4LjM4OTY1QzMwLjYxOTIgOC42ODIxMiAzMC42MTkzIDEwLjI0NjkgMjkuNTM0NCAxMC41MzkzTDI4Ljk2OTIgMTAuNjkxNEMyOC41OTQ0IDEwLjc5MjUgMjguMjk5OSAxMS4wODgzIDI4LjE5NTYgMTEuNDY4TDI4LjAxNTEgMTIuMTI4NUMyNy43MTc0IDEzLjIxMjggMjYuMjA0NyAxMy4yMTI4IDI1LjkwNyAxMi4xMjg1TDI1LjcyNTQgMTEuNDY4QzI1LjYyMTEgMTEuMDg4MiAyNS4zMjY4IDEwLjc5MjQgMjQuOTUxOCAxMC42OTE0TDI0LjM4NzcgMTAuNTM5M0MyMy4zMDI2IDEwLjI0NyAyMy4zMDI2IDguNjgxOTggMjQuMzg3NyA4LjM4OTY1TDI0Ljk0MDEgOC4yMzk3NUMyNS4zMjExIDguMTM3MDkgMjUuNjE5MSA3LjgzNDQ2IDI1LjcxOSA3LjQ0Njc5TDI1LjkwMjggNi43MzMxM1oiIGZpbGw9ImJsYWNrIiBmaWxsLW9wYWNpdHk9IjAuNzYiLz4KPC9zdmc+Cg==", - docUrl = "https://thingsboard.io/docs/user-guide/rule-engine-2-0/nodes/external/ai-request/" + docUrl = "https://thingsboard.io/docs/reference/rule-engine/nodes/external/ai-request/" ) public final class TbAiNode extends TbAbstractExternalNode implements TbNode { @@ -126,13 +124,11 @@ public final class TbAiNode extends TbAbstractExternalNode implements TbNode { throw new TbNodeException("[" + ctx.getTenantId() + "] AI model with ID: [" + modelId + "] must be of type CHAT, but was " + modelType, true); } AiChatModelConfig chatModelConfig = (AiChatModelConfig) model.getConfiguration(); - if (isJsonModeConfigured(config)) { - if (!chatModelConfig.supportsJsonMode()) { - throw new TbNodeException("[" + ctx.getTenantId() + "] AI model with ID: [" + modelId + "] does not support '" + config.getResponseFormat().type() + "' response format", true); - } - // LangChain4j AnthropicChatModel rejects requests with non-null ResponseFormat even if ResponseFormatType is TEXT - responseFormat = config.getResponseFormat().toLangChainResponseFormat(); + TbResponseFormat tbResponseFormat = config.getResponseFormat(); + if (!tbResponseFormat.isSupportedBy(chatModelConfig)) { + throw new TbNodeException("[" + ctx.getTenantId() + "] AI model with ID: [" + modelId + "] does not support '" + tbResponseFormat.type() + "' response format", true); } + responseFormat = tbResponseFormat.toLangChainResponseFormat(); if (config.getResourceIds() != null && !config.getResourceIds().isEmpty()) { resourceIds = new HashSet<>(config.getResourceIds().size()); for (UUID resourceId : config.getResourceIds()) { @@ -148,11 +144,6 @@ public final class TbAiNode extends TbAbstractExternalNode implements TbNode { super.forceAck = config.isForceAck() || super.forceAck; // force ack if node config says so, or if env variable (super.forceAck) says so } - private static boolean isJsonModeConfigured(TbAiNodeConfiguration config) { - var responseFormatType = config.getResponseFormat().type(); - return responseFormatType == TbResponseFormatType.JSON || responseFormatType == TbResponseFormatType.JSON_SCHEMA; - } - @Override public void onMsg(TbContext ctx, TbMsg msg) { var ackedMsg = ackIfNeeded(ctx, msg); diff --git a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/ai/TbResponseFormat.java b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/ai/TbResponseFormat.java index a32469be42..5dd1cd74f0 100644 --- a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/ai/TbResponseFormat.java +++ b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/ai/TbResponseFormat.java @@ -21,6 +21,7 @@ import com.fasterxml.jackson.databind.node.ObjectNode; import dev.langchain4j.model.chat.request.ResponseFormat; import dev.langchain4j.model.chat.request.ResponseFormatType; import jakarta.validation.constraints.NotNull; +import org.thingsboard.server.common.data.ai.model.chat.AiChatModelConfig; import org.thingsboard.server.common.data.validation.ValidJsonSchema; import static org.thingsboard.rule.engine.ai.TbResponseFormat.TbJsonResponseFormat; @@ -41,6 +42,8 @@ public sealed interface TbResponseFormat permits TbTextResponseFormat, TbJsonRes TbResponseFormatType type(); + boolean isSupportedBy(AiChatModelConfig modelConfig); + ResponseFormat toLangChainResponseFormat(); enum TbResponseFormatType { @@ -58,6 +61,11 @@ public sealed interface TbResponseFormat permits TbTextResponseFormat, TbJsonRes return TbResponseFormatType.TEXT; } + @Override + public boolean isSupportedBy(AiChatModelConfig modelConfig) { + return true; + } + @Override public ResponseFormat toLangChainResponseFormat() { return ResponseFormat.TEXT; @@ -72,6 +80,11 @@ public sealed interface TbResponseFormat permits TbTextResponseFormat, TbJsonRes return TbResponseFormatType.JSON; } + @Override + public boolean isSupportedBy(AiChatModelConfig modelConfig) { + return modelConfig.supportsSchemalessJsonOutput(); + } + @Override public ResponseFormat toLangChainResponseFormat() { return ResponseFormat.JSON; @@ -86,6 +99,11 @@ public sealed interface TbResponseFormat permits TbTextResponseFormat, TbJsonRes return TbResponseFormatType.JSON_SCHEMA; } + @Override + public boolean isSupportedBy(AiChatModelConfig modelConfig) { + return modelConfig.supportsJsonSchemaOutput(); + } + @Override public ResponseFormat toLangChainResponseFormat() { return ResponseFormat.builder() diff --git a/rule-engine/rule-engine-components/src/test/java/org/thingsboard/rule/engine/ai/TbAiNodeTest.java b/rule-engine/rule-engine-components/src/test/java/org/thingsboard/rule/engine/ai/TbAiNodeTest.java index e8aad50463..3a184267a5 100644 --- a/rule-engine/rule-engine-components/src/test/java/org/thingsboard/rule/engine/ai/TbAiNodeTest.java +++ b/rule-engine/rule-engine-components/src/test/java/org/thingsboard/rule/engine/ai/TbAiNodeTest.java @@ -38,8 +38,8 @@ import org.junit.jupiter.params.provider.ValueSource; import org.mockito.ArgumentCaptor; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; -import org.thingsboard.common.util.JacksonUtil; import org.thingsboard.common.util.DirectListeningExecutor; +import org.thingsboard.common.util.JacksonUtil; import org.thingsboard.rule.engine.ai.TbResponseFormat.TbJsonResponseFormat; import org.thingsboard.rule.engine.ai.TbResponseFormat.TbJsonSchemaResponseFormat; import org.thingsboard.rule.engine.ai.TbResponseFormat.TbTextResponseFormat; @@ -53,8 +53,10 @@ import org.thingsboard.server.common.data.TbResource; import org.thingsboard.server.common.data.TbResourceDataInfo; import org.thingsboard.server.common.data.ai.AiModel; import org.thingsboard.server.common.data.ai.model.AiModelConfig; +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.OpenAiChatModelConfig; +import org.thingsboard.server.common.data.ai.provider.AmazonBedrockProviderConfig; import org.thingsboard.server.common.data.ai.provider.AnthropicProviderConfig; import org.thingsboard.server.common.data.ai.provider.OpenAiProviderConfig; import org.thingsboard.server.common.data.id.AiModelId; @@ -400,6 +402,124 @@ class TbAiNodeTest { .matches(e -> ((TbNodeException) e).isUnrecoverable()); } + @Test + void givenJsonSchemaResponseFormatAndModelSupportsIt_whenInit_thenDoesNotThrow() { + // GIVEN + var jsonSchema = """ + { + "title": "Joke", + "type": "object", + "properties": { + "joke": { + "type": "string" + } + }, + "required": [ + "joke" + ] + } + """; + + config = constructValidConfig(); + config.setResponseFormat(new TbJsonSchemaResponseFormat((ObjectNode) JacksonUtil.toJsonNode(jsonSchema))); + + // Anthropic does not support schemaless JSON mode, but does support JSON Schema constrained output + modelConfig = AnthropicChatModelConfig.builder() + .providerConfig(new AnthropicProviderConfig("test-api-key")) + .modelId("claude-sonnet-4-5") + .build(); + + model = AiModel.builder() + .tenantId(tenantId) + .name("Test model") + .configuration(modelConfig) + .build(); + + model.setId(modelId); + model.setVersion(1L); + model.setCreatedTime(123L); + + given(aiModelServiceMock.findAiModelByTenantIdAndId(tenantId, modelId)).willReturn(Optional.of(model)); + + // WHEN-THEN + assertThatNoException() + .isThrownBy(() -> aiNode.init(ctxMock, new TbNodeConfiguration(JacksonUtil.valueToTree(config)))); + } + + @Test + void givenJsonSchemaResponseFormatAndBedrockModel_whenInit_thenDoesNotThrow() { + // GIVEN + var jsonSchema = """ + { + "title": "Joke", + "type": "object", + "properties": { + "joke": { + "type": "string" + } + }, + "required": [ + "joke" + ] + } + """; + + config = constructValidConfig(); + config.setResponseFormat(new TbJsonSchemaResponseFormat((ObjectNode) JacksonUtil.toJsonNode(jsonSchema))); + + // Bedrock does not support schemaless JSON mode, but does support JSON Schema constrained output (Converse API) + modelConfig = AmazonBedrockChatModelConfig.builder() + .providerConfig(new AmazonBedrockProviderConfig("us-east-1", "test-access-key", "test-secret-key")) + .modelId("anthropic.claude-sonnet-4-5") + .build(); + + model = AiModel.builder() + .tenantId(tenantId) + .name("Test model") + .configuration(modelConfig) + .build(); + + model.setId(modelId); + model.setVersion(1L); + model.setCreatedTime(123L); + + given(aiModelServiceMock.findAiModelByTenantIdAndId(tenantId, modelId)).willReturn(Optional.of(model)); + + // WHEN-THEN + assertThatNoException() + .isThrownBy(() -> aiNode.init(ctxMock, new TbNodeConfiguration(JacksonUtil.valueToTree(config)))); + } + + @Test + void givenSchemalessJsonResponseFormatAndBedrockModel_whenInit_thenThrowsUnrecoverableTbNodeException() { + // GIVEN + config = constructValidConfig(); + config.setResponseFormat(new TbJsonResponseFormat()); + + modelConfig = AmazonBedrockChatModelConfig.builder() + .providerConfig(new AmazonBedrockProviderConfig("us-east-1", "test-access-key", "test-secret-key")) + .modelId("anthropic.claude-sonnet-4-5") + .build(); + + model = AiModel.builder() + .tenantId(tenantId) + .name("Test model") + .configuration(modelConfig) + .build(); + + model.setId(modelId); + model.setVersion(1L); + model.setCreatedTime(123L); + + given(aiModelServiceMock.findAiModelByTenantIdAndId(tenantId, modelId)).willReturn(Optional.of(model)); + + // WHEN-THEN + assertThatThrownBy(() -> aiNode.init(ctxMock, new TbNodeConfiguration(JacksonUtil.valueToTree(config)))) + .isInstanceOf(TbNodeException.class) + .hasMessage("[" + tenantId + "] AI model with ID: [" + modelId + "] does not support 'JSON' response format") + .matches(e -> ((TbNodeException) e).isUnrecoverable()); + } + @Test void givenNotExistingResources_whenInit_thenThrowsException() { // GIVEN @@ -669,7 +789,7 @@ class TbAiNodeTest { argThat(actualChatRequest -> { assertThat(actualChatRequest.messages()).hasSize(2); assertThat(actualChatRequest.messages().get(0)).isEqualTo(SystemMessage.from(systemPrompt)); - assertThat(((UserMessage)actualChatRequest.messages().get(1)).contents()) + assertThat(((UserMessage) actualChatRequest.messages().get(1)).contents()) .containsAll(List.of(new TextContent(userPrompt), new TextContent(textData), new TextContent(xmlData), new ImageContent(Base64.getEncoder().encodeToString(PNG_IMAGE), "image/png"))); return true; @@ -706,7 +826,7 @@ class TbAiNodeTest { argThat(actualChatRequest -> { assertThat(actualChatRequest.messages()).hasSize(2); assertThat(actualChatRequest.messages().get(0)).isEqualTo(SystemMessage.from(config.getSystemPrompt())); - assertThat(((UserMessage)actualChatRequest.messages().get(1)).contents()) + assertThat(((UserMessage) actualChatRequest.messages().get(1)).contents()) .containsAll(List.of(new TextContent(config.getUserPrompt()))); return true; }) @@ -993,7 +1113,7 @@ class TbAiNodeTest { then(aiChatModelServiceMock).should().sendChatRequestAsync( any(), argThat(actualChatRequest -> { - assertThat(actualChatRequest.responseFormat()).isNull(); + assertThat(actualChatRequest.responseFormat()).isEqualTo(ResponseFormat.builder().type(ResponseFormatType.TEXT).build()); return true; }) ); diff --git a/tools/pom.xml b/tools/pom.xml index 7a83b99e1f..12ea0159c1 100644 --- a/tools/pom.xml +++ b/tools/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard - 4.3.1.2-SNAPSHOT + 4.3.1.3-SNAPSHOT thingsboard tools diff --git a/transport/coap/pom.xml b/transport/coap/pom.xml index 78e7ba7f71..74bc0becda 100644 --- a/transport/coap/pom.xml +++ b/transport/coap/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard - 4.3.1.2-SNAPSHOT + 4.3.1.3-SNAPSHOT transport org.thingsboard.transport diff --git a/transport/coap/src/main/resources/tb-coap-transport.yml b/transport/coap/src/main/resources/tb-coap-transport.yml index f55f155b45..cc1500105a 100644 --- a/transport/coap/src/main/resources/tb-coap-transport.yml +++ b/transport/coap/src/main/resources/tb-coap-transport.yml @@ -133,6 +133,8 @@ redis: blockWhenExhausted: "${REDIS_POOL_CONFIG_BLOCK_WHEN_EXHAUSTED:true}" transport: + # Size of the thread pool that executes transport API callbacks (session registration, telemetry/attribute and RPC responses, entity update notifications, and the tenant profile fetch on a cache miss). Bounds how many such callbacks - including those that block on a backend round-trip - can run concurrently. + callback_thread_pool_size: "${TB_TRANSPORT_CALLBACK_THREAD_POOL_SIZE:20}" # Local CoAP transport parameters coap: # CoaP processing timeout in milliseconds diff --git a/transport/http/pom.xml b/transport/http/pom.xml index 0fad7a5e63..db6459b3c9 100644 --- a/transport/http/pom.xml +++ b/transport/http/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard - 4.3.1.2-SNAPSHOT + 4.3.1.3-SNAPSHOT transport org.thingsboard.transport diff --git a/transport/http/src/main/resources/tb-http-transport.yml b/transport/http/src/main/resources/tb-http-transport.yml index 69e610a812..53f8f5fc5b 100644 --- a/transport/http/src/main/resources/tb-http-transport.yml +++ b/transport/http/src/main/resources/tb-http-transport.yml @@ -167,6 +167,8 @@ redis: # HTTP server parameters transport: + # Size of the thread pool that executes transport API callbacks (session registration, telemetry/attribute and RPC responses, entity update notifications, and the tenant profile fetch on a cache miss). Bounds how many such callbacks - including those that block on a backend round-trip - can run concurrently. + callback_thread_pool_size: "${TB_TRANSPORT_CALLBACK_THREAD_POOL_SIZE:20}" http: # HTTP request processing timeout in milliseconds request_timeout: "${HTTP_REQUEST_TIMEOUT:60000}" diff --git a/transport/lwm2m/pom.xml b/transport/lwm2m/pom.xml index 11d4c1f2db..61ae17fe7e 100644 --- a/transport/lwm2m/pom.xml +++ b/transport/lwm2m/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard - 4.3.1.2-SNAPSHOT + 4.3.1.3-SNAPSHOT transport org.thingsboard.transport diff --git a/transport/lwm2m/src/main/resources/tb-lwm2m-transport.yml b/transport/lwm2m/src/main/resources/tb-lwm2m-transport.yml index 05fe2c2956..ca41c94434 100644 --- a/transport/lwm2m/src/main/resources/tb-lwm2m-transport.yml +++ b/transport/lwm2m/src/main/resources/tb-lwm2m-transport.yml @@ -134,6 +134,8 @@ redis: # LWM2M server parameters transport: + # Size of the thread pool that executes transport API callbacks (session registration, telemetry/attribute and RPC responses, entity update notifications, and the tenant profile fetch on a cache miss). Bounds how many such callbacks - including those that block on a backend round-trip - can run concurrently. + callback_thread_pool_size: "${TB_TRANSPORT_CALLBACK_THREAD_POOL_SIZE:20}" sessions: # Session inactivity timeout is a global configuration parameter that defines how long the device transport session will be opened after the last message arrives from the device. # The parameter value is in milliseconds. diff --git a/transport/mqtt/pom.xml b/transport/mqtt/pom.xml index 58b04e5bba..369cd33e8b 100644 --- a/transport/mqtt/pom.xml +++ b/transport/mqtt/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard - 4.3.1.2-SNAPSHOT + 4.3.1.3-SNAPSHOT transport org.thingsboard.transport diff --git a/transport/mqtt/src/main/resources/tb-mqtt-transport.yml b/transport/mqtt/src/main/resources/tb-mqtt-transport.yml index 1ff998560a..99a8a5a593 100644 --- a/transport/mqtt/src/main/resources/tb-mqtt-transport.yml +++ b/transport/mqtt/src/main/resources/tb-mqtt-transport.yml @@ -135,6 +135,8 @@ redis: # MQTT server parameters transport: + # Size of the thread pool that executes transport API callbacks (session registration, telemetry/attribute and RPC responses, entity update notifications, and the tenant profile fetch on a cache miss). Bounds how many such callbacks - including those that block on a backend round-trip - can run concurrently. + callback_thread_pool_size: "${TB_TRANSPORT_CALLBACK_THREAD_POOL_SIZE:20}" mqtt: # MQTT bind-address bind_address: "${MQTT_BIND_ADDRESS:0.0.0.0}" diff --git a/transport/pom.xml b/transport/pom.xml index 835f98f0ad..c4b690932f 100644 --- a/transport/pom.xml +++ b/transport/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard - 4.3.1.2-SNAPSHOT + 4.3.1.3-SNAPSHOT thingsboard transport diff --git a/transport/snmp/pom.xml b/transport/snmp/pom.xml index 3158a9efaa..2f7981d9a0 100644 --- a/transport/snmp/pom.xml +++ b/transport/snmp/pom.xml @@ -21,7 +21,7 @@ org.thingsboard - 4.3.1.2-SNAPSHOT + 4.3.1.3-SNAPSHOT transport diff --git a/transport/snmp/src/main/resources/tb-snmp-transport.yml b/transport/snmp/src/main/resources/tb-snmp-transport.yml index bb46478477..5516148670 100644 --- a/transport/snmp/src/main/resources/tb-snmp-transport.yml +++ b/transport/snmp/src/main/resources/tb-snmp-transport.yml @@ -134,6 +134,8 @@ redis: # Snmp server parameters transport: + # Size of the thread pool that executes transport API callbacks (session registration, telemetry/attribute and RPC responses, entity update notifications, and the tenant profile fetch on a cache miss). Bounds how many such callbacks - including those that block on a backend round-trip - can run concurrently. + callback_thread_pool_size: "${TB_TRANSPORT_CALLBACK_THREAD_POOL_SIZE:20}" snmp: # Enable/disable SNMP transport protocol enabled: "${SNMP_ENABLED:true}" diff --git a/ui-ngx/package.json b/ui-ngx/package.json index 082d2ad965..e93721a696 100644 --- a/ui-ngx/package.json +++ b/ui-ngx/package.json @@ -1,6 +1,6 @@ { "name": "thingsboard", - "version": "4.3.1.2", + "version": "4.3.1.3", "scripts": { "ng": "ng", "start": "node --max_old_space_size=8048 ./node_modules/@angular/cli/bin/ng serve --configuration development --host 0.0.0.0 --open", diff --git a/ui-ngx/pom.xml b/ui-ngx/pom.xml index 7c73c7bc33..61194d94bf 100644 --- a/ui-ngx/pom.xml +++ b/ui-ngx/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard - 4.3.1.2-SNAPSHOT + 4.3.1.3-SNAPSHOT thingsboard org.thingsboard diff --git a/ui-ngx/src/app/core/api/widget-api.models.ts b/ui-ngx/src/app/core/api/widget-api.models.ts index 12f5bc60d8..f7c719cc0c 100644 --- a/ui-ngx/src/app/core/api/widget-api.models.ts +++ b/ui-ngx/src/app/core/api/widget-api.models.ts @@ -119,6 +119,7 @@ export interface WidgetActionsApi { elementClick: ($event: Event) => void; cardClick: ($event: Event) => void; click: ($event: Event) => void; + invokeAction: ($event: Event, actionName: string, additionalParams?: any) => void; getActiveEntityInfo: () => SubscriptionEntityInfo; openDashboardStateInSeparateDialog: (targetDashboardStateId: string, params?: StateParams, dialogTitle?: string, hideDashboardToolbar?: boolean, dialogWidth?: number, dialogHeight?: number) => MatDialogRef; diff --git a/ui-ngx/src/app/core/auth/auth.models.ts b/ui-ngx/src/app/core/auth/auth.models.ts index 623ceefadd..6c123e393a 100644 --- a/ui-ngx/src/app/core/auth/auth.models.ts +++ b/ui-ngx/src/app/core/auth/auth.models.ts @@ -17,6 +17,7 @@ import { AuthUser, User } from '@shared/models/user.model'; import { UserSettings } from '@shared/models/user-settings.models'; import { TrendzSettings } from '@shared/models/trendz-settings.models'; +import { NullsOrderStrategy } from '@shared/models/page/page-link'; export interface SysParamsState { userTokenAccessEnabled: boolean; @@ -40,6 +41,8 @@ export interface SysParamsState { calculatedFieldDebugPerTenantLimitsConfiguration?: string; intermediateAggregationIntervalInSecForCF: number; trendzSettings: TrendzSettings; + nullsOrderStrategy: NullsOrderStrategy; + edqsEnabled: boolean; } export interface SysParams extends SysParamsState { diff --git a/ui-ngx/src/app/core/auth/auth.reducer.ts b/ui-ngx/src/app/core/auth/auth.reducer.ts index 51e02b1ab8..7b02d172a7 100644 --- a/ui-ngx/src/app/core/auth/auth.reducer.ts +++ b/ui-ngx/src/app/core/auth/auth.reducer.ts @@ -42,7 +42,9 @@ const emptyUserAuthState: AuthPayload = { maxDebugModeDurationMinutes: 0, intermediateAggregationIntervalInSecForCF: 0, userSettings: initialUserSettings, - trendzSettings: initialTrendzSettings + trendzSettings: initialTrendzSettings, + nullsOrderStrategy: 'default', + edqsEnabled: false }; export const initialState: AuthState = { diff --git a/ui-ngx/src/app/core/auth/auth.service.ts b/ui-ngx/src/app/core/auth/auth.service.ts index 07a8c5b7a2..ea8d1caa39 100644 --- a/ui-ngx/src/app/core/auth/auth.service.ts +++ b/ui-ngx/src/app/core/auth/auth.service.ts @@ -20,6 +20,7 @@ import { HttpClient } from '@angular/common/http'; import { Observable, of, ReplaySubject, throwError } from 'rxjs'; import { catchError, map, mergeMap, tap } from 'rxjs/operators'; +import { setEdqsEnabled, setNullsOrderStrategy } from '@shared/models/page/page-link'; import { LoginRequest, LoginResponse, PublicLoginRequest } from '@shared/models/login.models'; import { Router, UrlTree } from '@angular/router'; @@ -456,6 +457,8 @@ export class AuthService { return this.http.get('/api/system/params', defaultHttpOptions()).pipe( map((sysParams) => { this.timeService.setMaxDatapointsLimit(sysParams.maxDatapointsLimit); + setNullsOrderStrategy(sysParams.nullsOrderStrategy); + setEdqsEnabled(sysParams.edqsEnabled); return sysParams; }), catchError(() => of({} as SysParamsState)) diff --git a/ui-ngx/src/app/modules/home/components/rule-node/external/ai-config.component.html b/ui-ngx/src/app/modules/home/components/rule-node/external/ai-config.component.html index 73605e0767..483d6be37a 100644 --- a/ui-ngx/src/app/modules/home/components/rule-node/external/ai-config.component.html +++ b/ui-ngx/src/app/modules/home/components/rule-node/external/ai-config.component.html @@ -91,9 +91,15 @@ {{ 'rule-node-config.ai.response-format' | translate }} - {{ 'rule-node-config.ai.response-text' | translate }} - {{ 'rule-node-config.ai.response-json' | translate }} - {{ 'rule-node-config.ai.response-json-schema' | translate }} + @if (allowedResponseFormats.includes(responseFormat.TEXT)) { + {{ 'rule-node-config.ai.response-text' | translate }} + } + @if (allowedResponseFormats.includes(responseFormat.JSON)) { + {{ 'rule-node-config.ai.response-json' | translate }} + } + @if (allowedResponseFormats.includes(responseFormat.JSON_SCHEMA)) { + {{ 'rule-node-config.ai.response-json-schema' | translate }} + } -
+
widget-config.actions
diff --git a/ui-ngx/src/app/modules/home/components/widget/config/basic/common/widget-actions-panel.component.ts b/ui-ngx/src/app/modules/home/components/widget/config/basic/common/widget-actions-panel.component.ts index 0320dc7fd1..389c780e4c 100644 --- a/ui-ngx/src/app/modules/home/components/widget/config/basic/common/widget-actions-panel.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/config/basic/common/widget-actions-panel.component.ts @@ -26,6 +26,7 @@ import { import { deepClone } from '@core/utils'; import { MatDialog } from '@angular/material/dialog'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; +import { coerceBoolean } from '@shared/decorators/coercion'; @Component({ selector: 'tb-widget-actions-panel', @@ -45,6 +46,10 @@ export class WidgetActionsPanelComponent implements ControlValueAccessor, OnInit @Input() disabled: boolean; + @Input() + @coerceBoolean() + strokedPanel = false; + actionsFormGroup: UntypedFormGroup; private propagateChange = (_val: any) => {}; diff --git a/ui-ngx/src/app/modules/home/components/widget/config/basic/html/html-container-basic-config.component.html b/ui-ngx/src/app/modules/home/components/widget/config/basic/html/html-container-basic-config.component.html index 298bdb6e61..907da3e14b 100644 --- a/ui-ngx/src/app/modules/home/components/widget/config/basic/html/html-container-basic-config.component.html +++ b/ui-ngx/src/app/modules/home/components/widget/config/basic/html/html-container-basic-config.component.html @@ -17,4 +17,8 @@ --> + + diff --git a/ui-ngx/src/app/modules/home/components/widget/config/basic/html/html-container-basic-config.component.ts b/ui-ngx/src/app/modules/home/components/widget/config/basic/html/html-container-basic-config.component.ts index d057acf057..3872e07853 100644 --- a/ui-ngx/src/app/modules/home/components/widget/config/basic/html/html-container-basic-config.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/config/basic/html/html-container-basic-config.component.ts @@ -51,12 +51,14 @@ export class HtmlContainerBasicConfigComponent extends BasicWidgetConfigComponen protected onConfigSet(configData: WidgetConfigComponentData) { const settings: HtmlContainerWidgetSettings = {...htmlContainerDefaultSettings, ...(configData.config.settings || {})}; this.htmlContainerWidgetConfigForm = this.fb.group({ - settings: [settings, []] + settings: [settings, []], + actions: [configData.config.actions || {}, []] }); } protected prepareOutputConfig(config: any): WidgetConfigComponentData { this.widgetConfig.config.settings = {...(this.widgetConfig.config.settings || {}), ...config.settings}; + this.widgetConfig.config.actions = config.actions; return this.widgetConfig; } } diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/alarm/alarms-table-widget.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/alarm/alarms-table-widget.component.ts index 06e0cb73ad..d1b52dcf03 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/alarm/alarms-table-widget.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/alarm/alarms-table-widget.component.ts @@ -39,7 +39,7 @@ import { UtilsService } from '@core/services/utils.service'; import { TranslateService } from '@ngx-translate/core'; import { deepClone, hashCode, isDefined, isDefinedAndNotNull, isNotEmptyStr, isObject, isUndefined } from '@core/utils'; import cssjs from '@core/css/css'; -import { sortItems } from '@shared/models/page/page-link'; +import { SortColumnType, sortItems } from '@shared/models/page/page-link'; import { Direction } from '@shared/models/page/sort-order'; import { CollectionViewer, DataSource, SelectionModel } from '@angular/cdk/collections'; import { BehaviorSubject, forkJoin, fromEvent, merge, Observable, of, Subject, Subscription } from 'rxjs'; @@ -110,6 +110,7 @@ import { dataKeyToEntityKey, dataKeyTypeToEntityKeyType, entityDataPageLinkSortDirection, + EntityKeyType, KeyFilter } from '@app/shared/models/query/query.models'; import { DataKeyType } from '@shared/models/telemetry/telemetry.models'; @@ -722,8 +723,12 @@ export class AlarmsTableWidgetComponent extends PageComponent implements OnInit, this.pageLink.sortOrder = null; } const sortOrderLabel = fromEntityColumnDef(this.sort.active, this.columns); + const sortColumnType: SortColumnType = key + ? (key.type === EntityKeyType.ENTITY_FIELD || key.type === EntityKeyType.ALARM_FIELD ? 'entityField' + : key.type === EntityKeyType.TIME_SERIES ? 'timeseries' : 'attribute') + : 'entityField'; const keyFilters: KeyFilter[] = null; // TODO: - this.alarmsDatasource.loadAlarms(this.pageLink, sortOrderLabel, keyFilters); + this.alarmsDatasource.loadAlarms(this.pageLink, sortOrderLabel, sortColumnType, keyFilters); this.ctx.detectChanges(); } @@ -1251,6 +1256,7 @@ class AlarmsDatasource implements DataSource { private appliedPageLink: AlarmDataPageLink; private appliedSortOrderLabel: string; + private appliedSortColumnType: SortColumnType = 'entityField'; private reserveSpaceForHiddenAction = true; private cellButtonActions: TableCellButtonActionDescriptor[]; @@ -1289,11 +1295,13 @@ class AlarmsDatasource implements DataSource { this.pageDataSubject.complete(); } - loadAlarms(pageLink: AlarmDataPageLink, sortOrderLabel: string, keyFilters: KeyFilter[]) { + loadAlarms(pageLink: AlarmDataPageLink, sortOrderLabel: string, + sortColumnType: SortColumnType, keyFilters: KeyFilter[]) { this.dataLoading = true; // this.clear(); this.appliedPageLink = pageLink; this.appliedSortOrderLabel = sortOrderLabel; + this.appliedSortColumnType = sortColumnType; this.subscription.subscribeForAlarms(pageLink, keyFilters); } @@ -1325,7 +1333,7 @@ class AlarmsDatasource implements DataSource { } if (this.appliedSortOrderLabel && this.appliedSortOrderLabel.length) { const asc = this.appliedPageLink.sortOrder.direction === Direction.ASC; - alarms = alarms.sort((a, b) => sortItems(a, b, this.appliedSortOrderLabel, asc)); + alarms = alarms.sort((a, b) => sortItems(a, b, this.appliedSortOrderLabel, asc, this.appliedSortColumnType)); } if (this.selection.hasValue()) { const alarmIds = alarms.map((alarm) => alarm.id.id); diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/entity/entities-table-widget.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/entity/entities-table-widget.component.ts index cd43dbdf19..18984b5b83 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/entity/entities-table-widget.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/entity/entities-table-widget.component.ts @@ -100,7 +100,7 @@ import { EntityKeyType, KeyFilter } from '@shared/models/query/query.models'; -import { sortItems } from '@shared/models/page/page-link'; +import { SortColumnType, sortItems } from '@shared/models/page/page-link'; import { entityFields } from '@shared/models/entity.models'; import { DatePipe } from '@angular/common'; import { coerceBooleanProperty } from '@angular/cdk/coercion'; @@ -617,8 +617,12 @@ export class EntitiesTableWidgetComponent extends PageComponent implements OnIni this.pageLink.sortOrder = null; } const sortOrderLabel = fromEntityColumnDef(this.sort.active, this.columns); + const sortColumnType: SortColumnType = key + ? (key.type === EntityKeyType.ENTITY_FIELD ? 'entityField' + : key.type === EntityKeyType.TIME_SERIES ? 'timeseries' : 'attribute') + : 'entityField'; const keyFilters: KeyFilter[] = null; // TODO: - this.entityDatasource.loadEntities(this.pageLink, sortOrderLabel, keyFilters); + this.entityDatasource.loadEntities(this.pageLink, sortOrderLabel, sortColumnType, keyFilters); this.ctx.detectChanges(); } @@ -865,6 +869,7 @@ class EntityDatasource implements DataSource { private appliedPageLink: EntityDataPageLink; private appliedSortOrderLabel: string; + private appliedSortColumnType: SortColumnType = 'entityField'; private reserveSpaceForHiddenAction = true; private cellButtonActions: TableCellButtonActionDescriptor[]; @@ -905,11 +910,13 @@ class EntityDatasource implements DataSource { this.pageDataSubject.complete(); } - loadEntities(pageLink: EntityDataPageLink, sortOrderLabel: string, keyFilters: KeyFilter[]) { + loadEntities(pageLink: EntityDataPageLink, sortOrderLabel: string, + sortColumnType: SortColumnType, keyFilters: KeyFilter[]) { this.dataLoading = true; // this.clear(); this.appliedPageLink = pageLink; this.appliedSortOrderLabel = sortOrderLabel; + this.appliedSortColumnType = sortColumnType; this.subscription.subscribeForPaginatedData(0, pageLink, keyFilters); } @@ -934,7 +941,7 @@ class EntityDatasource implements DataSource { }); if (this.appliedSortOrderLabel && this.appliedSortOrderLabel.length) { const asc = this.appliedPageLink.sortOrder.direction === Direction.ASC; - entities = entities.sort((a, b) => sortItems(a, b, this.appliedSortOrderLabel, asc)); + entities = entities.sort((a, b) => sortItems(a, b, this.appliedSortOrderLabel, asc, this.appliedSortColumnType)); } if (!dynamicWidthCellButtonActions && this.cellButtonActions.length && entities.length) { maxCellButtonAction = entities[0].actionCellButtons.length; diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/html/html-container-widget.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/html/html-container-widget.component.ts index f7bae926bc..51ee9871f8 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/html/html-container-widget.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/html/html-container-widget.component.ts @@ -15,8 +15,10 @@ /// import { - Component, + ChangeDetectorRef, + Component, ComponentRef, ElementRef, + inject, Inject, Injector, Input, @@ -96,6 +98,7 @@ export class HtmlContainerWidgetComponent implements OnInit { @Inject(HOME_COMPONENTS_MODULE_TOKEN) private homeComponentsModule: Type, private dynamicComponentFactoryService: DynamicComponentFactoryService, private utils: UtilsService, + private cdr: ChangeDetectorRef, private resources: ResourcesService) {} ngOnInit(): void { @@ -160,11 +163,13 @@ export class HtmlContainerWidgetComponent implements OnInit { this.compileAngularFunction().subscribe( { next: (containerFunction) => { - try { - this.initAngularComponent(imports, containerFunction); - } catch (e) { - this.handleWidgetException(e); - } + setTimeout(() => { + try { + this.initAngularComponent(imports, containerFunction); + } catch (e) { + this.handleWidgetException(e); + } + }); }, error: (e) => { this.handleWidgetException(e); @@ -200,9 +205,16 @@ export class HtmlContainerWidgetComponent implements OnInit { compileModules = compileModules.concat(imports); } const self = () => this; + + let containerRef: ComponentRef; + this.dynamicComponentFactoryService.createDynamicComponent( class TbContainerInstance { + + private cdr = inject(ChangeDetectorRef); + ngOnInit(): void { + this.cdr.detach(); if (containerFunction) { const instance = self(); try { @@ -212,6 +224,15 @@ export class HtmlContainerWidgetComponent implements OnInit { } } } + ngDoCheck(): void { + const instance = self(); + try { + this.cdr.detectChanges() + } catch (error) { + containerRef.destroy(); + instance.handleWidgetException(error) + } + } ngOnDestroy(): void { destroyContainerInstanceResources(); } @@ -224,7 +245,7 @@ export class HtmlContainerWidgetComponent implements OnInit { this.containerInstanceComponentType = componentType; const injector: Injector = Injector.create({providers: [], parent: this.angularContainer.viewContainerRef.injector}); try { - this.angularContainer.viewContainerRef.createComponent(this.containerInstanceComponentType, + containerRef = this.angularContainer.viewContainerRef.createComponent(this.containerInstanceComponentType, {index: 0, injector}); } catch (error) { diff --git a/ui-ngx/src/app/modules/home/components/widget/widget.component.ts b/ui-ngx/src/app/modules/home/components/widget/widget.component.ts index b04fad8caf..29b51230b0 100644 --- a/ui-ngx/src/app/modules/home/components/widget/widget.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/widget.component.ts @@ -278,6 +278,7 @@ export class WidgetComponent extends PageComponent implements OnInit, OnChanges, elementClick: this.elementClick.bind(this), cardClick: this.cardClick.bind(this), click: this.click.bind(this), + invokeAction: this.invokeAction.bind(this), getActiveEntityInfo: this.getActiveEntityInfo.bind(this), openDashboardStateInSeparateDialog: this.openDashboardStateInSeparateDialog.bind(this), openDashboardStateInPopover: this.openDashboardStateInPopover.bind(this), @@ -1617,6 +1618,16 @@ export class WidgetComponent extends PageComponent implements OnInit, OnChanges, } } + private invokeAction($event: Event, actionName: string, additionalParams?: any) { + const descriptors = this.getActionDescriptors('javaScript'); + if (descriptors?.length) { + const found = descriptors.find(d => d.name === actionName); + if (found) { + this.handleWidgetAction($event, found, null, null, additionalParams); + } + } + } + private onWidgetAction($event: Event, action: WidgetAction) { if ($event) { $event.stopPropagation(); diff --git a/ui-ngx/src/app/modules/home/models/widget-component.models.ts b/ui-ngx/src/app/modules/home/models/widget-component.models.ts index a9d57a0d41..92cfd901c1 100644 --- a/ui-ngx/src/app/modules/home/models/widget-component.models.ts +++ b/ui-ngx/src/app/modules/home/models/widget-component.models.ts @@ -159,6 +159,8 @@ export interface IDashboardWidget { updateParamsFromData(detectChanges?: boolean): void; } +export type WidgetDestroyCallback = () => void; + export class WidgetContext { constructor(public dashboard: IDashboardComponent, @@ -363,6 +365,8 @@ export class WidgetContext { ...RxJSOperators }; + private destroyCallbacks: WidgetDestroyCallback[] = []; + registerPopoverComponent(popoverComponent: TbPopoverComponent) { this.popoverComponents.push(popoverComponent); popoverComponent.tbDestroy.subscribe(() => { @@ -402,6 +406,10 @@ export class WidgetContext { } } + registerDestroyCallback(destroyCallback: WidgetDestroyCallback) { + this.destroyCallbacks.push(destroyCallback); + } + showSuccessToast(message: string, duration: number = 1000, verticalPosition: NotificationVerticalPosition = 'bottom', horizontalPosition: NotificationHorizontalPosition = 'left', @@ -518,6 +526,13 @@ export class WidgetContext { labelPattern.destroy(); } this.labelPatterns.clear(); + this.destroyCallbacks.forEach((destroyCallback) => { + try { + destroyCallback() + } catch (_ignoredError) { /* empty */ } + } + ); + this.destroyCallbacks.length = 0; this.width = undefined; this.height = undefined; this.destroyed = true; diff --git a/ui-ngx/src/app/shared/models/ace/widget-completion.models.ts b/ui-ngx/src/app/shared/models/ace/widget-completion.models.ts index e6ce4b3ef7..757677fadd 100644 --- a/ui-ngx/src/app/shared/models/ace/widget-completion.models.ts +++ b/ui-ngx/src/app/shared/models/ace/widget-completion.models.ts @@ -632,6 +632,28 @@ export const widgetContextCompletionsWithSettings = (settingsCompletions?: TbEdi optional: true } ] + }, + invokeAction: { + description: 'Invoke action with JavaScript action source.', + meta: 'function', + args: [ + { + name: '$event', + description: 'DOM event object associated with action.', + type: 'Event' + }, + { + name: 'actionName', + description: 'Name of the configured action with JavaScript action source.', + type: 'string' + }, + { + name: 'additionalParams', + description: 'Optional payload merged into the action context and forwarded to the configured JavaScript action function as its additionalParams argument. Use it to pass row data, button state, or any extra values the action handler should react to.', + type: 'object', + optional: true + } + ] } } }, @@ -736,6 +758,17 @@ export const widgetContextCompletionsWithSettings = (settingsCompletions?: TbEdi } } } + }, + registerDestroyCallback: { + description: 'Registers a teardown callback that will be invoked exactly once when the widget is about to be destroyed (dashboard navigation, edit/view switch, layout change, etc.). Use it to release resources acquired during widget setup so they don\'t leak across widget reloads — e.g. unsubscribe RxJS subscriptions, detach DOM/window event listeners, clear setInterval / setTimeout timers, abort outstanding HTTP requests, destroy third-party plugin instances. Multiple callbacks may be registered; they are executed in registration order.', + meta: 'function', + args: [ + { + description: 'Zero-argument function executed when the widget is destroyed. Should be idempotent — synchronously dispose of one specific resource (e.g. one subscription or one listener) and avoid throwing; throwing here may prevent later cleanup callbacks from running.', + name: 'destroyCallback', + type: '() => void', + } + ] } }, ...serviceCompletions diff --git a/ui-ngx/src/app/shared/models/ai-model.models.ts b/ui-ngx/src/app/shared/models/ai-model.models.ts index 504e4d2659..575eba1d33 100644 --- a/ui-ngx/src/app/shared/models/ai-model.models.ts +++ b/ui-ngx/src/app/shared/models/ai-model.models.ts @@ -105,11 +105,11 @@ export const AiModelMap = new Map { + switch (provider) { + case AiProvider.ANTHROPIC: + case AiProvider.AMAZON_BEDROCK: + return [ResponseFormat.TEXT, ResponseFormat.JSON_SCHEMA]; + default: + return [ResponseFormat.TEXT, ResponseFormat.JSON, ResponseFormat.JSON_SCHEMA]; + } +}; + export interface AiModelWithUserMsg { userMessage: { contents: Array<{contentType: string; text: string}>; diff --git a/ui-ngx/src/app/shared/models/page/page-link.ts b/ui-ngx/src/app/shared/models/page/page-link.ts index eb2c059c2a..f3837a5cc5 100644 --- a/ui-ngx/src/app/shared/models/page/page-link.ts +++ b/ui-ngx/src/app/shared/models/page/page-link.ts @@ -22,6 +22,19 @@ import { EntitiesTableAction } from '@home/models/entity/entity-table-component. export const MAX_SAFE_PAGE_SIZE = 2147483647; +export type NullsOrderStrategy = 'default' | 'nulls_first' | 'nulls_last'; + +let nullsOrderStrategy: NullsOrderStrategy = 'default'; +let edqsEnabled = false; + +export function setNullsOrderStrategy(value: NullsOrderStrategy): void { + nullsOrderStrategy = value ?? 'default'; +} + +export function setEdqsEnabled(value: boolean): void { + edqsEnabled = !!value; +} + export type PageLinkSearchFunction = (entity: T, textSearch: string, searchProperty?: string) => boolean; export interface PageQueryParam extends Partial{ @@ -73,9 +86,37 @@ const defaultPageLinkSearch: PageLinkSearchFunction = return false; }; -export function sortItems(item1: any, item2: any, property: string, asc: boolean): number { +export type SortColumnType = 'entityField' | 'attribute' | 'timeseries'; + +export function sortItems(item1: any, item2: any, property: string, asc: boolean, + columnType: SortColumnType = 'entityField'): number { const item1Value = getDescendantProp(item1, property); const item2Value = getDescendantProp(item2, property); + const item1Empty = item1Value === null || item1Value === undefined || item1Value === ''; + const item2Empty = item2Value === null || item2Value === undefined || item2Value === ''; + // Mirror backend's nulls ordering. EDQS uses fixed NULLS FIRST regardless of strategy and + // naive compare below already matches it, so skip this branch when EDQS is on. + // For entityField columns the ORDER BY hits a real nullable DB column → strategy always applies. + // For attribute/timeseries the strategy only applies to numeric/boolean values; string/json + // are coalesced to '' on the backend, so naive compare below already matches its order. + if (!edqsEnabled && (item1Empty || item2Empty) && !(item1Empty && item2Empty)) { + let applyStrategy = columnType === 'entityField'; + if (!applyStrategy) { + const other = item1Empty ? item2Value : item1Value; + applyStrategy = + typeof other === 'boolean' || other === 'true' || other === 'false' || + (typeof other === 'number' && isFinite(other)) || + (typeof other === 'string' && other.trim() !== '' && !isNaN(Number(other))); + } + if (applyStrategy) { + const nullsFirst = nullsOrderStrategy === 'nulls_first' + || (nullsOrderStrategy === 'default' && !asc); + if (item1Empty) { + return nullsFirst ? -1 : 1; + } + return nullsFirst ? 1 : -1; + } + } let result = 0; if (item1Value !== item2Value) { const item1Type = typeof item1Value; diff --git a/ui-ngx/src/assets/locale/locale.constant-da_DK.json b/ui-ngx/src/assets/locale/locale.constant-da_DK.json index fc39de2abc..538affbc5a 100644 --- a/ui-ngx/src/assets/locale/locale.constant-da_DK.json +++ b/ui-ngx/src/assets/locale/locale.constant-da_DK.json @@ -1573,8 +1573,8 @@ "ai-providers": { "openai": "OpenAI", "azure-openai": "Azure OpenAI", - "google-ai-gemini": "Google AI Gemini", - "google-vertex-ai-gemini": "Google Vertex AI Gemini", + "google-ai-gemini": "Google Gemini (Gemini API)", + "google-vertex-ai-gemini": "Google Gemini (Agent Platform - Vertex AI)", "mistral-ai": "Mistral AI", "anthropic": "Anthropic", "amazon-bedrock": "Amazon Bedrock", @@ -10190,4 +10190,4 @@ "auto": "Auto", "language": "Sprog" } -} \ No newline at end of file +} diff --git a/ui-ngx/src/assets/locale/locale.constant-de_DE.json b/ui-ngx/src/assets/locale/locale.constant-de_DE.json index 2dfdd9645d..48d2698501 100644 --- a/ui-ngx/src/assets/locale/locale.constant-de_DE.json +++ b/ui-ngx/src/assets/locale/locale.constant-de_DE.json @@ -1573,8 +1573,8 @@ "ai-providers": { "openai": "OpenAI", "azure-openai": "Azure OpenAI", - "google-ai-gemini": "Google AI Gemini", - "google-vertex-ai-gemini": "Google Vertex AI Gemini", + "google-ai-gemini": "Google Gemini (Gemini API)", + "google-vertex-ai-gemini": "Google Gemini (Agent Platform - Vertex AI)", "mistral-ai": "Mistral AI", "anthropic": "Anthropic", "amazon-bedrock": "Amazon Bedrock", @@ -10190,4 +10190,4 @@ "auto": "Auto", "language": "Sprache" } -} \ No newline at end of file +} diff --git a/ui-ngx/src/assets/locale/locale.constant-el_GR.json b/ui-ngx/src/assets/locale/locale.constant-el_GR.json index ebd4d4df2d..6a7c63e466 100644 --- a/ui-ngx/src/assets/locale/locale.constant-el_GR.json +++ b/ui-ngx/src/assets/locale/locale.constant-el_GR.json @@ -1573,8 +1573,8 @@ "ai-providers": { "openai": "OpenAI", "azure-openai": "Azure OpenAI", - "google-ai-gemini": "Google AI Gemini", - "google-vertex-ai-gemini": "Google Vertex AI Gemini", + "google-ai-gemini": "Google Gemini (Gemini API)", + "google-vertex-ai-gemini": "Google Gemini (Agent Platform - Vertex AI)", "mistral-ai": "Mistral AI", "anthropic": "Anthropic", "amazon-bedrock": "Amazon Bedrock", @@ -10190,4 +10190,4 @@ "auto": "Auto", "language": "Γλώσσα" } -} \ No newline at end of file +} diff --git a/ui-ngx/src/assets/locale/locale.constant-en_US.json b/ui-ngx/src/assets/locale/locale.constant-en_US.json index 74e5e74c63..232ab52412 100644 --- a/ui-ngx/src/assets/locale/locale.constant-en_US.json +++ b/ui-ngx/src/assets/locale/locale.constant-en_US.json @@ -667,7 +667,7 @@ "assigned-to-user": "Alarm was assigned by user {{userName}} to user {{assigneeName}}", "unassigned-to-user": "Alarm was unassigned by user {{userName}}", "unassigned-from-deleted-user": "Alarm was unassigned because user {{userName}} - was deleted", - "comment-deleted": "User {{userName}} deleted his comment", + "comment-deleted": "Comment was deleted by user {{userName}}", "severity-changed": "Alarm severity was updated from {{oldSeverity}} to {{newSeverity}}" } }, @@ -1580,8 +1580,8 @@ "ai-providers": { "openai": "OpenAI", "azure-openai": "Azure OpenAI", - "google-ai-gemini": "Google AI Gemini", - "google-vertex-ai-gemini": "Google Vertex AI Gemini", + "google-ai-gemini": "Google Gemini (Gemini API)", + "google-vertex-ai-gemini": "Google Gemini (Agent Platform - Vertex AI)", "mistral-ai": "Mistral AI", "anthropic": "Anthropic", "amazon-bedrock": "Amazon Bedrock", diff --git a/ui-ngx/src/assets/locale/locale.constant-es_ES.json b/ui-ngx/src/assets/locale/locale.constant-es_ES.json index c919107272..61ab94ae77 100644 --- a/ui-ngx/src/assets/locale/locale.constant-es_ES.json +++ b/ui-ngx/src/assets/locale/locale.constant-es_ES.json @@ -1573,8 +1573,8 @@ "ai-providers": { "openai": "OpenAI", "azure-openai": "Azure OpenAI", - "google-ai-gemini": "Google AI Gemini", - "google-vertex-ai-gemini": "Google Vertex AI Gemini", + "google-ai-gemini": "Google Gemini (Gemini API)", + "google-vertex-ai-gemini": "Google Gemini (Agent Platform - Vertex AI)", "mistral-ai": "Mistral AI", "anthropic": "Anthropic", "amazon-bedrock": "Amazon Bedrock", @@ -10190,4 +10190,4 @@ "auto": "Auto", "language": "Idioma" } -} \ No newline at end of file +} diff --git a/ui-ngx/src/assets/locale/locale.constant-fr_FR.json b/ui-ngx/src/assets/locale/locale.constant-fr_FR.json index 15fa9b5586..1b6070833f 100644 --- a/ui-ngx/src/assets/locale/locale.constant-fr_FR.json +++ b/ui-ngx/src/assets/locale/locale.constant-fr_FR.json @@ -1573,8 +1573,8 @@ "ai-providers": { "openai": "OpenAI", "azure-openai": "Azure OpenAI", - "google-ai-gemini": "Google AI Gemini", - "google-vertex-ai-gemini": "Google Vertex AI Gemini", + "google-ai-gemini": "Google Gemini (Gemini API)", + "google-vertex-ai-gemini": "Google Gemini (Agent Platform - Vertex AI)", "mistral-ai": "Mistral AI", "anthropic": "Anthropic", "amazon-bedrock": "Amazon Bedrock", @@ -10190,4 +10190,4 @@ "auto": "Auto", "language": "Langue" } -} \ No newline at end of file +} diff --git a/ui-ngx/src/assets/locale/locale.constant-it_IT.json b/ui-ngx/src/assets/locale/locale.constant-it_IT.json index 00de105518..08ad1c41b2 100644 --- a/ui-ngx/src/assets/locale/locale.constant-it_IT.json +++ b/ui-ngx/src/assets/locale/locale.constant-it_IT.json @@ -1573,8 +1573,8 @@ "ai-providers": { "openai": "OpenAI", "azure-openai": "Azure OpenAI", - "google-ai-gemini": "Google AI Gemini", - "google-vertex-ai-gemini": "Google Vertex AI Gemini", + "google-ai-gemini": "Google Gemini (Gemini API)", + "google-vertex-ai-gemini": "Google Gemini (Agent Platform - Vertex AI)", "mistral-ai": "Mistral AI", "anthropic": "Anthropic", "amazon-bedrock": "Amazon Bedrock", @@ -10190,4 +10190,4 @@ "auto": "Auto", "language": "Lingua" } -} \ No newline at end of file +} diff --git a/ui-ngx/src/assets/locale/locale.constant-ja_JP.json b/ui-ngx/src/assets/locale/locale.constant-ja_JP.json index 84345c6216..2d5954e59a 100644 --- a/ui-ngx/src/assets/locale/locale.constant-ja_JP.json +++ b/ui-ngx/src/assets/locale/locale.constant-ja_JP.json @@ -1573,8 +1573,8 @@ "ai-providers": { "openai": "OpenAI", "azure-openai": "Azure OpenAI", - "google-ai-gemini": "Google AI Gemini", - "google-vertex-ai-gemini": "Google Vertex AI Gemini", + "google-ai-gemini": "Google Gemini (Gemini API)", + "google-vertex-ai-gemini": "Google Gemini (Agent Platform - Vertex AI)", "mistral-ai": "Mistral AI", "anthropic": "Anthropic", "amazon-bedrock": "Amazon Bedrock", @@ -10190,4 +10190,4 @@ "auto": "自動", "language": "言語" } -} \ No newline at end of file +} diff --git a/ui-ngx/src/assets/locale/locale.constant-lt_LT.json b/ui-ngx/src/assets/locale/locale.constant-lt_LT.json index 893453031b..50d766ea91 100644 --- a/ui-ngx/src/assets/locale/locale.constant-lt_LT.json +++ b/ui-ngx/src/assets/locale/locale.constant-lt_LT.json @@ -1106,8 +1106,8 @@ "ai-providers": { "openai": "OpenAI", "azure-openai": "Azure OpenAI", - "google-ai-gemini": "Google AI Gemini", - "google-vertex-ai-gemini": "Google Vertex AI Gemini", + "google-ai-gemini": "Google Gemini (Gemini API)", + "google-vertex-ai-gemini": "Google Gemini (Agent Platform - Vertex AI)", "mistral-ai": "Mistral AI", "anthropic": "Anthropic", "amazon-bedrock": "Amazon Bedrock", diff --git a/ui-ngx/src/assets/locale/locale.constant-nl_NL.json b/ui-ngx/src/assets/locale/locale.constant-nl_NL.json index 7926c16961..4acdcdafae 100644 --- a/ui-ngx/src/assets/locale/locale.constant-nl_NL.json +++ b/ui-ngx/src/assets/locale/locale.constant-nl_NL.json @@ -1573,8 +1573,8 @@ "ai-providers": { "openai": "OpenAI", "azure-openai": "Azure OpenAI", - "google-ai-gemini": "Google AI Gemini", - "google-vertex-ai-gemini": "Google Vertex AI Gemini", + "google-ai-gemini": "Google Gemini (Gemini API)", + "google-vertex-ai-gemini": "Google Gemini (Agent Platform - Vertex AI)", "mistral-ai": "Mistral AI", "anthropic": "Anthropic", "amazon-bedrock": "Amazon Bedrock", @@ -10190,4 +10190,4 @@ "auto": "Auto", "language": "Taal" } -} \ No newline at end of file +} diff --git a/ui-ngx/src/assets/locale/locale.constant-no_NO.json b/ui-ngx/src/assets/locale/locale.constant-no_NO.json index be53c9624d..85bb5f57d5 100644 --- a/ui-ngx/src/assets/locale/locale.constant-no_NO.json +++ b/ui-ngx/src/assets/locale/locale.constant-no_NO.json @@ -1573,8 +1573,8 @@ "ai-providers": { "openai": "OpenAI", "azure-openai": "Azure OpenAI", - "google-ai-gemini": "Google AI Gemini", - "google-vertex-ai-gemini": "Google Vertex AI Gemini", + "google-ai-gemini": "Google Gemini (Gemini API)", + "google-vertex-ai-gemini": "Google Gemini (Agent Platform - Vertex AI)", "mistral-ai": "Mistral AI", "anthropic": "Anthropic", "amazon-bedrock": "Amazon Bedrock", @@ -10190,4 +10190,4 @@ "auto": "Auto", "language": "Språk" } -} \ No newline at end of file +} diff --git a/ui-ngx/src/assets/locale/locale.constant-pt_BR.json b/ui-ngx/src/assets/locale/locale.constant-pt_BR.json index a602a04529..8a59259efb 100644 --- a/ui-ngx/src/assets/locale/locale.constant-pt_BR.json +++ b/ui-ngx/src/assets/locale/locale.constant-pt_BR.json @@ -1573,8 +1573,8 @@ "ai-providers": { "openai": "OpenAI", "azure-openai": "Azure OpenAI", - "google-ai-gemini": "Google AI Gemini", - "google-vertex-ai-gemini": "Google Vertex AI Gemini", + "google-ai-gemini": "Google Gemini (Gemini API)", + "google-vertex-ai-gemini": "Google Gemini (Agent Platform - Vertex AI)", "mistral-ai": "Mistral AI", "anthropic": "Anthropic", "amazon-bedrock": "Amazon Bedrock", @@ -10190,4 +10190,4 @@ "auto": "Automático", "language": "Idioma" } -} \ No newline at end of file +} diff --git a/ui-ngx/src/assets/locale/locale.constant-tr_TR.json b/ui-ngx/src/assets/locale/locale.constant-tr_TR.json index 0027c7d8ed..f49fe1d95e 100644 --- a/ui-ngx/src/assets/locale/locale.constant-tr_TR.json +++ b/ui-ngx/src/assets/locale/locale.constant-tr_TR.json @@ -1573,8 +1573,8 @@ "ai-providers": { "openai": "OpenAI", "azure-openai": "Azure OpenAI", - "google-ai-gemini": "Google AI Gemini", - "google-vertex-ai-gemini": "Google Vertex AI Gemini", + "google-ai-gemini": "Google Gemini (Gemini API)", + "google-vertex-ai-gemini": "Google Gemini (Agent Platform - Vertex AI)", "mistral-ai": "Mistral AI", "anthropic": "Anthropic", "amazon-bedrock": "Amazon Bedrock", @@ -10190,4 +10190,4 @@ "auto": "Auto", "language": "Dil" } -} \ No newline at end of file +} diff --git a/ui-ngx/src/assets/locale/locale.constant-uk_UA.json b/ui-ngx/src/assets/locale/locale.constant-uk_UA.json index 5e46c556ad..551397bbee 100644 --- a/ui-ngx/src/assets/locale/locale.constant-uk_UA.json +++ b/ui-ngx/src/assets/locale/locale.constant-uk_UA.json @@ -1573,8 +1573,8 @@ "ai-providers": { "openai": "OpenAI", "azure-openai": "Azure OpenAI", - "google-ai-gemini": "Google AI Gemini", - "google-vertex-ai-gemini": "Google Vertex AI Gemini", + "google-ai-gemini": "Google Gemini (Gemini API)", + "google-vertex-ai-gemini": "Google Gemini (Agent Platform - Vertex AI)", "mistral-ai": "Mistral AI", "anthropic": "Anthropic", "amazon-bedrock": "Amazon Bedrock", @@ -10190,4 +10190,4 @@ "auto": "Auto", "language": "Мова" } -} \ No newline at end of file +} diff --git a/ui-ngx/src/assets/locale/locale.constant-zh_CN.json b/ui-ngx/src/assets/locale/locale.constant-zh_CN.json index 8ed93ba0a4..abfb4cb788 100644 --- a/ui-ngx/src/assets/locale/locale.constant-zh_CN.json +++ b/ui-ngx/src/assets/locale/locale.constant-zh_CN.json @@ -1573,8 +1573,8 @@ "ai-providers": { "openai": "OpenAI", "azure-openai": "Azure OpenAI", - "google-ai-gemini": "Google AI Gemini", - "google-vertex-ai-gemini": "Google Vertex AI Gemini", + "google-ai-gemini": "Google Gemini (Gemini API)", + "google-vertex-ai-gemini": "Google Gemini (Agent Platform - Vertex AI)", "mistral-ai": "Mistral AI", "anthropic": "Anthropic", "amazon-bedrock": "Amazon Bedrock", @@ -10190,4 +10190,4 @@ "auto": "自动", "language": "语言" } -} \ No newline at end of file +} diff --git a/ui-ngx/yarn.lock b/ui-ngx/yarn.lock index e9564f5279..6b5f48283b 100644 --- a/ui-ngx/yarn.lock +++ b/ui-ngx/yarn.lock @@ -987,14 +987,14 @@ "@babel/helper-plugin-utils" "^7.28.6" "@babel/plugin-transform-modules-systemjs@^7.27.1": - version "7.28.5" - resolved "https://registry.yarnpkg.com/@babel/plugin-transform-modules-systemjs/-/plugin-transform-modules-systemjs-7.28.5.tgz#7439e592a92d7670dfcb95d0cbc04bd3e64801d2" - integrity sha512-vn5Jma98LCOeBy/KpeQhXcV2WZgaRUtjwQmjoBuLNlOmkg0fB5pdvYVeWRYI69wWKwK2cD1QbMiUQnoujWvrew== + version "7.29.4" + resolved "https://registry.yarnpkg.com/@babel/plugin-transform-modules-systemjs/-/plugin-transform-modules-systemjs-7.29.4.tgz#f621105da99919c15cf4bde6fcc7346ef95e7b20" + integrity sha512-N7QmZ0xRZfjHOfZeQLJjwgX2zS9pdGHSVl/cjSGlo4dXMqvurfxXDMKY4RqEKzPozV78VMcd0lxyG13mlbKc4w== dependencies: - "@babel/helper-module-transforms" "^7.28.3" - "@babel/helper-plugin-utils" "^7.27.1" + "@babel/helper-module-transforms" "^7.28.6" + "@babel/helper-plugin-utils" "^7.28.6" "@babel/helper-validator-identifier" "^7.28.5" - "@babel/traverse" "^7.28.5" + "@babel/traverse" "^7.29.0" "@babel/plugin-transform-modules-umd@^7.27.1": version "7.27.1" @@ -1298,7 +1298,7 @@ "@babel/parser" "^7.28.6" "@babel/types" "^7.28.6" -"@babel/traverse@^7.27.1", "@babel/traverse@^7.28.0", "@babel/traverse@^7.28.3", "@babel/traverse@^7.28.5", "@babel/traverse@^7.28.6": +"@babel/traverse@^7.27.1", "@babel/traverse@^7.28.0", "@babel/traverse@^7.28.3", "@babel/traverse@^7.28.5", "@babel/traverse@^7.28.6", "@babel/traverse@^7.29.0": version "7.29.0" resolved "https://registry.yarnpkg.com/@babel/traverse/-/traverse-7.29.0.tgz#f323d05001440253eead3c9c858adbe00b90310a" integrity sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA== @@ -4349,17 +4349,17 @@ boolbase@^1.0.0: integrity sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww== brace-expansion@^1.1.7: - version "1.1.13" - resolved "https://registry.yarnpkg.com/brace-expansion/-/brace-expansion-1.1.13.tgz#d37875c01dc9eff988dd49d112a57cb67b54efe6" - integrity sha512-9ZLprWS6EENmhEOpjCYW2c8VkmOvckIJZfkr7rBW6dObmfgJ/L1GpSYW5Hpo9lDz4D1+n0Ckz8rU7FwHDQiG/w== + version "1.1.14" + resolved "https://registry.yarnpkg.com/brace-expansion/-/brace-expansion-1.1.14.tgz#d9de602370d91347cd9ddad1224d4fd701eb348b" + integrity sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g== dependencies: balanced-match "^1.0.0" concat-map "0.0.1" brace-expansion@^5.0.2: - version "5.0.5" - resolved "https://registry.yarnpkg.com/brace-expansion/-/brace-expansion-5.0.5.tgz#dcc3a37116b79f3e1b46db994ced5d570e930fdb" - integrity sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ== + version "5.0.6" + resolved "https://registry.yarnpkg.com/brace-expansion/-/brace-expansion-5.0.6.tgz#ec68fe0a641a29d8711579caf641d05bae1f2285" + integrity sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g== dependencies: balanced-match "^4.0.2" @@ -6113,9 +6113,9 @@ fast-levenshtein@^2.0.6: integrity sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw== fast-uri@^3.0.1: - version "3.1.0" - resolved "https://registry.yarnpkg.com/fast-uri/-/fast-uri-3.1.0.tgz#66eecff6c764c0df9b762e62ca7edcfb53b4edfa" - integrity sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA== + version "3.1.2" + resolved "https://registry.yarnpkg.com/fast-uri/-/fast-uri-3.1.2.tgz#8af3d4fc9d3e71b11572cc2673b514a7d1a8c8ec" + integrity sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ== fastq@^1.6.0: version "1.17.1"