From fcc2a917fde477467e54e0ecb4ea74ed016479a6 Mon Sep 17 00:00:00 2001 From: Yevhenii Date: Tue, 23 Sep 2025 12:00:16 +0300 Subject: [PATCH 01/54] Added User sync for Edge --- .../service/edge/EdgeContextComponent.java | 4 + .../service/edge/rpc/EdgeGrpcSession.java | 24 +++ .../rpc/processor/user/BaseUserProcessor.java | 130 ++++++++++++ .../rpc/processor/user/UserEdgeProcessor.java | 97 ++++++++- .../rpc/processor/user/UserProcessor.java | 31 +++ .../thingsboard/server/edge/UserEdgeTest.java | 193 ++++++++++++------ .../server/dao/user/UserService.java | 7 + common/edge-api/src/main/proto/edge.proto | 2 + .../service/validator/UserDataValidator.java | 1 + .../server/dao/user/UserServiceImpl.java | 77 +++++-- 10 files changed, 485 insertions(+), 81 deletions(-) create mode 100644 application/src/main/java/org/thingsboard/server/service/edge/rpc/processor/user/BaseUserProcessor.java create mode 100644 application/src/main/java/org/thingsboard/server/service/edge/rpc/processor/user/UserProcessor.java diff --git a/application/src/main/java/org/thingsboard/server/service/edge/EdgeContextComponent.java b/application/src/main/java/org/thingsboard/server/service/edge/EdgeContextComponent.java index 5c5eea32fd..0f469b15b8 100644 --- a/application/src/main/java/org/thingsboard/server/service/edge/EdgeContextComponent.java +++ b/application/src/main/java/org/thingsboard/server/service/edge/EdgeContextComponent.java @@ -73,6 +73,7 @@ import org.thingsboard.server.service.edge.rpc.processor.resource.ResourceEdgePr import org.thingsboard.server.service.edge.rpc.processor.rule.RuleChainEdgeProcessor; import org.thingsboard.server.service.edge.rpc.processor.rule.RuleChainMetadataEdgeProcessor; import org.thingsboard.server.service.edge.rpc.processor.telemetry.TelemetryEdgeProcessor; +import org.thingsboard.server.service.edge.rpc.processor.user.UserProcessor; import org.thingsboard.server.service.edge.rpc.sync.EdgeRequestsService; import org.thingsboard.server.service.executors.GrpcCallbackExecutorService; @@ -261,6 +262,9 @@ public class EdgeContextComponent { @Autowired private CalculatedFieldProcessor calculatedFieldProcessor; + @Autowired + private UserProcessor userProcessor; + public EdgeProcessor getProcessor(EdgeEventType edgeEventType) { EdgeProcessor processor = processorMap.get(edgeEventType); if (processor == null) { diff --git a/application/src/main/java/org/thingsboard/server/service/edge/rpc/EdgeGrpcSession.java b/application/src/main/java/org/thingsboard/server/service/edge/rpc/EdgeGrpcSession.java index a86409c6af..393d1ba3da 100644 --- a/application/src/main/java/org/thingsboard/server/service/edge/rpc/EdgeGrpcSession.java +++ b/application/src/main/java/org/thingsboard/server/service/edge/rpc/EdgeGrpcSession.java @@ -83,6 +83,8 @@ import org.thingsboard.server.gen.edge.v1.SyncCompletedMsg; import org.thingsboard.server.gen.edge.v1.UplinkMsg; import org.thingsboard.server.gen.edge.v1.UplinkResponseMsg; import org.thingsboard.server.gen.edge.v1.UserCredentialsRequestMsg; +import org.thingsboard.server.gen.edge.v1.UserCredentialsUpdateMsg; +import org.thingsboard.server.gen.edge.v1.UserUpdateMsg; import org.thingsboard.server.gen.edge.v1.WidgetBundleTypesRequestMsg; import org.thingsboard.server.service.edge.EdgeContextComponent; import org.thingsboard.server.service.edge.EdgeMsgConstructorUtils; @@ -100,6 +102,7 @@ import java.util.UUID; import java.util.concurrent.ConcurrentLinkedQueue; import java.util.concurrent.ScheduledExecutorService; import java.util.concurrent.TimeUnit; +import java.util.concurrent.locks.Lock; import java.util.concurrent.locks.ReentrantLock; import java.util.function.BiConsumer; @@ -121,6 +124,7 @@ public abstract class EdgeGrpcSession implements Closeable { private final EdgeSessionState sessionState = new EdgeSessionState(); private final ReentrantLock downlinkMsgLock = new ReentrantLock(); + private final Lock sequenceDependencyLock = new ReentrantLock(); protected EdgeContextComponent ctx; protected Edge edge; @@ -934,6 +938,26 @@ public abstract class EdgeGrpcSession implements Closeable { result.add(ctx.getCalculatedFieldProcessor().processCalculatedFieldMsgFromEdge(edge.getTenantId(), edge, calculatedFieldUpdateMsg)); } } + if (uplinkMsg.getUserUpdateMsgCount() > 0) { + for (UserUpdateMsg userUpdateMsg : uplinkMsg.getUserUpdateMsgList()) { + sequenceDependencyLock.lock(); + try { + result.add(ctx.getUserProcessor().processUserMsgFromEdge(edge.getTenantId(), edge, userUpdateMsg)); + } finally { + sequenceDependencyLock.unlock(); + } + } + } + if (uplinkMsg.getUserCredentialsUpdateMsgCount() > 0) { + for (UserCredentialsUpdateMsg userCredentialsUpdateMsg : uplinkMsg.getUserCredentialsUpdateMsgList()) { + sequenceDependencyLock.lock(); + try { + result.add(ctx.getUserProcessor().processUserCredentialsMsgFromEdge(edge.getTenantId(), edge, userCredentialsUpdateMsg)); + } finally { + sequenceDependencyLock.unlock(); + } + } + } } catch (Exception e) { String failureMsg = String.format("Can't process uplink msg [%s] from edge", uplinkMsg); log.trace("[{}][{}] Can't process uplink msg [{}]", tenantId, edge.getId(), uplinkMsg, e); diff --git a/application/src/main/java/org/thingsboard/server/service/edge/rpc/processor/user/BaseUserProcessor.java b/application/src/main/java/org/thingsboard/server/service/edge/rpc/processor/user/BaseUserProcessor.java new file mode 100644 index 0000000000..92d5664a4f --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/edge/rpc/processor/user/BaseUserProcessor.java @@ -0,0 +1,130 @@ +/** + * Copyright © 2016-2025 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.edge.rpc.processor.user; + +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.data.util.Pair; +import org.thingsboard.common.util.JacksonUtil; +import org.thingsboard.server.common.data.StringUtils; +import org.thingsboard.server.common.data.User; +import org.thingsboard.server.common.data.id.CustomerId; +import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.common.data.id.UserId; +import org.thingsboard.server.common.data.security.UserCredentials; +import org.thingsboard.server.dao.service.DataValidator; +import org.thingsboard.server.gen.edge.v1.UserCredentialsUpdateMsg; +import org.thingsboard.server.gen.edge.v1.UserUpdateMsg; +import org.thingsboard.server.service.edge.rpc.processor.BaseEdgeProcessor; + +@Slf4j +public abstract class BaseUserProcessor extends BaseEdgeProcessor { + + @Autowired + private DataValidator userValidator; + + protected Pair saveOrUpdateUser(TenantId tenantId, UserId userId, UserUpdateMsg userUpdateMsg) { + boolean isCreated = false; + boolean userEmailUpdated = false; + + try { + User user = JacksonUtil.fromString(userUpdateMsg.getEntity(), User.class, true); + if (user == null) { + throw new RuntimeException("[{" + tenantId + "}] userUpdateMsg {" + userUpdateMsg + "} cannot be converted to user"); + } + + User userById = edgeCtx.getUserService().findUserById(tenantId, userId); + if (userById == null) { + isCreated = true; + user.setId(null); + } else { + user.setId(userId); + } + + String userEmail = user.getEmail(); + User existing = edgeCtx.getUserService().findUserByTenantIdAndEmail(tenantId, user.getEmail()); + + if (existing != null && !existing.getId().equals(user.getId())) { + String[] splitEmail = userEmail.split("@"); + userEmail = splitEmail[0] + "_" + StringUtils.randomAlphanumeric(15) + "@" + splitEmail[1]; + log.warn("[{}] User with email {} already exists. Renaming User email to {}", + tenantId, user.getEmail(), userEmail); + userEmailUpdated = true; + } + user.setEmail(userEmail); + setCustomerId(tenantId, isCreated ? null : userById.getCustomerId(), user, userUpdateMsg); + + userValidator.validate(user, User::getTenantId); + + if (isCreated) { + user.setId(userId); + } + + edgeCtx.getUserService().saveUser(tenantId, user, false); + } catch (Exception e) { + log.error("[{}] Failed to process user update msg [{}]", tenantId, userUpdateMsg, e); + throw e; + } + + return Pair.of(isCreated, userEmailUpdated); + } + + protected void updateUserCredentials(TenantId tenantId, UserCredentialsUpdateMsg updateMsg) { + UserCredentials userCredentialsFromUpdateMsg = JacksonUtil.fromString(updateMsg.getEntity(), UserCredentials.class, true); + if (userCredentialsFromUpdateMsg == null) { + throw new RuntimeException(String.format("[%s] Failed to parse UserCredentials from updateMsg: %s", tenantId, updateMsg)); + } + + User user = edgeCtx.getUserService().findUserById(tenantId, userCredentialsFromUpdateMsg.getUserId()); + if (user == null) { + log.warn("[{}] Can't find user by id [{}] skipping credentials update. UserCredentialsUpdateMsg [{}]", + tenantId, userCredentialsFromUpdateMsg.getUserId(), updateMsg); + return; + } + + log.debug("[{}] Updating user credentials for user [{}]. New credentials Id [{}], enabled [{}]", + tenantId, user.getName(), userCredentialsFromUpdateMsg.getId(), userCredentialsFromUpdateMsg.isEnabled()); + + try { + UserCredentials existing = edgeCtx.getUserService().findUserCredentialsByUserId(tenantId, user.getId()); + boolean created = existing == null; + + UserCredentials updated = created ? new UserCredentials() : existing; + updated.setId(userCredentialsFromUpdateMsg.getId()); + updated.setUserId(user.getId()); + updated.setEnabled(userCredentialsFromUpdateMsg.isEnabled()); + updated.setActivateToken(userCredentialsFromUpdateMsg.getActivateToken()); + updated.setAdditionalInfo(userCredentialsFromUpdateMsg.getAdditionalInfo()); + updated.setPassword(userCredentialsFromUpdateMsg.getPassword()); + updated.setResetToken(userCredentialsFromUpdateMsg.getResetToken()); + + + if (created) { + edgeCtx.getUserService().saveUserCredentials(tenantId, updated, false); + } else { + edgeCtx.getUserService().replaceUserCredentials(tenantId, updated, existing.getId(), false); + } + } catch (Exception e) { + log.error("[{}] Can't update user credentials for user [{}], userCredentialsUpdateMsg [{}]", + tenantId, user.getName(), updateMsg, e); + throw new RuntimeException(e); + } + + } + + protected abstract void setCustomerId(TenantId tenantId, CustomerId customerId, User user, UserUpdateMsg userUpdateMsg); + +} diff --git a/application/src/main/java/org/thingsboard/server/service/edge/rpc/processor/user/UserEdgeProcessor.java b/application/src/main/java/org/thingsboard/server/service/edge/rpc/processor/user/UserEdgeProcessor.java index fdd03d63f3..e0bd07c6c6 100644 --- a/application/src/main/java/org/thingsboard/server/service/edge/rpc/processor/user/UserEdgeProcessor.java +++ b/application/src/main/java/org/thingsboard/server/service/edge/rpc/processor/user/UserEdgeProcessor.java @@ -15,26 +15,106 @@ */ package org.thingsboard.server.service.edge.rpc.processor.user; +import com.google.common.util.concurrent.Futures; +import com.google.common.util.concurrent.ListenableFuture; import lombok.extern.slf4j.Slf4j; +import org.springframework.data.util.Pair; import org.springframework.stereotype.Component; +import org.thingsboard.common.util.JacksonUtil; import org.thingsboard.server.common.data.EdgeUtils; import org.thingsboard.server.common.data.User; +import org.thingsboard.server.common.data.edge.Edge; import org.thingsboard.server.common.data.edge.EdgeEvent; +import org.thingsboard.server.common.data.edge.EdgeEventActionType; import org.thingsboard.server.common.data.edge.EdgeEventType; +import org.thingsboard.server.common.data.id.CustomerId; +import org.thingsboard.server.common.data.id.TenantId; import org.thingsboard.server.common.data.id.UserId; +import org.thingsboard.server.common.data.msg.TbMsgType; import org.thingsboard.server.common.data.security.UserCredentials; +import org.thingsboard.server.common.msg.TbMsgMetaData; +import org.thingsboard.server.dao.exception.DataValidationException; import org.thingsboard.server.gen.edge.v1.DownlinkMsg; import org.thingsboard.server.gen.edge.v1.EdgeVersion; import org.thingsboard.server.gen.edge.v1.UpdateMsgType; import org.thingsboard.server.gen.edge.v1.UserCredentialsUpdateMsg; +import org.thingsboard.server.gen.edge.v1.UserUpdateMsg; import org.thingsboard.server.queue.util.TbCoreComponent; import org.thingsboard.server.service.edge.EdgeMsgConstructorUtils; -import org.thingsboard.server.service.edge.rpc.processor.BaseEdgeProcessor; + +import java.util.UUID; @Slf4j @Component @TbCoreComponent -public class UserEdgeProcessor extends BaseEdgeProcessor { +public class UserEdgeProcessor extends BaseUserProcessor implements UserProcessor { + + @Override + public ListenableFuture processUserMsgFromEdge(TenantId tenantId, Edge edge, UserUpdateMsg userUpdateMsg) { + log.trace("[{}] executing processUserMsgFromEdge [{}] from edge [{}]", tenantId, userUpdateMsg, edge.getId()); + UserId userId = new UserId(new UUID(userUpdateMsg.getIdMSB(), userUpdateMsg.getIdLSB())); + try { + edgeSynchronizationManager.getEdgeId().set(edge.getId()); + + return switch (userUpdateMsg.getMsgType()) { + case ENTITY_CREATED_RPC_MESSAGE, ENTITY_UPDATED_RPC_MESSAGE -> { + saveOrUpdateUser(tenantId, userId, userUpdateMsg, edge); + yield Futures.immediateFuture(null); + } + default -> handleUnsupportedMsgType(userUpdateMsg.getMsgType()); + }; + } catch (DataValidationException e) { + if (e.getMessage().contains("limit reached")) { + log.warn("[{}] Number of allowed users violated {}", tenantId, userUpdateMsg, e); + return Futures.immediateFuture(null); + } else { + return Futures.immediateFailedFuture(e); + } + } finally { + edgeSynchronizationManager.getEdgeId().remove(); + } + } + + @Override + public ListenableFuture processUserCredentialsMsgFromEdge(TenantId tenantId, Edge edge, UserCredentialsUpdateMsg userCredentialsUpdateMsg) { + log.debug("[{}] Executing processUserCredentialsMsgFromEdge, userCredentialsUpdateMsg [{}]", tenantId, userCredentialsUpdateMsg); + try { + edgeSynchronizationManager.getEdgeId().set(edge.getId()); + + super.updateUserCredentials(tenantId, userCredentialsUpdateMsg); + } finally { + edgeSynchronizationManager.getEdgeId().remove(); + } + return Futures.immediateFuture(null); + } + + private void saveOrUpdateUser(TenantId tenantId, UserId userId, UserUpdateMsg userUpdateMsg, Edge edge) { + Pair resultPair = super.saveOrUpdateUser(tenantId, userId, userUpdateMsg); + boolean isCreated = resultPair.getFirst(); + if (isCreated) { + createRelationFromEdge(tenantId, edge.getId(), userId); + pushUserCreatedEventToRuleEngine(tenantId, edge, userId); + } + + Boolean userEmailUpdated = resultPair.getSecond(); + + if (userEmailUpdated) { + saveEdgeEvent(tenantId, edge.getId(), EdgeEventType.USER, EdgeEventActionType.UPDATED, userId, null); + } + } + + private void pushUserCreatedEventToRuleEngine(TenantId tenantId, Edge edge, UserId userId) { + try { + User user = edgeCtx.getUserService().findUserById(tenantId, userId); + if (user != null) { + String userAsString = JacksonUtil.toString(user); + TbMsgMetaData msgMetaData = getEdgeActionTbMsgMetaData(edge, user.getCustomerId()); + pushEntityEventToRuleEngine(tenantId, userId, user.getCustomerId(), TbMsgType.ENTITY_CREATED, userAsString, msgMetaData); + } + } catch (Exception e) { + log.warn("[{}][{}] Failed to push user action to rule engine: {}", tenantId, userId, TbMsgType.ENTITY_CREATED.name(), e); + } + } @Override public DownlinkMsg convertEdgeEventToDownlink(EdgeEvent edgeEvent, EdgeVersion edgeVersion) { @@ -48,7 +128,7 @@ public class UserEdgeProcessor extends BaseEdgeProcessor { .setDownlinkMsgId(EdgeUtils.nextPositiveInt()) .addUserUpdateMsg(EdgeMsgConstructorUtils.constructUserUpdatedMsg(msgType, user)); UserCredentials userCredentialsByUserId = edgeCtx.getUserService().findUserCredentialsByUserId(edgeEvent.getTenantId(), userId); - if (userCredentialsByUserId != null && userCredentialsByUserId.isEnabled()) { + if (userCredentialsByUserId != null) { builder.addUserCredentialsUpdateMsg(EdgeMsgConstructorUtils.constructUserCredentialsUpdatedMsg(userCredentialsByUserId)); } return builder.build(); @@ -62,11 +142,10 @@ public class UserEdgeProcessor extends BaseEdgeProcessor { } case CREDENTIALS_UPDATED -> { UserCredentials userCredentialsByUserId = edgeCtx.getUserService().findUserCredentialsByUserId(edgeEvent.getTenantId(), userId); - if (userCredentialsByUserId != null && userCredentialsByUserId.isEnabled()) { - UserCredentialsUpdateMsg userCredentialsUpdateMsg = EdgeMsgConstructorUtils.constructUserCredentialsUpdatedMsg(userCredentialsByUserId); + if (userCredentialsByUserId != null) { return DownlinkMsg.newBuilder() .setDownlinkMsgId(EdgeUtils.nextPositiveInt()) - .addUserCredentialsUpdateMsg(userCredentialsUpdateMsg) + .addUserCredentialsUpdateMsg(EdgeMsgConstructorUtils.constructUserCredentialsUpdatedMsg(userCredentialsByUserId)) .build(); } } @@ -79,4 +158,10 @@ public class UserEdgeProcessor extends BaseEdgeProcessor { return EdgeEventType.USER; } + @Override + protected void setCustomerId(TenantId tenantId, CustomerId customerId, User user, UserUpdateMsg userUpdateMsg) { + CustomerId customerUUID = user.getCustomerId() != null ? user.getCustomerId() : customerId; + user.setCustomerId(customerUUID); + } + } diff --git a/application/src/main/java/org/thingsboard/server/service/edge/rpc/processor/user/UserProcessor.java b/application/src/main/java/org/thingsboard/server/service/edge/rpc/processor/user/UserProcessor.java new file mode 100644 index 0000000000..dd9659c1f4 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/edge/rpc/processor/user/UserProcessor.java @@ -0,0 +1,31 @@ +/** + * Copyright © 2016-2025 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.edge.rpc.processor.user; + +import com.google.common.util.concurrent.ListenableFuture; +import org.thingsboard.server.common.data.edge.Edge; +import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.gen.edge.v1.UserCredentialsUpdateMsg; +import org.thingsboard.server.gen.edge.v1.UserUpdateMsg; +import org.thingsboard.server.service.edge.rpc.processor.EdgeProcessor; + +public interface UserProcessor extends EdgeProcessor { + + ListenableFuture processUserMsgFromEdge(TenantId tenantId, Edge edge, UserUpdateMsg userUpdateMsg); + + ListenableFuture processUserCredentialsMsgFromEdge(TenantId tenantId, Edge edge, UserCredentialsUpdateMsg userCredentialsUpdateMsg); + +} diff --git a/application/src/test/java/org/thingsboard/server/edge/UserEdgeTest.java b/application/src/test/java/org/thingsboard/server/edge/UserEdgeTest.java index 05debdc015..84442e2d0a 100644 --- a/application/src/test/java/org/thingsboard/server/edge/UserEdgeTest.java +++ b/application/src/test/java/org/thingsboard/server/edge/UserEdgeTest.java @@ -15,6 +15,7 @@ */ package org.thingsboard.server.edge; +import com.fasterxml.jackson.databind.JsonNode; import com.google.protobuf.AbstractMessage; import org.junit.Assert; import org.junit.Test; @@ -22,8 +23,12 @@ import org.springframework.beans.factory.annotation.Autowired; import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; import org.thingsboard.common.util.JacksonUtil; import org.thingsboard.server.common.data.Customer; +import org.thingsboard.server.common.data.StringUtils; import org.thingsboard.server.common.data.User; import org.thingsboard.server.common.data.edge.Edge; +import org.thingsboard.server.common.data.id.CustomerId; +import org.thingsboard.server.common.data.id.UserCredentialsId; +import org.thingsboard.server.common.data.id.UserId; import org.thingsboard.server.common.data.security.Authority; import org.thingsboard.server.common.data.security.UserCredentials; import org.thingsboard.server.dao.service.DaoSqlTest; @@ -32,11 +37,14 @@ import org.thingsboard.server.gen.edge.v1.UplinkMsg; import org.thingsboard.server.gen.edge.v1.UserCredentialsRequestMsg; import org.thingsboard.server.gen.edge.v1.UserCredentialsUpdateMsg; import org.thingsboard.server.gen.edge.v1.UserUpdateMsg; +import org.thingsboard.server.service.edge.EdgeMsgConstructorUtils; import org.thingsboard.server.service.security.model.ChangePasswordRequest; import java.util.Optional; +import java.util.UUID; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; +import static org.thingsboard.server.dao.user.UserServiceImpl.DEFAULT_TOKEN_LENGTH; @DaoSqlTest public class UserEdgeTest extends AbstractEdgeTest { @@ -44,46 +52,35 @@ public class UserEdgeTest extends AbstractEdgeTest { @Autowired private BCryptPasswordEncoder passwordEncoder; + private static final String DEFAULT_FIRST_NAME = "Boris"; + private static final String UPDATED_LAST_NAME = "Borisov"; + @Test public void testCreateUpdateDeleteTenantUser() throws Exception { // create user edgeImitator.expectMessageAmount(3); - User newTenantAdmin = new User(); - newTenantAdmin.setAuthority(Authority.TENANT_ADMIN); - newTenantAdmin.setTenantId(tenantId); - newTenantAdmin.setEmail("tenantAdmin@thingsboard.org"); - newTenantAdmin.setFirstName("Boris"); - newTenantAdmin.setLastName("Johnson"); + User newTenantAdmin = buildUser(Authority.TENANT_ADMIN, null, "tenantAdmin@thingsboard.org", DEFAULT_FIRST_NAME, "Johnson"); User savedTenantAdmin = createUser(newTenantAdmin, "tenant"); Assert.assertTrue(edgeImitator.waitForMessages()); // wait 3 messages - x1 user update msg and x2 user credentials update msgs (create + authenticate user) Assert.assertEquals(1, edgeImitator.findAllMessagesByType(UserUpdateMsg.class).size()); Assert.assertEquals(2, edgeImitator.findAllMessagesByType(UserCredentialsUpdateMsg.class).size()); - Optional userUpdateMsgOpt = edgeImitator.findMessageByType(UserUpdateMsg.class); - Assert.assertTrue(userUpdateMsgOpt.isPresent()); - UserUpdateMsg userUpdateMsg = userUpdateMsgOpt.get(); + + UserUpdateMsg userUpdateMsg = getLatestUserUpdateMsg(); User userMsg = JacksonUtil.fromString(userUpdateMsg.getEntity(), User.class, true); Assert.assertNotNull(userMsg); Assert.assertEquals(UpdateMsgType.ENTITY_CREATED_RPC_MESSAGE, userUpdateMsg.getMsgType()); - Assert.assertEquals(savedTenantAdmin.getId(), userMsg.getId()); - Assert.assertEquals(savedTenantAdmin.getAuthority(), userMsg.getAuthority()); - Assert.assertEquals(savedTenantAdmin.getEmail(), userMsg.getEmail()); - Assert.assertEquals(savedTenantAdmin.getFirstName(), userMsg.getFirstName()); - Assert.assertEquals(savedTenantAdmin.getLastName(), userMsg.getLastName()); - Optional userCredentialsUpdateMsgOpt = edgeImitator.findMessageByType(UserCredentialsUpdateMsg.class); - Assert.assertTrue(userCredentialsUpdateMsgOpt.isPresent()); // update user edgeImitator.expectMessageAmount(2); - savedTenantAdmin.setLastName("Borisov"); + savedTenantAdmin.setLastName(UPDATED_LAST_NAME); savedTenantAdmin = doPost("/api/user", savedTenantAdmin, User.class); Assert.assertTrue(edgeImitator.waitForMessages()); - userUpdateMsgOpt = edgeImitator.findMessageByType(UserUpdateMsg.class); - Assert.assertTrue(userUpdateMsgOpt.isPresent()); - userUpdateMsg = userUpdateMsgOpt.get(); - userMsg = JacksonUtil.fromString(userUpdateMsg.getEntity(), User.class, true); - Assert.assertNotNull(userMsg); + + userUpdateMsg = getLatestUserUpdateMsg(); + User userFromMsg = JacksonUtil.fromString(userUpdateMsg.getEntity(), User.class, true); + Assert.assertNotNull(userFromMsg); Assert.assertEquals(UpdateMsgType.ENTITY_UPDATED_RPC_MESSAGE, userUpdateMsg.getMsgType()); - Assert.assertEquals(savedTenantAdmin.getLastName(), userMsg.getLastName()); + Assert.assertEquals(UPDATED_LAST_NAME, userFromMsg.getLastName()); // update user credentials login(savedTenantAdmin.getEmail(), "tenant"); @@ -94,6 +91,7 @@ public class UserEdgeTest extends AbstractEdgeTest { changePasswordRequest.setNewPassword("newTenant"); doPost("/api/auth/changePassword", changePasswordRequest); Assert.assertTrue(edgeImitator.waitForMessages()); + AbstractMessage latestMessage = edgeImitator.getLatestMessage(); Assert.assertTrue(latestMessage instanceof UserCredentialsUpdateMsg); UserCredentialsUpdateMsg userCredentialsUpdateMsg = (UserCredentialsUpdateMsg) latestMessage; @@ -109,6 +107,7 @@ public class UserEdgeTest extends AbstractEdgeTest { doDelete("/api/user/" + savedTenantAdmin.getUuidId()) .andExpect(status().isOk()); Assert.assertTrue(edgeImitator.waitForMessages()); + latestMessage = edgeImitator.getLatestMessage(); Assert.assertTrue(latestMessage instanceof UserUpdateMsg); userUpdateMsg = (UserUpdateMsg) latestMessage; @@ -120,27 +119,11 @@ public class UserEdgeTest extends AbstractEdgeTest { @Test public void testCreateUpdateDeleteCustomerUser() throws Exception { // create customer - edgeImitator.expectMessageAmount(1); - Customer customer = new Customer(); - customer.setTitle("Edge Customer"); - Customer savedCustomer = doPost("/api/customer", customer, Customer.class); - Assert.assertFalse(edgeImitator.waitForMessages(5)); - - // assign edge to customer - edgeImitator.expectMessageAmount(2); - doPost("/api/customer/" + savedCustomer.getUuidId() - + "/edge/" + edge.getUuidId(), Edge.class); - Assert.assertTrue(edgeImitator.waitForMessages()); + Customer savedCustomer = createAndAssignCustomerToEdge("Edge Customer"); // create user edgeImitator.expectMessageAmount(3); - User customerUser = new User(); - customerUser.setAuthority(Authority.CUSTOMER_USER); - customerUser.setTenantId(tenantId); - customerUser.setCustomerId(savedCustomer.getId()); - customerUser.setEmail("customerUser@thingsboard.org"); - customerUser.setFirstName("John"); - customerUser.setLastName("Edwards"); + User customerUser = buildUser(Authority.CUSTOMER_USER, savedCustomer.getId(), "customerUser@thingsboard.org", "John", "Edwards"); User savedCustomerUser = createUser(customerUser, "customer"); Assert.assertTrue(edgeImitator.waitForMessages()); // wait 3 messages - x1 user update msg and x2 user credentials update msgs (create + authenticate user) Assert.assertEquals(1, edgeImitator.findAllMessagesByType(UserUpdateMsg.class).size()); @@ -205,32 +188,57 @@ public class UserEdgeTest extends AbstractEdgeTest { } @Test - public void testSendUserCredentialsRequestToCloud() throws Exception { - UplinkMsg.Builder uplinkMsgBuilder = UplinkMsg.newBuilder(); - UserCredentialsRequestMsg.Builder userCredentialsRequestMsgBuilder = UserCredentialsRequestMsg.newBuilder(); - userCredentialsRequestMsgBuilder.setUserIdMSB(tenantAdminUserId.getId().getMostSignificantBits()); - userCredentialsRequestMsgBuilder.setUserIdLSB(tenantAdminUserId.getId().getLeastSignificantBits()); - testAutoGeneratedCodeByProtobuf(userCredentialsRequestMsgBuilder); - uplinkMsgBuilder.addUserCredentialsRequestMsg(userCredentialsRequestMsgBuilder.build()); + public void testSendUserToCloudFromEdge() throws Exception { + // create customer + Customer savedCustomer = createAndAssignCustomerToEdge("Edge Customer"); - testAutoGeneratedCodeByProtobuf(uplinkMsgBuilder); + // create user + User customerUser = buildUser(Authority.CUSTOMER_USER, savedCustomer.getId(), "customerUser@thingsboard.org", DEFAULT_FIRST_NAME, "Johnson"); + + UUID uuid = UUID.randomUUID(); + customerUser.setId(new UserId(uuid)); + UUID userCredentialsUuid = UUID.randomUUID(); + UplinkMsg uplinkMsg = constructUserUplinkMsg(customerUser, UpdateMsgType.ENTITY_CREATED_RPC_MESSAGE, userCredentialsUuid); edgeImitator.expectResponsesAmount(1); - edgeImitator.expectMessageAmount(1); - edgeImitator.sendUplinkMsg(uplinkMsgBuilder.build()); + edgeImitator.sendUplinkMsg(uplinkMsg); Assert.assertTrue(edgeImitator.waitForResponses()); - Assert.assertTrue(edgeImitator.waitForMessages()); - AbstractMessage latestMessage = edgeImitator.getLatestMessage(); - Assert.assertTrue(latestMessage instanceof UserCredentialsUpdateMsg); - UserCredentialsUpdateMsg userCredentialsUpdateMsg = (UserCredentialsUpdateMsg) latestMessage; - UserCredentials userCredentialsMsg = JacksonUtil.fromString(userCredentialsUpdateMsg.getEntity(), UserCredentials.class, true); - Assert.assertNotNull(userCredentialsMsg); - Assert.assertEquals(tenantAdminUserId, userCredentialsMsg.getUserId()); + User userFromCloud = doGet("/api/user/" + uuid, User.class); + Assert.assertNotNull(userFromCloud); + Assert.assertEquals(customerUser.getEmail(), userFromCloud.getEmail()); + //check user with existing email + User userWithExistingEmail = buildUser(Authority.CUSTOMER_USER, savedCustomer.getId(), "customerUser@thingsboard.org", DEFAULT_FIRST_NAME, "Johnson"); + + UUID uuidForExistingEmail = UUID.randomUUID(); + userWithExistingEmail.setId(new UserId(uuidForExistingEmail)); + UUID userCredentialsUuidForExistingEmail = UUID.randomUUID(); + UplinkMsg uplinkMsgForExistingEmail = constructUserUplinkMsg(userWithExistingEmail, UpdateMsgType.ENTITY_CREATED_RPC_MESSAGE, userCredentialsUuidForExistingEmail); + + edgeImitator.expectResponsesAmount(1); + edgeImitator.sendUplinkMsg(uplinkMsgForExistingEmail); + Assert.assertTrue(edgeImitator.waitForResponses()); + + User userFromCloudWithExistingEmail = doGet("/api/user/" + uuidForExistingEmail, User.class); + Assert.assertNotNull(userFromCloudWithExistingEmail); + Assert.assertNotEquals(userWithExistingEmail.getEmail(), userFromCloudWithExistingEmail.getEmail()); + + assertUserCredentialsFlags(userFromCloud, false, false); + + UplinkMsg enabledCredentialsUplinkMsg = constructUserCredentialsUplinkMsg(customerUser.getId(), "password", true, userCredentialsUuid); + + edgeImitator.expectResponsesAmount(1); + edgeImitator.sendUplinkMsg(enabledCredentialsUplinkMsg); + Assert.assertTrue(edgeImitator.waitForResponses()); + + User cloudUserWithCredentials = doGet("/api/user/" + uuid, User.class); + Assert.assertNotNull(cloudUserWithCredentials); + + assertUserCredentialsFlags(cloudUserWithCredentials, true, true); } @Test - public void sendUserCredentialsRequest() throws Exception { + public void testSendUserCredentialsRequest() throws Exception { UplinkMsg.Builder uplinkMsgBuilder = UplinkMsg.newBuilder(); UserCredentialsRequestMsg.Builder userCredentialsRequestMsgBuilder = UserCredentialsRequestMsg.newBuilder(); userCredentialsRequestMsgBuilder.setUserIdMSB(tenantAdminUserId.getId().getMostSignificantBits()); @@ -256,4 +264,71 @@ public class UserEdgeTest extends AbstractEdgeTest { testAutoGeneratedCodeByProtobuf(userCredentialsUpdateMsg); } + private User buildUser(Authority authority, CustomerId customerId, String email, String firstName, String lastName) { + User customerUser = new User(); + customerUser.setAuthority(authority); + customerUser.setTenantId(tenantId); + customerUser.setCustomerId(customerId); + customerUser.setEmail(email); + customerUser.setFirstName(firstName); + customerUser.setLastName(lastName); + return customerUser; + } + + private Customer createAndAssignCustomerToEdge(String title) throws Exception { + edgeImitator.expectMessageAmount(1); + Customer customer = new Customer(); + customer.setTitle(title); + Customer savedCustomer = doPost("/api/customer", customer, Customer.class); + Assert.assertFalse(edgeImitator.waitForMessages(5)); + + edgeImitator.expectMessageAmount(2); + doPost("/api/customer/" + savedCustomer.getUuidId() + "/edge/" + edge.getUuidId(), Edge.class); + Assert.assertTrue(edgeImitator.waitForMessages()); + + return savedCustomer; + } + + private UplinkMsg constructUserUplinkMsg(User user, UpdateMsgType msgType, UUID userCredentialsUuid) { + UserUpdateMsg userUpdateMsg = EdgeMsgConstructorUtils.constructUserUpdatedMsg(msgType, user); + + UserCredentials userCredentials = new UserCredentials(); + userCredentials.setId(new UserCredentialsId(userCredentialsUuid)); + userCredentials.setUserId(user.getId()); + userCredentials.setEnabled(false); + userCredentials.setAdditionalInfo(JacksonUtil.newObjectNode()); + userCredentials.setActivateToken(StringUtils.randomAlphanumeric(DEFAULT_TOKEN_LENGTH)); + UserCredentialsUpdateMsg userCredentialsMsg = EdgeMsgConstructorUtils.constructUserCredentialsUpdatedMsg(userCredentials); + + return UplinkMsg.newBuilder() + .addUserUpdateMsg(userUpdateMsg) + .addUserCredentialsUpdateMsg(userCredentialsMsg) + .build(); + } + + private UplinkMsg constructUserCredentialsUplinkMsg(UserId userId, String password, boolean enabled, UUID userCredentialsUuid) { + UserCredentials userCredentials = new UserCredentials(); + userCredentials.setId(new UserCredentialsId(userCredentialsUuid)); + userCredentials.setUserId(userId); + userCredentials.setEnabled(enabled); + userCredentials.setPassword(password); + UserCredentialsUpdateMsg credsMsg = EdgeMsgConstructorUtils.constructUserCredentialsUpdatedMsg(userCredentials); + return UplinkMsg.newBuilder() + .addUserCredentialsUpdateMsg(credsMsg) + .build(); + } + + private void assertUserCredentialsFlags(User user, boolean enabled, boolean activated) { + JsonNode info = user.getAdditionalInfo(); + Assert.assertNotNull(info); + Assert.assertEquals(enabled, info.get("userCredentialsEnabled").asBoolean()); + Assert.assertEquals(activated, info.get("userActivated").asBoolean()); + } + + private UserUpdateMsg getLatestUserUpdateMsg() { + Optional opt = edgeImitator.findMessageByType(UserUpdateMsg.class); + Assert.assertTrue(opt.isPresent()); + return opt.get(); + } + } diff --git a/common/dao-api/src/main/java/org/thingsboard/server/dao/user/UserService.java b/common/dao-api/src/main/java/org/thingsboard/server/dao/user/UserService.java index c016631064..1c8943ad7a 100644 --- a/common/dao-api/src/main/java/org/thingsboard/server/dao/user/UserService.java +++ b/common/dao-api/src/main/java/org/thingsboard/server/dao/user/UserService.java @@ -45,6 +45,8 @@ public interface UserService extends EntityDaoService { User saveUser(TenantId tenantId, User user); + User saveUser(TenantId tenantId, User user, boolean doValidate); + UserCredentials findUserCredentialsByUserId(TenantId tenantId, UserId userId); UserCredentials findUserCredentialsByActivateToken(TenantId tenantId, String activateToken); @@ -53,6 +55,8 @@ public interface UserService extends EntityDaoService { UserCredentials saveUserCredentials(TenantId tenantId, UserCredentials userCredentials); + UserCredentials saveUserCredentials(TenantId tenantId, UserCredentials userCredentials, boolean doValidate); + UserCredentials activateUserCredentials(TenantId tenantId, String activateToken, String password); UserCredentials requestPasswordReset(TenantId tenantId, String email); @@ -67,6 +71,9 @@ public interface UserService extends EntityDaoService { UserCredentials replaceUserCredentials(TenantId tenantId, UserCredentials userCredentials); + UserCredentials replaceUserCredentials(TenantId tenantId, UserCredentials userCredentials, + UserCredentialsId oldUserCredentialsId, boolean doValidate); + void deleteUser(TenantId tenantId, User user); PageData findUsersByTenantId(TenantId tenantId, PageLink pageLink); diff --git a/common/edge-api/src/main/proto/edge.proto b/common/edge-api/src/main/proto/edge.proto index dbda462a99..7eddb326ef 100644 --- a/common/edge-api/src/main/proto/edge.proto +++ b/common/edge-api/src/main/proto/edge.proto @@ -441,6 +441,8 @@ message UplinkMsg { repeated RuleChainMetadataUpdateMsg ruleChainMetadataUpdateMsg = 24; repeated CalculatedFieldUpdateMsg calculatedFieldUpdateMsg = 25; repeated CalculatedFieldRequestMsg calculatedFieldRequestMsg = 26; + repeated UserUpdateMsg userUpdateMsg = 27; + repeated UserCredentialsUpdateMsg userCredentialsUpdateMsg = 28; } message UplinkResponseMsg { diff --git a/dao/src/main/java/org/thingsboard/server/dao/service/validator/UserDataValidator.java b/dao/src/main/java/org/thingsboard/server/dao/service/validator/UserDataValidator.java index 777ce4dfc8..64d0fc21b8 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/service/validator/UserDataValidator.java +++ b/dao/src/main/java/org/thingsboard/server/dao/service/validator/UserDataValidator.java @@ -136,4 +136,5 @@ public class UserDataValidator extends DataValidator { } } } + } diff --git a/dao/src/main/java/org/thingsboard/server/dao/user/UserServiceImpl.java b/dao/src/main/java/org/thingsboard/server/dao/user/UserServiceImpl.java index 5c94ba1891..8c229c815d 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/user/UserServiceImpl.java +++ b/dao/src/main/java/org/thingsboard/server/dao/user/UserServiceImpl.java @@ -70,6 +70,7 @@ import java.util.List; import java.util.Map; import java.util.Objects; import java.util.Optional; +import java.util.UUID; import java.util.concurrent.TimeUnit; import static org.thingsboard.server.common.data.StringUtils.generateSafeToken; @@ -84,7 +85,7 @@ public class UserServiceImpl extends AbstractCachedEntityService tenantId); + if (doValidate) { + userCredentialsValidator.validate(userCredentials, data -> tenantId); + } UserCredentials result = userCredentialsDao.save(tenantId, userCredentials); eventPublisher.publishEvent(ActionEntityEvent.builder() .tenantId(tenantId) @@ -304,19 +324,44 @@ public class UserServiceImpl extends AbstractCachedEntityService tenantId); - userCredentialsDao.removeById(tenantId, userCredentials.getUuidId()); - userCredentials.setId(null); - if (userCredentials.getPassword() != null) { - updatePasswordHistory(userCredentials); + return replaceUserCredentialsInternal(tenantId, userCredentials, userCredentials.getUuidId(), true); + } + + @Override + public UserCredentials replaceUserCredentials(TenantId tenantId, UserCredentials userCredentials, + UserCredentialsId oldUserCredentialsId, boolean doValidate) { + return replaceUserCredentialsInternal(tenantId, userCredentials, oldUserCredentialsId.getId(), doValidate); + } + + private UserCredentials replaceUserCredentialsInternal(TenantId tenantId, UserCredentials userCredentials, + UUID oldCredentialsUuid, boolean doValidate) { + log.trace("[{}] Replacing user credentials for user [{}], old credentials ID [{}]", + tenantId, userCredentials.getUserId(), oldCredentialsUuid); + + if (doValidate) { + userCredentialsValidator.validate(userCredentials, data -> tenantId); + } + + try { + userCredentialsDao.removeById(tenantId, oldCredentialsUuid); + + if (userCredentials.getPassword() != null) { + updatePasswordHistory(userCredentials); + } + + UserCredentials savedCredentials = userCredentialsDao.save(tenantId, userCredentials); + + eventPublisher.publishEvent(ActionEntityEvent.builder() + .tenantId(tenantId) + .entityId(userCredentials.getUserId()) + .actionType(ActionType.CREDENTIALS_UPDATED) + .build()); + + return savedCredentials; + } catch (Exception e) { + log.error("[{}] Failed to replace user credentials for user [{}]", tenantId, userCredentials.getUserId(), e); + throw new RuntimeException("Failed to replace user credentials", e); } - UserCredentials result = userCredentialsDao.save(tenantId, userCredentials); - eventPublisher.publishEvent(ActionEntityEvent.builder() - .tenantId(tenantId) - .entityId(userCredentials.getUserId()) - .actionType(ActionType.CREDENTIALS_UPDATED).build()); - return result; } @Override From c1b5febc754a4da4bfb14c496d7cb87ead67b709 Mon Sep 17 00:00:00 2001 From: Yevhenii Date: Wed, 8 Oct 2025 14:53:10 +0300 Subject: [PATCH 02/54] Refactor --- .../rpc/processor/user/BaseUserProcessor.java | 13 +- .../rpc/processor/user/UserEdgeProcessor.java | 2 +- .../thingsboard/server/edge/UserEdgeTest.java | 311 +++++++++--------- .../service/validator/UserDataValidator.java | 1 - .../server/dao/user/UserServiceImpl.java | 3 +- 5 files changed, 153 insertions(+), 177 deletions(-) diff --git a/application/src/main/java/org/thingsboard/server/service/edge/rpc/processor/user/BaseUserProcessor.java b/application/src/main/java/org/thingsboard/server/service/edge/rpc/processor/user/BaseUserProcessor.java index 92d5664a4f..c836c14233 100644 --- a/application/src/main/java/org/thingsboard/server/service/edge/rpc/processor/user/BaseUserProcessor.java +++ b/application/src/main/java/org/thingsboard/server/service/edge/rpc/processor/user/BaseUserProcessor.java @@ -43,16 +43,12 @@ public abstract class BaseUserProcessor extends BaseEdgeProcessor { try { User user = JacksonUtil.fromString(userUpdateMsg.getEntity(), User.class, true); if (user == null) { - throw new RuntimeException("[{" + tenantId + "}] userUpdateMsg {" + userUpdateMsg + "} cannot be converted to user"); + throw new IllegalArgumentException(String.format("[%s] Failed to parse User from UserUpdateMsg: %s", tenantId, userUpdateMsg)); } User userById = edgeCtx.getUserService().findUserById(tenantId, userId); - if (userById == null) { - isCreated = true; - user.setId(null); - } else { - user.setId(userId); - } + isCreated = userById == null; + user.setId(isCreated ? null : userId); String userEmail = user.getEmail(); User existing = edgeCtx.getUserService().findUserByTenantIdAndEmail(tenantId, user.getEmail()); @@ -85,7 +81,7 @@ public abstract class BaseUserProcessor extends BaseEdgeProcessor { protected void updateUserCredentials(TenantId tenantId, UserCredentialsUpdateMsg updateMsg) { UserCredentials userCredentialsFromUpdateMsg = JacksonUtil.fromString(updateMsg.getEntity(), UserCredentials.class, true); if (userCredentialsFromUpdateMsg == null) { - throw new RuntimeException(String.format("[%s] Failed to parse UserCredentials from updateMsg: %s", tenantId, updateMsg)); + throw new IllegalArgumentException(String.format("[%s] Failed to parse UserCredentials from updateMsg: %s", tenantId, updateMsg)); } User user = edgeCtx.getUserService().findUserById(tenantId, userCredentialsFromUpdateMsg.getUserId()); @@ -111,7 +107,6 @@ public abstract class BaseUserProcessor extends BaseEdgeProcessor { updated.setPassword(userCredentialsFromUpdateMsg.getPassword()); updated.setResetToken(userCredentialsFromUpdateMsg.getResetToken()); - if (created) { edgeCtx.getUserService().saveUserCredentials(tenantId, updated, false); } else { diff --git a/application/src/main/java/org/thingsboard/server/service/edge/rpc/processor/user/UserEdgeProcessor.java b/application/src/main/java/org/thingsboard/server/service/edge/rpc/processor/user/UserEdgeProcessor.java index e0bd07c6c6..e7813caae9 100644 --- a/application/src/main/java/org/thingsboard/server/service/edge/rpc/processor/user/UserEdgeProcessor.java +++ b/application/src/main/java/org/thingsboard/server/service/edge/rpc/processor/user/UserEdgeProcessor.java @@ -96,7 +96,7 @@ public class UserEdgeProcessor extends BaseUserProcessor implements UserProcesso pushUserCreatedEventToRuleEngine(tenantId, edge, userId); } - Boolean userEmailUpdated = resultPair.getSecond(); + boolean userEmailUpdated = resultPair.getSecond(); if (userEmailUpdated) { saveEdgeEvent(tenantId, edge.getId(), EdgeEventType.USER, EdgeEventActionType.UPDATED, userId, null); diff --git a/application/src/test/java/org/thingsboard/server/edge/UserEdgeTest.java b/application/src/test/java/org/thingsboard/server/edge/UserEdgeTest.java index 84442e2d0a..3e8abd645c 100644 --- a/application/src/test/java/org/thingsboard/server/edge/UserEdgeTest.java +++ b/application/src/test/java/org/thingsboard/server/edge/UserEdgeTest.java @@ -53,188 +53,75 @@ public class UserEdgeTest extends AbstractEdgeTest { private BCryptPasswordEncoder passwordEncoder; private static final String DEFAULT_FIRST_NAME = "Boris"; + private static final String DEFAULT_LAST_NAME = "Johnson"; private static final String UPDATED_LAST_NAME = "Borisov"; + private static final String DEFAULT_TENANT_ADMIN_EMAIL = "tenantAdmin@thingsboard.org"; + private static final String DEFAULT_CUSTOMER_USER_EMAIL = "customerUser@thingsboard.org"; @Test public void testCreateUpdateDeleteTenantUser() throws Exception { // create user - edgeImitator.expectMessageAmount(3); - User newTenantAdmin = buildUser(Authority.TENANT_ADMIN, null, "tenantAdmin@thingsboard.org", DEFAULT_FIRST_NAME, "Johnson"); - User savedTenantAdmin = createUser(newTenantAdmin, "tenant"); - Assert.assertTrue(edgeImitator.waitForMessages()); // wait 3 messages - x1 user update msg and x2 user credentials update msgs (create + authenticate user) - Assert.assertEquals(1, edgeImitator.findAllMessagesByType(UserUpdateMsg.class).size()); - Assert.assertEquals(2, edgeImitator.findAllMessagesByType(UserCredentialsUpdateMsg.class).size()); - - UserUpdateMsg userUpdateMsg = getLatestUserUpdateMsg(); - User userMsg = JacksonUtil.fromString(userUpdateMsg.getEntity(), User.class, true); - Assert.assertNotNull(userMsg); - Assert.assertEquals(UpdateMsgType.ENTITY_CREATED_RPC_MESSAGE, userUpdateMsg.getMsgType()); + User newTenantAdmin = buildUser(Authority.TENANT_ADMIN, null); + User savedTenantAdmin = createAndVerifyUserOnEdge(newTenantAdmin); // update user - edgeImitator.expectMessageAmount(2); - savedTenantAdmin.setLastName(UPDATED_LAST_NAME); - savedTenantAdmin = doPost("/api/user", savedTenantAdmin, User.class); - Assert.assertTrue(edgeImitator.waitForMessages()); - - userUpdateMsg = getLatestUserUpdateMsg(); - User userFromMsg = JacksonUtil.fromString(userUpdateMsg.getEntity(), User.class, true); - Assert.assertNotNull(userFromMsg); - Assert.assertEquals(UpdateMsgType.ENTITY_UPDATED_RPC_MESSAGE, userUpdateMsg.getMsgType()); - Assert.assertEquals(UPDATED_LAST_NAME, userFromMsg.getLastName()); + updateAndVerifyUserLastName(savedTenantAdmin); // update user credentials login(savedTenantAdmin.getEmail(), "tenant"); - - edgeImitator.expectMessageAmount(1); - ChangePasswordRequest changePasswordRequest = new ChangePasswordRequest(); - changePasswordRequest.setCurrentPassword("tenant"); - changePasswordRequest.setNewPassword("newTenant"); - doPost("/api/auth/changePassword", changePasswordRequest); - Assert.assertTrue(edgeImitator.waitForMessages()); - - AbstractMessage latestMessage = edgeImitator.getLatestMessage(); - Assert.assertTrue(latestMessage instanceof UserCredentialsUpdateMsg); - UserCredentialsUpdateMsg userCredentialsUpdateMsg = (UserCredentialsUpdateMsg) latestMessage; - UserCredentials userCredentialsMsg = JacksonUtil.fromString(userCredentialsUpdateMsg.getEntity(), UserCredentials.class, true); - Assert.assertNotNull(userCredentialsMsg); - Assert.assertEquals(savedTenantAdmin.getId(), userCredentialsMsg.getUserId()); - Assert.assertTrue(passwordEncoder.matches(changePasswordRequest.getNewPassword(), userCredentialsMsg.getPassword())); - + updateAndVerifyUserCredentials(savedTenantAdmin); loginTenantAdmin(); // delete user - edgeImitator.expectMessageAmount(1); - doDelete("/api/user/" + savedTenantAdmin.getUuidId()) - .andExpect(status().isOk()); - Assert.assertTrue(edgeImitator.waitForMessages()); - - latestMessage = edgeImitator.getLatestMessage(); - Assert.assertTrue(latestMessage instanceof UserUpdateMsg); - userUpdateMsg = (UserUpdateMsg) latestMessage; - Assert.assertEquals(UpdateMsgType.ENTITY_DELETED_RPC_MESSAGE, userUpdateMsg.getMsgType()); - Assert.assertEquals(savedTenantAdmin.getUuidId().getMostSignificantBits(), userUpdateMsg.getIdMSB()); - Assert.assertEquals(savedTenantAdmin.getUuidId().getLeastSignificantBits(), userUpdateMsg.getIdLSB()); + deleteAndVerifyUser(savedTenantAdmin); } @Test public void testCreateUpdateDeleteCustomerUser() throws Exception { // create customer - Customer savedCustomer = createAndAssignCustomerToEdge("Edge Customer"); + Customer savedCustomer = createAndAssignCustomerToEdge(); // create user - edgeImitator.expectMessageAmount(3); - User customerUser = buildUser(Authority.CUSTOMER_USER, savedCustomer.getId(), "customerUser@thingsboard.org", "John", "Edwards"); - User savedCustomerUser = createUser(customerUser, "customer"); - Assert.assertTrue(edgeImitator.waitForMessages()); // wait 3 messages - x1 user update msg and x2 user credentials update msgs (create + authenticate user) - Assert.assertEquals(1, edgeImitator.findAllMessagesByType(UserUpdateMsg.class).size()); - Assert.assertEquals(2, edgeImitator.findAllMessagesByType(UserCredentialsUpdateMsg.class).size()); - Optional userUpdateMsgOpt = edgeImitator.findMessageByType(UserUpdateMsg.class); - Assert.assertTrue(userUpdateMsgOpt.isPresent()); - UserUpdateMsg userUpdateMsg = userUpdateMsgOpt.get(); - User userMsg = JacksonUtil.fromString(userUpdateMsg.getEntity(), User.class, true); - Assert.assertNotNull(userMsg); - Assert.assertEquals(UpdateMsgType.ENTITY_CREATED_RPC_MESSAGE, userUpdateMsg.getMsgType()); - Assert.assertEquals(savedCustomerUser.getId(), userMsg.getId()); - Assert.assertEquals(savedCustomerUser.getCustomerId(), userMsg.getCustomerId()); - Assert.assertEquals(savedCustomerUser.getAuthority(), userMsg.getAuthority()); - Assert.assertEquals(savedCustomerUser.getEmail(), userMsg.getEmail()); - Assert.assertEquals(savedCustomerUser.getFirstName(), userMsg.getFirstName()); - Assert.assertEquals(savedCustomerUser.getLastName(), userMsg.getLastName()); + User customerUser = buildUser(Authority.CUSTOMER_USER, savedCustomer.getId()); + User savedCustomerUser = createAndVerifyUserOnEdge(customerUser); // update user - edgeImitator.expectMessageAmount(2); - savedCustomerUser.setLastName("Addams"); - savedCustomerUser = doPost("/api/user", savedCustomerUser, User.class); - Assert.assertTrue(edgeImitator.waitForMessages()); - userUpdateMsgOpt = edgeImitator.findMessageByType(UserUpdateMsg.class); - Assert.assertTrue(userUpdateMsgOpt.isPresent()); - userUpdateMsg = userUpdateMsgOpt.get(); - userMsg = JacksonUtil.fromString(userUpdateMsg.getEntity(), User.class, true); - Assert.assertNotNull(userMsg); - Assert.assertEquals(UpdateMsgType.ENTITY_UPDATED_RPC_MESSAGE, userUpdateMsg.getMsgType()); - Assert.assertEquals(savedCustomerUser.getLastName(), userMsg.getLastName()); + updateAndVerifyUserLastName(savedCustomerUser); // update user credentials login(savedCustomerUser.getEmail(), "customer"); - - edgeImitator.expectMessageAmount(1); - ChangePasswordRequest changePasswordRequest = new ChangePasswordRequest(); - changePasswordRequest.setCurrentPassword("customer"); - changePasswordRequest.setNewPassword("newCustomer"); - doPost("/api/auth/changePassword", changePasswordRequest); - Assert.assertTrue(edgeImitator.waitForMessages()); - AbstractMessage latestMessage = edgeImitator.getLatestMessage(); - Assert.assertTrue(latestMessage instanceof UserCredentialsUpdateMsg); - UserCredentialsUpdateMsg userCredentialsUpdateMsg = (UserCredentialsUpdateMsg) latestMessage; - UserCredentials userCredentialsMsg = JacksonUtil.fromString(userCredentialsUpdateMsg.getEntity(), UserCredentials.class, true); - Assert.assertNotNull(userCredentialsMsg); - Assert.assertEquals(savedCustomerUser.getId(), userCredentialsMsg.getUserId()); - Assert.assertTrue(passwordEncoder.matches(changePasswordRequest.getNewPassword(), userCredentialsMsg.getPassword())); - + updateAndVerifyUserCredentials(savedCustomerUser); loginTenantAdmin(); // delete user - edgeImitator.expectMessageAmount(1); - doDelete("/api/user/" + savedCustomerUser.getUuidId()) - .andExpect(status().isOk()); - Assert.assertTrue(edgeImitator.waitForMessages()); - latestMessage = edgeImitator.getLatestMessage(); - Assert.assertTrue(latestMessage instanceof UserUpdateMsg); - userUpdateMsg = (UserUpdateMsg) latestMessage; - Assert.assertEquals(UpdateMsgType.ENTITY_DELETED_RPC_MESSAGE, userUpdateMsg.getMsgType()); - Assert.assertEquals(savedCustomerUser.getUuidId().getMostSignificantBits(), userUpdateMsg.getIdMSB()); - Assert.assertEquals(savedCustomerUser.getUuidId().getLeastSignificantBits(), userUpdateMsg.getIdLSB()); - + deleteAndVerifyUser(savedCustomerUser); } @Test public void testSendUserToCloudFromEdge() throws Exception { // create customer - Customer savedCustomer = createAndAssignCustomerToEdge("Edge Customer"); - - // create user - User customerUser = buildUser(Authority.CUSTOMER_USER, savedCustomer.getId(), "customerUser@thingsboard.org", DEFAULT_FIRST_NAME, "Johnson"); - - UUID uuid = UUID.randomUUID(); - customerUser.setId(new UserId(uuid)); - UUID userCredentialsUuid = UUID.randomUUID(); - UplinkMsg uplinkMsg = constructUserUplinkMsg(customerUser, UpdateMsgType.ENTITY_CREATED_RPC_MESSAGE, userCredentialsUuid); + Customer savedCustomer = createAndAssignCustomerToEdge(); - edgeImitator.expectResponsesAmount(1); - edgeImitator.sendUplinkMsg(uplinkMsg); - Assert.assertTrue(edgeImitator.waitForResponses()); - - User userFromCloud = doGet("/api/user/" + uuid, User.class); - Assert.assertNotNull(userFromCloud); - Assert.assertEquals(customerUser.getEmail(), userFromCloud.getEmail()); - //check user with existing email - User userWithExistingEmail = buildUser(Authority.CUSTOMER_USER, savedCustomer.getId(), "customerUser@thingsboard.org", DEFAULT_FIRST_NAME, "Johnson"); - - UUID uuidForExistingEmail = UUID.randomUUID(); - userWithExistingEmail.setId(new UserId(uuidForExistingEmail)); - UUID userCredentialsUuidForExistingEmail = UUID.randomUUID(); - UplinkMsg uplinkMsgForExistingEmail = constructUserUplinkMsg(userWithExistingEmail, UpdateMsgType.ENTITY_CREATED_RPC_MESSAGE, userCredentialsUuidForExistingEmail); - - edgeImitator.expectResponsesAmount(1); - edgeImitator.sendUplinkMsg(uplinkMsgForExistingEmail); - Assert.assertTrue(edgeImitator.waitForResponses()); - - User userFromCloudWithExistingEmail = doGet("/api/user/" + uuidForExistingEmail, User.class); - Assert.assertNotNull(userFromCloudWithExistingEmail); - Assert.assertNotEquals(userWithExistingEmail.getEmail(), userFromCloudWithExistingEmail.getEmail()); + // create uplinkMsg with user and userCredentials + UserId userId = new UserId(UUID.randomUUID()); + UserCredentialsId userCredentialsId = new UserCredentialsId(UUID.randomUUID()); + UplinkMsg uplinkMsg = buildUserUplinkMsg(userId, savedCustomer.getId(), userCredentialsId); + User userFromCloud = verifyMsgOnCloud(uplinkMsg, userId, false); assertUserCredentialsFlags(userFromCloud, false, false); - UplinkMsg enabledCredentialsUplinkMsg = constructUserCredentialsUplinkMsg(customerUser.getId(), "password", true, userCredentialsUuid); + // create uplinkMsg with enabled userCredentials + UplinkMsg uplinkMsgWithEnabledCredentials = constructUserCredentialsUplinkMsg(userCredentialsId, userId); - edgeImitator.expectResponsesAmount(1); - edgeImitator.sendUplinkMsg(enabledCredentialsUplinkMsg); - Assert.assertTrue(edgeImitator.waitForResponses()); + User cloudUserWithCredentials = verifyMsgOnCloud(uplinkMsgWithEnabledCredentials, userId, false); + assertUserCredentialsFlags(cloudUserWithCredentials, true, true); - User cloudUserWithCredentials = doGet("/api/user/" + uuid, User.class); - Assert.assertNotNull(cloudUserWithCredentials); + // create uplinkMsg with user the same email + UserId secondUserId = new UserId(UUID.randomUUID()); + UserCredentialsId secondCredentialsId = new UserCredentialsId(UUID.randomUUID()); + UplinkMsg uplinkMsgForUserExistingEmail = buildUserUplinkMsg(secondUserId, savedCustomer.getId(), secondCredentialsId); - assertUserCredentialsFlags(cloudUserWithCredentials, true, true); + verifyMsgOnCloud(uplinkMsgForUserExistingEmail, secondUserId, true); } @Test @@ -264,40 +151,111 @@ public class UserEdgeTest extends AbstractEdgeTest { testAutoGeneratedCodeByProtobuf(userCredentialsUpdateMsg); } - private User buildUser(Authority authority, CustomerId customerId, String email, String firstName, String lastName) { + private Customer createAndAssignCustomerToEdge() throws Exception { + edgeImitator.expectMessageAmount(1); + Customer customer = new Customer(); + customer.setTitle("Edge Customer"); + Customer savedCustomer = doPost("/api/customer", customer, Customer.class); + Assert.assertFalse(edgeImitator.waitForMessages(5)); + + edgeImitator.expectMessageAmount(2); + doPost("/api/customer/" + savedCustomer.getUuidId() + "/edge/" + edge.getUuidId(), Edge.class); + Assert.assertTrue(edgeImitator.waitForMessages()); + + return savedCustomer; + } + + private User buildUser(Authority authority, CustomerId customerId) { User customerUser = new User(); + customerUser.setAuthority(authority); customerUser.setTenantId(tenantId); customerUser.setCustomerId(customerId); - customerUser.setEmail(email); - customerUser.setFirstName(firstName); - customerUser.setLastName(lastName); + customerUser.setEmail(authority == Authority.TENANT_ADMIN ? DEFAULT_TENANT_ADMIN_EMAIL : DEFAULT_CUSTOMER_USER_EMAIL); + customerUser.setFirstName(DEFAULT_FIRST_NAME); + customerUser.setLastName(DEFAULT_LAST_NAME); + return customerUser; } - private Customer createAndAssignCustomerToEdge(String title) throws Exception { - edgeImitator.expectMessageAmount(1); - Customer customer = new Customer(); - customer.setTitle(title); - Customer savedCustomer = doPost("/api/customer", customer, Customer.class); - Assert.assertFalse(edgeImitator.waitForMessages(5)); + private User createAndVerifyUserOnEdge(User user) throws Exception { + // wait 3 messages - x1 user update msg and x2 user credentials update msgs (create + authenticate user) + edgeImitator.expectMessageAmount(3); + User savedUser = createUser(user, user.getAuthority() == Authority.TENANT_ADMIN ? "tenant" : "customer"); + Assert.assertTrue(edgeImitator.waitForMessages()); + + Assert.assertEquals(1, edgeImitator.findAllMessagesByType(UserUpdateMsg.class).size()); + Assert.assertEquals(2, edgeImitator.findAllMessagesByType(UserCredentialsUpdateMsg.class).size()); + + UserUpdateMsg userUpdateMsg = getLatestUserUpdateMsg(); + User userMsg = JacksonUtil.fromString(userUpdateMsg.getEntity(), User.class, true); + Assert.assertNotNull(userMsg); + Assert.assertEquals(UpdateMsgType.ENTITY_CREATED_RPC_MESSAGE, userUpdateMsg.getMsgType()); + Assert.assertEquals(savedUser.getId(), userMsg.getId()); + Assert.assertEquals(savedUser.getCustomerId(), userMsg.getCustomerId()); + Assert.assertEquals(savedUser.getAuthority(), userMsg.getAuthority()); + Assert.assertEquals(savedUser.getEmail(), userMsg.getEmail()); + Assert.assertEquals(savedUser.getFirstName(), userMsg.getFirstName()); + Assert.assertEquals(savedUser.getLastName(), userMsg.getLastName()); + + return savedUser; + } + + private void updateAndVerifyUserLastName(User user) throws Exception { + user.setLastName(UPDATED_LAST_NAME); edgeImitator.expectMessageAmount(2); - doPost("/api/customer/" + savedCustomer.getUuidId() + "/edge/" + edge.getUuidId(), Edge.class); + doPost("/api/user", user, User.class); Assert.assertTrue(edgeImitator.waitForMessages()); - return savedCustomer; + UserUpdateMsg userUpdateMsg = getLatestUserUpdateMsg(); + User userFromMsg = JacksonUtil.fromString(userUpdateMsg.getEntity(), User.class, true); + Assert.assertNotNull(userFromMsg); + Assert.assertEquals(UpdateMsgType.ENTITY_UPDATED_RPC_MESSAGE, userUpdateMsg.getMsgType()); + Assert.assertEquals(UPDATED_LAST_NAME, userFromMsg.getLastName()); } - private UplinkMsg constructUserUplinkMsg(User user, UpdateMsgType msgType, UUID userCredentialsUuid) { - UserUpdateMsg userUpdateMsg = EdgeMsgConstructorUtils.constructUserUpdatedMsg(msgType, user); + private void updateAndVerifyUserCredentials(User user) throws Exception { + String password = user.getAuthority() == Authority.TENANT_ADMIN ? "tenant" : "customer"; + String newPassword = "new" + password; - UserCredentials userCredentials = new UserCredentials(); - userCredentials.setId(new UserCredentialsId(userCredentialsUuid)); - userCredentials.setUserId(user.getId()); - userCredentials.setEnabled(false); - userCredentials.setAdditionalInfo(JacksonUtil.newObjectNode()); + edgeImitator.expectMessageAmount(1); + ChangePasswordRequest changePasswordRequest = new ChangePasswordRequest(); + changePasswordRequest.setCurrentPassword(password); + changePasswordRequest.setNewPassword(newPassword); + doPost("/api/auth/changePassword", changePasswordRequest); + Assert.assertTrue(edgeImitator.waitForMessages()); + + AbstractMessage latestMessage = edgeImitator.getLatestMessage(); + Assert.assertTrue(latestMessage instanceof UserCredentialsUpdateMsg); + UserCredentialsUpdateMsg msg = (UserCredentialsUpdateMsg) latestMessage; + UserCredentials creds = JacksonUtil.fromString(msg.getEntity(), UserCredentials.class, true); + Assert.assertNotNull(creds); + Assert.assertEquals(user.getId(), creds.getUserId()); + Assert.assertTrue(passwordEncoder.matches(newPassword, creds.getPassword())); + } + + private void deleteAndVerifyUser(User savedTenantAdmin) throws Exception { + edgeImitator.expectMessageAmount(1); + doDelete("/api/user/" + savedTenantAdmin.getUuidId()) + .andExpect(status().isOk()); + Assert.assertTrue(edgeImitator.waitForMessages()); + + AbstractMessage latestMessage = edgeImitator.getLatestMessage(); + Assert.assertTrue(latestMessage instanceof UserUpdateMsg); + UserUpdateMsg userUpdateMsg = (UserUpdateMsg) latestMessage; + Assert.assertEquals(UpdateMsgType.ENTITY_DELETED_RPC_MESSAGE, userUpdateMsg.getMsgType()); + Assert.assertEquals(savedTenantAdmin.getUuidId().getMostSignificantBits(), userUpdateMsg.getIdMSB()); + Assert.assertEquals(savedTenantAdmin.getUuidId().getLeastSignificantBits(), userUpdateMsg.getIdLSB()); + } + + private UplinkMsg buildUserUplinkMsg(UserId userId, CustomerId customerId, UserCredentialsId userCredentialsUuid) { + User customerUser = buildUser(Authority.CUSTOMER_USER, customerId); + customerUser.setId(userId); + UserCredentials userCredentials = buildCredentials(userCredentialsUuid, userId, false); userCredentials.setActivateToken(StringUtils.randomAlphanumeric(DEFAULT_TOKEN_LENGTH)); + + UserUpdateMsg userUpdateMsg = EdgeMsgConstructorUtils.constructUserUpdatedMsg(UpdateMsgType.ENTITY_CREATED_RPC_MESSAGE, customerUser); UserCredentialsUpdateMsg userCredentialsMsg = EdgeMsgConstructorUtils.constructUserCredentialsUpdatedMsg(userCredentials); return UplinkMsg.newBuilder() @@ -306,13 +264,38 @@ public class UserEdgeTest extends AbstractEdgeTest { .build(); } - private UplinkMsg constructUserCredentialsUplinkMsg(UserId userId, String password, boolean enabled, UUID userCredentialsUuid) { + private UserCredentials buildCredentials(UserCredentialsId userCredentialsUuid, UserId userId, boolean enabled) { UserCredentials userCredentials = new UserCredentials(); - userCredentials.setId(new UserCredentialsId(userCredentialsUuid)); + + userCredentials.setId(userCredentialsUuid); userCredentials.setUserId(userId); userCredentials.setEnabled(enabled); - userCredentials.setPassword(password); + userCredentials.setAdditionalInfo(JacksonUtil.newObjectNode()); + + return userCredentials; + } + + private User verifyMsgOnCloud(UplinkMsg uplinkMsg, UserId userId, boolean emailExist) throws Exception { + edgeImitator.expectResponsesAmount(1); + edgeImitator.sendUplinkMsg(uplinkMsg); + Assert.assertTrue(edgeImitator.waitForResponses()); + + User userFromCloud = doGet("/api/user/" + userId, User.class); + Assert.assertNotNull(userFromCloud); + + if (emailExist) { + Assert.assertNotEquals(DEFAULT_CUSTOMER_USER_EMAIL, userFromCloud.getEmail()); + } else { + Assert.assertEquals(DEFAULT_CUSTOMER_USER_EMAIL, userFromCloud.getEmail()); + } + return userFromCloud; + } + + private UplinkMsg constructUserCredentialsUplinkMsg(UserCredentialsId userCredentialsUuid, UserId userId) { + UserCredentials userCredentials = buildCredentials(userCredentialsUuid, userId, true); + userCredentials.setPassword("password"); UserCredentialsUpdateMsg credsMsg = EdgeMsgConstructorUtils.constructUserCredentialsUpdatedMsg(userCredentials); + return UplinkMsg.newBuilder() .addUserCredentialsUpdateMsg(credsMsg) .build(); diff --git a/dao/src/main/java/org/thingsboard/server/dao/service/validator/UserDataValidator.java b/dao/src/main/java/org/thingsboard/server/dao/service/validator/UserDataValidator.java index 64d0fc21b8..777ce4dfc8 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/service/validator/UserDataValidator.java +++ b/dao/src/main/java/org/thingsboard/server/dao/service/validator/UserDataValidator.java @@ -136,5 +136,4 @@ public class UserDataValidator extends DataValidator { } } } - } diff --git a/dao/src/main/java/org/thingsboard/server/dao/user/UserServiceImpl.java b/dao/src/main/java/org/thingsboard/server/dao/user/UserServiceImpl.java index 8c229c815d..7cbf1c80bd 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/user/UserServiceImpl.java +++ b/dao/src/main/java/org/thingsboard/server/dao/user/UserServiceImpl.java @@ -166,7 +166,6 @@ public class UserServiceImpl extends AbstractCachedEntityService Date: Thu, 9 Oct 2025 13:10:58 +0300 Subject: [PATCH 03/54] Refactor Test --- .../thingsboard/server/edge/UserEdgeTest.java | 42 +++++++++---------- 1 file changed, 21 insertions(+), 21 deletions(-) diff --git a/application/src/test/java/org/thingsboard/server/edge/UserEdgeTest.java b/application/src/test/java/org/thingsboard/server/edge/UserEdgeTest.java index 3e8abd645c..8b5261150b 100644 --- a/application/src/test/java/org/thingsboard/server/edge/UserEdgeTest.java +++ b/application/src/test/java/org/thingsboard/server/edge/UserEdgeTest.java @@ -16,7 +16,6 @@ package org.thingsboard.server.edge; import com.fasterxml.jackson.databind.JsonNode; -import com.google.protobuf.AbstractMessage; import org.junit.Assert; import org.junit.Test; import org.springframework.beans.factory.annotation.Autowired; @@ -125,7 +124,7 @@ public class UserEdgeTest extends AbstractEdgeTest { } @Test - public void testSendUserCredentialsRequest() throws Exception { + public void testSendUserCredentialsRequestToCloud() throws Exception { UplinkMsg.Builder uplinkMsgBuilder = UplinkMsg.newBuilder(); UserCredentialsRequestMsg.Builder userCredentialsRequestMsgBuilder = UserCredentialsRequestMsg.newBuilder(); userCredentialsRequestMsgBuilder.setUserIdMSB(tenantAdminUserId.getId().getMostSignificantBits()); @@ -141,9 +140,7 @@ public class UserEdgeTest extends AbstractEdgeTest { Assert.assertTrue(edgeImitator.waitForResponses()); Assert.assertTrue(edgeImitator.waitForMessages()); - AbstractMessage latestMessage = edgeImitator.getLatestMessage(); - Assert.assertTrue(latestMessage instanceof UserCredentialsUpdateMsg); - UserCredentialsUpdateMsg userCredentialsUpdateMsg = (UserCredentialsUpdateMsg) latestMessage; + UserCredentialsUpdateMsg userCredentialsUpdateMsg = getLatestUserCredentialsUpdateMsg(); UserCredentials userCredentialsMsg = JacksonUtil.fromString(userCredentialsUpdateMsg.getEntity(), UserCredentials.class, true); Assert.assertNotNull(userCredentialsMsg); Assert.assertEquals(tenantAdminUserId, userCredentialsMsg.getUserId()); @@ -166,24 +163,25 @@ public class UserEdgeTest extends AbstractEdgeTest { } private User buildUser(Authority authority, CustomerId customerId) { - User customerUser = new User(); + User user = new User(); - customerUser.setAuthority(authority); - customerUser.setTenantId(tenantId); - customerUser.setCustomerId(customerId); - customerUser.setEmail(authority == Authority.TENANT_ADMIN ? DEFAULT_TENANT_ADMIN_EMAIL : DEFAULT_CUSTOMER_USER_EMAIL); - customerUser.setFirstName(DEFAULT_FIRST_NAME); - customerUser.setLastName(DEFAULT_LAST_NAME); + user.setAuthority(authority); + user.setTenantId(tenantId); + user.setCustomerId(customerId); + user.setEmail(authority == Authority.TENANT_ADMIN ? DEFAULT_TENANT_ADMIN_EMAIL : DEFAULT_CUSTOMER_USER_EMAIL); + user.setFirstName(DEFAULT_FIRST_NAME); + user.setLastName(DEFAULT_LAST_NAME); - return customerUser; + return user; } private User createAndVerifyUserOnEdge(User user) throws Exception { + String password = user.getAuthority() == Authority.TENANT_ADMIN ? "tenant" : "customer"; + // wait 3 messages - x1 user update msg and x2 user credentials update msgs (create + authenticate user) edgeImitator.expectMessageAmount(3); - User savedUser = createUser(user, user.getAuthority() == Authority.TENANT_ADMIN ? "tenant" : "customer"); + User savedUser = createUser(user, password); Assert.assertTrue(edgeImitator.waitForMessages()); - Assert.assertEquals(1, edgeImitator.findAllMessagesByType(UserUpdateMsg.class).size()); Assert.assertEquals(2, edgeImitator.findAllMessagesByType(UserCredentialsUpdateMsg.class).size()); @@ -226,9 +224,7 @@ public class UserEdgeTest extends AbstractEdgeTest { doPost("/api/auth/changePassword", changePasswordRequest); Assert.assertTrue(edgeImitator.waitForMessages()); - AbstractMessage latestMessage = edgeImitator.getLatestMessage(); - Assert.assertTrue(latestMessage instanceof UserCredentialsUpdateMsg); - UserCredentialsUpdateMsg msg = (UserCredentialsUpdateMsg) latestMessage; + UserCredentialsUpdateMsg msg = getLatestUserCredentialsUpdateMsg(); UserCredentials creds = JacksonUtil.fromString(msg.getEntity(), UserCredentials.class, true); Assert.assertNotNull(creds); Assert.assertEquals(user.getId(), creds.getUserId()); @@ -241,9 +237,7 @@ public class UserEdgeTest extends AbstractEdgeTest { .andExpect(status().isOk()); Assert.assertTrue(edgeImitator.waitForMessages()); - AbstractMessage latestMessage = edgeImitator.getLatestMessage(); - Assert.assertTrue(latestMessage instanceof UserUpdateMsg); - UserUpdateMsg userUpdateMsg = (UserUpdateMsg) latestMessage; + UserUpdateMsg userUpdateMsg = getLatestUserUpdateMsg(); Assert.assertEquals(UpdateMsgType.ENTITY_DELETED_RPC_MESSAGE, userUpdateMsg.getMsgType()); Assert.assertEquals(savedTenantAdmin.getUuidId().getMostSignificantBits(), userUpdateMsg.getIdMSB()); Assert.assertEquals(savedTenantAdmin.getUuidId().getLeastSignificantBits(), userUpdateMsg.getIdLSB()); @@ -314,4 +308,10 @@ public class UserEdgeTest extends AbstractEdgeTest { return opt.get(); } + private UserCredentialsUpdateMsg getLatestUserCredentialsUpdateMsg() { + Optional opt = edgeImitator.findMessageByType(UserCredentialsUpdateMsg.class); + Assert.assertTrue(opt.isPresent()); + return opt.get(); + } + } From 267997fb1ab540780e10abd146b4c557d92b6f2b Mon Sep 17 00:00:00 2001 From: dashevchenko Date: Tue, 18 Nov 2025 18:14:33 +0200 Subject: [PATCH 04/54] noxss for AI model and oauth2 client --- .../java/org/thingsboard/server/common/data/ai/AiModel.java | 2 ++ .../org/thingsboard/server/common/data/oauth2/OAuth2Client.java | 2 ++ 2 files changed, 4 insertions(+) diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/ai/AiModel.java b/common/data/src/main/java/org/thingsboard/server/common/data/ai/AiModel.java index 4d7bb21930..3564f8dee0 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/ai/AiModel.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/ai/AiModel.java @@ -32,6 +32,7 @@ import org.thingsboard.server.common.data.id.AiModelId; import org.thingsboard.server.common.data.id.TenantId; import org.thingsboard.server.common.data.validation.Length; import org.thingsboard.server.common.data.validation.NoNullChar; +import org.thingsboard.server.common.data.validation.NoXss; import java.io.Serial; @@ -64,6 +65,7 @@ public final class AiModel extends BaseData implements HasTenantId, H @NotBlank @NoNullChar @Length(min = 1, max = 255) + @NoXss @Schema( requiredMode = Schema.RequiredMode.REQUIRED, accessMode = Schema.AccessMode.READ_WRITE, diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/oauth2/OAuth2Client.java b/common/data/src/main/java/org/thingsboard/server/common/data/oauth2/OAuth2Client.java index 6396ecb7b8..e6b6c1ff6f 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/oauth2/OAuth2Client.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/oauth2/OAuth2Client.java @@ -30,6 +30,7 @@ import org.thingsboard.server.common.data.HasTenantId; import org.thingsboard.server.common.data.id.OAuth2ClientId; import org.thingsboard.server.common.data.id.TenantId; import org.thingsboard.server.common.data.validation.Length; +import org.thingsboard.server.common.data.validation.NoXss; import java.util.List; @@ -42,6 +43,7 @@ public class OAuth2Client extends BaseDataWithAdditionalInfo imp private TenantId tenantId; @Schema(description = "Oauth2 client title") @NotBlank + @NoXss @Length(fieldName = "title", max = 100, message = "cannot be longer than 100 chars") private String title; @Schema(description = "Config for mapping OAuth2 log in response to platform entities", requiredMode = Schema.RequiredMode.REQUIRED) From d2ba38aee7760a35e4653a65ea16824bc2a1828b Mon Sep 17 00:00:00 2001 From: Andrii Landiak Date: Wed, 19 Nov 2025 16:54:42 +0200 Subject: [PATCH 05/54] Improve auth providers to be in sync with PE new logic --- .../auth/AbstractAuthenticationProvider.java | 95 +++++++++++++++++++ .../RefreshTokenAuthenticationProvider.java | 76 +++------------ .../pat/ApiKeyAuthenticationProvider.java | 45 +++------ .../auth/pat/ApiKeyAuthenticationToken.java | 12 +-- ...eyTokenAuthenticationProcessingFilter.java | 7 +- .../auth/rest/RestAuthenticationProvider.java | 42 ++------ ...{RawApiKey.java => ApiKeyAuthRequest.java} | 2 +- .../server/dao/model/BaseSqlEntity.java | 3 - .../model/sql/AbstractEntityViewEntity.java | 4 - .../dao/model/sql/ApiUsageStateEntity.java | 3 - .../MobileAppBundleOauth2ClientEntity.java | 2 +- 11 files changed, 139 insertions(+), 152 deletions(-) create mode 100644 application/src/main/java/org/thingsboard/server/service/security/auth/AbstractAuthenticationProvider.java rename application/src/main/java/org/thingsboard/server/service/security/model/token/{RawApiKey.java => ApiKeyAuthRequest.java} (93%) diff --git a/application/src/main/java/org/thingsboard/server/service/security/auth/AbstractAuthenticationProvider.java b/application/src/main/java/org/thingsboard/server/service/security/auth/AbstractAuthenticationProvider.java new file mode 100644 index 0000000000..e05aba5d18 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/security/auth/AbstractAuthenticationProvider.java @@ -0,0 +1,95 @@ +/** + * Copyright © 2016-2025 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.security.auth; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.security.authentication.AuthenticationProvider; +import org.springframework.security.authentication.BadCredentialsException; +import org.springframework.security.authentication.DisabledException; +import org.springframework.security.authentication.InsufficientAuthenticationException; +import org.springframework.security.core.userdetails.UsernameNotFoundException; +import org.thingsboard.server.common.data.Customer; +import org.thingsboard.server.common.data.User; +import org.thingsboard.server.common.data.UserAuthDetails; +import org.thingsboard.server.common.data.id.CustomerId; +import org.thingsboard.server.common.data.id.EntityId; +import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.common.data.id.UserId; +import org.thingsboard.server.common.data.security.Authority; +import org.thingsboard.server.dao.customer.CustomerService; +import org.thingsboard.server.service.security.model.SecurityUser; +import org.thingsboard.server.service.security.model.UserPrincipal; +import org.thingsboard.server.service.user.cache.UserAuthDetailsCache; + +import java.util.UUID; + +@Slf4j +@RequiredArgsConstructor +public abstract class AbstractAuthenticationProvider implements AuthenticationProvider { + + private final CustomerService customerService; + private final UserAuthDetailsCache userAuthDetailsCache; + + protected SecurityUser authenticateByPublicId(String publicId, String authContextName, UserPrincipal userPrincipal) { + TenantId systemId = TenantId.SYS_TENANT_ID; + CustomerId customerId; + try { + customerId = new CustomerId(UUID.fromString(publicId)); + } catch (Exception e) { + throw new BadCredentialsException(authContextName + " is not valid"); + } + Customer publicCustomer = customerService.findCustomerById(systemId, customerId); + if (publicCustomer == null) { + throw new UsernameNotFoundException("Public entity not found"); + } + + if (!publicCustomer.isPublic()) { + throw new BadCredentialsException(authContextName + " is not valid"); + } + + User user = new User(new UserId(EntityId.NULL_UUID)); + user.setTenantId(publicCustomer.getTenantId()); + user.setCustomerId(publicCustomer.getId()); + user.setEmail(publicId); + user.setAuthority(Authority.CUSTOMER_USER); + user.setFirstName("Public"); + user.setLastName("Public"); + + UserPrincipal principal = userPrincipal == null ? new UserPrincipal(UserPrincipal.Type.PUBLIC_ID, publicId) : userPrincipal; + + return new SecurityUser(user, true, principal); + } + + protected SecurityUser authenticateByUserId(TenantId tenantId, UserId userId) { + UserAuthDetails userAuthDetails = userAuthDetailsCache.getUserAuthDetails(tenantId, userId); + if (userAuthDetails == null) { + throw new UsernameNotFoundException("User with credentials not found"); + } + if (!userAuthDetails.credentialsEnabled()) { + throw new DisabledException("User is not active"); + } + + User user = userAuthDetails.user(); + if (user.getAuthority() == null) { + throw new InsufficientAuthenticationException("User has no authority assigned"); + } + + UserPrincipal userPrincipal = new UserPrincipal(UserPrincipal.Type.USER_NAME, user.getEmail()); + return new SecurityUser(user, true, userPrincipal); + } + +} diff --git a/application/src/main/java/org/thingsboard/server/service/security/auth/jwt/RefreshTokenAuthenticationProvider.java b/application/src/main/java/org/thingsboard/server/service/security/auth/jwt/RefreshTokenAuthenticationProvider.java index 5017afeb24..2851012685 100644 --- a/application/src/main/java/org/thingsboard/server/service/security/auth/jwt/RefreshTokenAuthenticationProvider.java +++ b/application/src/main/java/org/thingsboard/server/service/security/auth/jwt/RefreshTokenAuthenticationProvider.java @@ -15,26 +15,14 @@ */ package org.thingsboard.server.service.security.auth.jwt; -import lombok.RequiredArgsConstructor; -import org.springframework.security.authentication.AuthenticationProvider; -import org.springframework.security.authentication.BadCredentialsException; import org.springframework.security.authentication.CredentialsExpiredException; -import org.springframework.security.authentication.DisabledException; -import org.springframework.security.authentication.InsufficientAuthenticationException; import org.springframework.security.core.Authentication; import org.springframework.security.core.AuthenticationException; -import org.springframework.security.core.userdetails.UsernameNotFoundException; import org.springframework.stereotype.Component; import org.springframework.util.Assert; -import org.thingsboard.server.common.data.Customer; -import org.thingsboard.server.common.data.User; -import org.thingsboard.server.common.data.UserAuthDetails; -import org.thingsboard.server.common.data.id.CustomerId; -import org.thingsboard.server.common.data.id.EntityId; import org.thingsboard.server.common.data.id.TenantId; -import org.thingsboard.server.common.data.id.UserId; -import org.thingsboard.server.common.data.security.Authority; import org.thingsboard.server.dao.customer.CustomerService; +import org.thingsboard.server.service.security.auth.AbstractAuthenticationProvider; import org.thingsboard.server.service.security.auth.RefreshAuthenticationToken; import org.thingsboard.server.service.security.auth.TokenOutdatingService; import org.thingsboard.server.service.security.model.SecurityUser; @@ -43,17 +31,19 @@ import org.thingsboard.server.service.security.model.token.JwtTokenFactory; import org.thingsboard.server.service.security.model.token.RawAccessJwtToken; import org.thingsboard.server.service.user.cache.UserAuthDetailsCache; -import java.util.UUID; - @Component -@RequiredArgsConstructor -public class RefreshTokenAuthenticationProvider implements AuthenticationProvider { +public class RefreshTokenAuthenticationProvider extends AbstractAuthenticationProvider { private final JwtTokenFactory tokenFactory; - private final UserAuthDetailsCache userAuthDetailsCache; - private final CustomerService customerService; private final TokenOutdatingService tokenOutdatingService; + public RefreshTokenAuthenticationProvider(JwtTokenFactory jwtTokenFactory, UserAuthDetailsCache userAuthDetailsCache, + CustomerService customerService, TokenOutdatingService tokenOutdatingService) { + super(customerService, userAuthDetailsCache); + this.tokenFactory = jwtTokenFactory; + this.tokenOutdatingService = tokenOutdatingService; + } + @Override public Authentication authenticate(Authentication authentication) throws AuthenticationException { Assert.notNull(authentication, "No authentication data provided"); @@ -63,7 +53,7 @@ public class RefreshTokenAuthenticationProvider implements AuthenticationProvide SecurityUser securityUser; if (principal.getType() == UserPrincipal.Type.USER_NAME) { - securityUser = authenticateByUserId(unsafeUser.getId()); + securityUser = authenticateByUserId(TenantId.SYS_TENANT_ID, unsafeUser.getId()); } else { securityUser = authenticateByPublicId(principal.getValue()); } @@ -75,52 +65,8 @@ public class RefreshTokenAuthenticationProvider implements AuthenticationProvide return new RefreshAuthenticationToken(securityUser); } - private SecurityUser authenticateByUserId(UserId userId) { - UserAuthDetails userAuthDetails = userAuthDetailsCache.getUserAuthDetails(TenantId.SYS_TENANT_ID, userId); - if (userAuthDetails == null) { - throw new UsernameNotFoundException("User with credentials not found"); - } - if (!userAuthDetails.credentialsEnabled()) { - throw new DisabledException("User is not active"); - } - - User user = userAuthDetails.user(); - if (user.getAuthority() == null) { - throw new InsufficientAuthenticationException("User has no authority assigned"); - } - - UserPrincipal userPrincipal = new UserPrincipal(UserPrincipal.Type.USER_NAME, user.getEmail()); - return new SecurityUser(user, true, userPrincipal); - } - private SecurityUser authenticateByPublicId(String publicId) { - TenantId systemId = TenantId.SYS_TENANT_ID; - CustomerId customerId; - try { - customerId = new CustomerId(UUID.fromString(publicId)); - } catch (Exception e) { - throw new BadCredentialsException("Refresh token is not valid"); - } - Customer publicCustomer = customerService.findCustomerById(systemId, customerId); - if (publicCustomer == null) { - throw new UsernameNotFoundException("Public entity not found by refresh token"); - } - - if (!publicCustomer.isPublic()) { - throw new BadCredentialsException("Refresh token is not valid"); - } - - User user = new User(new UserId(EntityId.NULL_UUID)); - user.setTenantId(publicCustomer.getTenantId()); - user.setCustomerId(publicCustomer.getId()); - user.setEmail(publicId); - user.setAuthority(Authority.CUSTOMER_USER); - user.setFirstName("Public"); - user.setLastName("Public"); - - UserPrincipal userPrincipal = new UserPrincipal(UserPrincipal.Type.PUBLIC_ID, publicId); - - return new SecurityUser(user, true, userPrincipal); + return super.authenticateByPublicId(publicId, "Refresh token", null); } @Override diff --git a/application/src/main/java/org/thingsboard/server/service/security/auth/pat/ApiKeyAuthenticationProvider.java b/application/src/main/java/org/thingsboard/server/service/security/auth/pat/ApiKeyAuthenticationProvider.java index b72cc4c73e..fe62f496b6 100644 --- a/application/src/main/java/org/thingsboard/server/service/security/auth/pat/ApiKeyAuthenticationProvider.java +++ b/application/src/main/java/org/thingsboard/server/service/security/auth/pat/ApiKeyAuthenticationProvider.java @@ -15,42 +15,35 @@ */ package org.thingsboard.server.service.security.auth.pat; -import lombok.RequiredArgsConstructor; import org.springframework.security.authentication.BadCredentialsException; import org.springframework.security.authentication.CredentialsExpiredException; import org.springframework.security.authentication.DisabledException; -import org.springframework.security.authentication.InsufficientAuthenticationException; import org.springframework.security.core.Authentication; import org.springframework.security.core.AuthenticationException; -import org.springframework.security.core.userdetails.UsernameNotFoundException; import org.springframework.stereotype.Component; import org.thingsboard.server.common.data.StringUtils; -import org.thingsboard.server.common.data.User; -import org.thingsboard.server.common.data.UserAuthDetails; import org.thingsboard.server.common.data.pat.ApiKey; import org.thingsboard.server.dao.pat.ApiKeyService; +import org.thingsboard.server.service.security.auth.AbstractAuthenticationProvider; import org.thingsboard.server.service.security.model.SecurityUser; -import org.thingsboard.server.service.security.model.UserPrincipal; -import org.thingsboard.server.service.security.model.token.RawApiKey; +import org.thingsboard.server.service.security.model.token.ApiKeyAuthRequest; import org.thingsboard.server.service.user.cache.UserAuthDetailsCache; @Component -@RequiredArgsConstructor -public class ApiKeyAuthenticationProvider implements org.springframework.security.authentication.AuthenticationProvider { +public class ApiKeyAuthenticationProvider extends AbstractAuthenticationProvider { private final ApiKeyService apiKeyService; - private final UserAuthDetailsCache userAuthDetailsCache; - @Override - public Authentication authenticate(Authentication authentication) throws AuthenticationException { - RawApiKey rawApiKey = (RawApiKey) authentication.getCredentials(); - SecurityUser securityUser = authenticate(rawApiKey.apiKey()); - return new ApiKeyAuthenticationToken(securityUser); + public ApiKeyAuthenticationProvider(ApiKeyService apiKeyService, UserAuthDetailsCache userAuthDetailsCache) { + super(null, userAuthDetailsCache); + this.apiKeyService = apiKeyService; } @Override - public boolean supports(Class authentication) { - return ApiKeyAuthenticationToken.class.isAssignableFrom(authentication); + public Authentication authenticate(Authentication authentication) throws AuthenticationException { + ApiKeyAuthRequest apiKeyAuthRequest = (ApiKeyAuthRequest) authentication.getCredentials(); + SecurityUser securityUser = authenticate(apiKeyAuthRequest.apiKey()); + return new ApiKeyAuthenticationToken(securityUser); } private SecurityUser authenticate(String key) { @@ -67,20 +60,12 @@ public class ApiKeyAuthenticationProvider implements org.springframework.securit if (apiKey.getExpirationTime() != 0 && apiKey.getExpirationTime() < System.currentTimeMillis()) { throw new CredentialsExpiredException("API key is expired"); } - UserAuthDetails userAuthDetails = userAuthDetailsCache.getUserAuthDetails(apiKey.getTenantId(), apiKey.getUserId()); - if (userAuthDetails == null) { - throw new UsernameNotFoundException("User with credentials not found"); - } - if (!userAuthDetails.credentialsEnabled()) { - throw new DisabledException("User is not active"); - } + return super.authenticateByUserId(apiKey.getTenantId(), apiKey.getUserId()); + } - User user = userAuthDetails.user(); - if (user.getAuthority() == null) { - throw new InsufficientAuthenticationException("User has no authority assigned"); - } - UserPrincipal userPrincipal = new UserPrincipal(UserPrincipal.Type.USER_NAME, user.getEmail()); - return new SecurityUser(user, true, userPrincipal); + @Override + public boolean supports(Class authentication) { + return ApiKeyAuthenticationToken.class.isAssignableFrom(authentication); } } diff --git a/application/src/main/java/org/thingsboard/server/service/security/auth/pat/ApiKeyAuthenticationToken.java b/application/src/main/java/org/thingsboard/server/service/security/auth/pat/ApiKeyAuthenticationToken.java index 8165baf483..ee76cc46a3 100644 --- a/application/src/main/java/org/thingsboard/server/service/security/auth/pat/ApiKeyAuthenticationToken.java +++ b/application/src/main/java/org/thingsboard/server/service/security/auth/pat/ApiKeyAuthenticationToken.java @@ -17,7 +17,7 @@ package org.thingsboard.server.service.security.auth.pat; import org.springframework.security.authentication.AbstractAuthenticationToken; import org.thingsboard.server.service.security.model.SecurityUser; -import org.thingsboard.server.service.security.model.token.RawApiKey; +import org.thingsboard.server.service.security.model.token.ApiKeyAuthRequest; import java.io.Serial; @@ -26,12 +26,12 @@ public class ApiKeyAuthenticationToken extends AbstractAuthenticationToken { @Serial private static final long serialVersionUID = 2978710889397403536L; - private RawApiKey rawApiKey; + private ApiKeyAuthRequest apiKeyAuthRequest; private SecurityUser securityUser; - public ApiKeyAuthenticationToken(RawApiKey rawApiKey) { + public ApiKeyAuthenticationToken(ApiKeyAuthRequest apiKeyAuthRequest) { super(null); - this.rawApiKey = rawApiKey; + this.apiKeyAuthRequest = apiKeyAuthRequest; setAuthenticated(false); } @@ -44,7 +44,7 @@ public class ApiKeyAuthenticationToken extends AbstractAuthenticationToken { @Override public Object getCredentials() { - return rawApiKey; + return apiKeyAuthRequest; } @Override @@ -55,7 +55,7 @@ public class ApiKeyAuthenticationToken extends AbstractAuthenticationToken { @Override public void eraseCredentials() { super.eraseCredentials(); - this.rawApiKey = null; + this.apiKeyAuthRequest = null; } } diff --git a/application/src/main/java/org/thingsboard/server/service/security/auth/pat/ApiKeyTokenAuthenticationProcessingFilter.java b/application/src/main/java/org/thingsboard/server/service/security/auth/pat/ApiKeyTokenAuthenticationProcessingFilter.java index 20a95a5ae5..1a91315505 100644 --- a/application/src/main/java/org/thingsboard/server/service/security/auth/pat/ApiKeyTokenAuthenticationProcessingFilter.java +++ b/application/src/main/java/org/thingsboard/server/service/security/auth/pat/ApiKeyTokenAuthenticationProcessingFilter.java @@ -29,7 +29,7 @@ import org.springframework.security.web.authentication.AbstractAuthenticationPro import org.springframework.security.web.authentication.AuthenticationFailureHandler; import org.springframework.security.web.util.matcher.RequestMatcher; import org.thingsboard.server.service.security.auth.extractor.TokenExtractor; -import org.thingsboard.server.service.security.model.token.RawApiKey; +import org.thingsboard.server.service.security.model.token.ApiKeyAuthRequest; import java.io.IOException; @@ -52,8 +52,9 @@ public class ApiKeyTokenAuthenticationProcessingFilter extends AbstractAuthentic @Override public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) throws AuthenticationException { - RawApiKey rawApiKey = new RawApiKey(tokenExtractor.extract(request)); - return getAuthenticationManager().authenticate(new ApiKeyAuthenticationToken(rawApiKey)); + String apiKeyValue = tokenExtractor.extract(request); + ApiKeyAuthRequest apiKeyAuthRequest = new ApiKeyAuthRequest(apiKeyValue); + return getAuthenticationManager().authenticate(new ApiKeyAuthenticationToken(apiKeyAuthRequest)); } @Override diff --git a/application/src/main/java/org/thingsboard/server/service/security/auth/rest/RestAuthenticationProvider.java b/application/src/main/java/org/thingsboard/server/service/security/auth/rest/RestAuthenticationProvider.java index 3c396f10aa..e43a89f815 100644 --- a/application/src/main/java/org/thingsboard/server/service/security/auth/rest/RestAuthenticationProvider.java +++ b/application/src/main/java/org/thingsboard/server/service/security/auth/rest/RestAuthenticationProvider.java @@ -17,7 +17,6 @@ package org.thingsboard.server.service.security.auth.rest; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.security.authentication.AuthenticationProvider; import org.springframework.security.authentication.BadCredentialsException; import org.springframework.security.authentication.InsufficientAuthenticationException; import org.springframework.security.authentication.LockedException; @@ -27,14 +26,9 @@ import org.springframework.security.core.AuthenticationException; import org.springframework.security.core.userdetails.UsernameNotFoundException; import org.springframework.stereotype.Component; import org.springframework.util.Assert; -import org.thingsboard.server.common.data.Customer; import org.thingsboard.server.common.data.User; import org.thingsboard.server.common.data.audit.ActionType; -import org.thingsboard.server.common.data.id.CustomerId; -import org.thingsboard.server.common.data.id.EntityId; import org.thingsboard.server.common.data.id.TenantId; -import org.thingsboard.server.common.data.id.UserId; -import org.thingsboard.server.common.data.security.Authority; import org.thingsboard.server.common.data.security.UserCredentials; import org.thingsboard.server.common.data.security.model.SecuritySettings; import org.thingsboard.server.common.data.security.model.UserPasswordPolicy; @@ -43,6 +37,7 @@ import org.thingsboard.server.dao.exception.DataValidationException; import org.thingsboard.server.dao.settings.SecuritySettingsService; import org.thingsboard.server.dao.user.UserService; import org.thingsboard.server.queue.util.TbCoreComponent; +import org.thingsboard.server.service.security.auth.AbstractAuthenticationProvider; import org.thingsboard.server.service.security.auth.MfaAuthenticationToken; import org.thingsboard.server.service.security.auth.MfaConfigurationToken; import org.thingsboard.server.service.security.auth.mfa.TwoFactorAuthService; @@ -51,18 +46,14 @@ import org.thingsboard.server.service.security.model.SecurityUser; import org.thingsboard.server.service.security.model.UserPrincipal; import org.thingsboard.server.service.security.system.SystemSecurityService; -import java.util.UUID; - - @Component @Slf4j @TbCoreComponent -public class RestAuthenticationProvider implements AuthenticationProvider { +public class RestAuthenticationProvider extends AbstractAuthenticationProvider { private final SystemSecurityService systemSecurityService; private final SecuritySettingsService securitySettingsService; private final UserService userService; - private final CustomerService customerService; private final TwoFactorAuthService twoFactorAuthService; @Autowired @@ -71,8 +62,8 @@ public class RestAuthenticationProvider implements AuthenticationProvider { final SystemSecurityService systemSecurityService, SecuritySettingsService securitySettingsService, TwoFactorAuthService twoFactorAuthService) { + super(customerService, null); this.userService = userService; - this.customerService = customerService; this.systemSecurityService = systemSecurityService; this.securitySettingsService = securitySettingsService; this.twoFactorAuthService = twoFactorAuthService; @@ -125,7 +116,6 @@ public class RestAuthenticationProvider implements AuthenticationProvider { } try { - UserCredentials userCredentials = userService.findUserCredentialsByUserId(TenantId.SYS_TENANT_ID, user.getId()); if (userCredentials == null) { throw new UsernameNotFoundException("User credentials not found"); @@ -138,8 +128,9 @@ public class RestAuthenticationProvider implements AuthenticationProvider { throw e; } - if (user.getAuthority() == null) + if (user.getAuthority() == null) { throw new InsufficientAuthenticationException("User has no authority assigned"); + } return new SecurityUser(user, userCredentials.isEnabled(), userPrincipal); } catch (Exception e) { @@ -149,28 +140,7 @@ public class RestAuthenticationProvider implements AuthenticationProvider { } private SecurityUser authenticateByPublicId(UserPrincipal userPrincipal, String publicId) { - CustomerId customerId; - try { - customerId = new CustomerId(UUID.fromString(publicId)); - } catch (Exception e) { - throw new BadCredentialsException("Authentication Failed. Public Id is not valid."); - } - Customer publicCustomer = customerService.findCustomerById(TenantId.SYS_TENANT_ID, customerId); - if (publicCustomer == null) { - throw new UsernameNotFoundException("Public entity not found: " + publicId); - } - if (!publicCustomer.isPublic()) { - throw new BadCredentialsException("Authentication Failed. Public Id is not valid."); - } - User user = new User(new UserId(EntityId.NULL_UUID)); - user.setTenantId(publicCustomer.getTenantId()); - user.setCustomerId(publicCustomer.getId()); - user.setEmail(publicId); - user.setAuthority(Authority.CUSTOMER_USER); - user.setFirstName("Public"); - user.setLastName("Public"); - - return new SecurityUser(user, true, userPrincipal); + return super.authenticateByPublicId(publicId, "Public Id", userPrincipal); } @Override diff --git a/application/src/main/java/org/thingsboard/server/service/security/model/token/RawApiKey.java b/application/src/main/java/org/thingsboard/server/service/security/model/token/ApiKeyAuthRequest.java similarity index 93% rename from application/src/main/java/org/thingsboard/server/service/security/model/token/RawApiKey.java rename to application/src/main/java/org/thingsboard/server/service/security/model/token/ApiKeyAuthRequest.java index 1268df592f..8d4fb967d7 100644 --- a/application/src/main/java/org/thingsboard/server/service/security/model/token/RawApiKey.java +++ b/application/src/main/java/org/thingsboard/server/service/security/model/token/ApiKeyAuthRequest.java @@ -15,4 +15,4 @@ */ package org.thingsboard.server.service.security.model.token; -public record RawApiKey(String apiKey) {} +public record ApiKeyAuthRequest(String apiKey) {} diff --git a/dao/src/main/java/org/thingsboard/server/dao/model/BaseSqlEntity.java b/dao/src/main/java/org/thingsboard/server/dao/model/BaseSqlEntity.java index dda45539f9..0f758b4f00 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/model/BaseSqlEntity.java +++ b/dao/src/main/java/org/thingsboard/server/dao/model/BaseSqlEntity.java @@ -36,9 +36,6 @@ import java.util.UUID; import java.util.function.Function; import java.util.stream.Collectors; -/** - * Created by ashvayka on 13.07.17. - */ @Data @MappedSuperclass public abstract class BaseSqlEntity implements BaseEntity { diff --git a/dao/src/main/java/org/thingsboard/server/dao/model/sql/AbstractEntityViewEntity.java b/dao/src/main/java/org/thingsboard/server/dao/model/sql/AbstractEntityViewEntity.java index e865f47407..b06d6c292c 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/model/sql/AbstractEntityViewEntity.java +++ b/dao/src/main/java/org/thingsboard/server/dao/model/sql/AbstractEntityViewEntity.java @@ -40,10 +40,6 @@ import java.util.UUID; import static org.thingsboard.server.dao.model.ModelConstants.ENTITY_TYPE_PROPERTY; -/** - * Created by Victor Basanets on 8/30/2017. - */ - @Data @EqualsAndHashCode(callSuper = true) @MappedSuperclass diff --git a/dao/src/main/java/org/thingsboard/server/dao/model/sql/ApiUsageStateEntity.java b/dao/src/main/java/org/thingsboard/server/dao/model/sql/ApiUsageStateEntity.java index 40f7442047..3817f404fd 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/model/sql/ApiUsageStateEntity.java +++ b/dao/src/main/java/org/thingsboard/server/dao/model/sql/ApiUsageStateEntity.java @@ -33,9 +33,6 @@ import org.thingsboard.server.dao.model.ModelConstants; import java.util.UUID; -/** - * Created by Valerii Sosliuk on 4/21/2017. - */ @Data @EqualsAndHashCode(callSuper = true) @Entity diff --git a/dao/src/main/java/org/thingsboard/server/dao/model/sql/MobileAppBundleOauth2ClientEntity.java b/dao/src/main/java/org/thingsboard/server/dao/model/sql/MobileAppBundleOauth2ClientEntity.java index 8afdb09b7b..fc29582bfa 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/model/sql/MobileAppBundleOauth2ClientEntity.java +++ b/dao/src/main/java/org/thingsboard/server/dao/model/sql/MobileAppBundleOauth2ClientEntity.java @@ -32,7 +32,6 @@ import static org.thingsboard.server.dao.model.ModelConstants.MOBILE_APP_BUNDLE_ import static org.thingsboard.server.dao.model.ModelConstants.MOBILE_APP_BUNDLE_OAUTH2_CLIENT_MOBILE_APP_BUNDLE_ID_PROPERTY; import static org.thingsboard.server.dao.model.ModelConstants.MOBILE_APP_BUNDLE_OAUTH2_CLIENT_TABLE_NAME; - @Data @Entity @Table(name = MOBILE_APP_BUNDLE_OAUTH2_CLIENT_TABLE_NAME) @@ -63,4 +62,5 @@ public final class MobileAppBundleOauth2ClientEntity implements ToData Date: Thu, 20 Nov 2025 09:43:37 +0200 Subject: [PATCH 06/54] Fix error in tests for SystemPatchApplier --- .../server/controller/AbstractWebTest.java | 16 +++++++++------ .../server/common/data/pat/ApiKey.java | 2 +- .../server/common/data/pat/ApiKeyInfo.java | 20 +++++++++---------- .../server/dao/service/ApiKeyServiceTest.java | 1 + 4 files changed, 22 insertions(+), 17 deletions(-) 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 7bc6f9876c..51d77bd134 100644 --- a/application/src/test/java/org/thingsboard/server/controller/AbstractWebTest.java +++ b/application/src/test/java/org/thingsboard/server/controller/AbstractWebTest.java @@ -38,8 +38,6 @@ import org.mockito.Mockito; import org.mockito.invocation.InvocationOnMock; import org.mockito.stubbing.Answer; import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.boot.test.mock.mockito.MockBean; -import org.springframework.boot.test.mock.mockito.SpyBean; import org.springframework.cache.Cache; import org.springframework.cache.CacheManager; import org.springframework.http.HttpHeaders; @@ -53,6 +51,8 @@ import org.springframework.mock.http.MockHttpInputMessage; import org.springframework.mock.http.MockHttpOutputMessage; import org.springframework.mock.web.MockMultipartFile; import org.springframework.mock.web.MockPart; +import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.springframework.test.context.bean.override.mockito.MockitoSpyBean; import org.springframework.test.util.ReflectionTestUtils; import org.springframework.test.web.servlet.MockMvc; import org.springframework.test.web.servlet.MvcResult; @@ -167,6 +167,7 @@ import org.thingsboard.server.service.entitiy.tenant.profile.TbTenantProfileServ import org.thingsboard.server.service.security.auth.jwt.RefreshTokenRequest; import org.thingsboard.server.service.security.auth.rest.LoginRequest; import org.thingsboard.server.service.security.model.token.JwtTokenFactory; +import org.thingsboard.server.service.system.SystemPatchApplier; import java.io.IOException; import java.nio.charset.StandardCharsets; @@ -295,18 +296,21 @@ public abstract class AbstractWebTest extends AbstractInMemoryStorageTest { @Autowired private JwtTokenFactory jwtTokenFactory; - @SpyBean - protected MailService mailService; - @Autowired protected InMemoryStorage storage; @Autowired protected JdbcTemplate jdbcTemplate; - @MockBean + @MockitoSpyBean + protected MailService mailService; + + @MockitoBean protected CfRocksDb cfRocksDb; + @MockitoBean + protected SystemPatchApplier systemPatchApplier; + @Rule public TestRule watcher = new TestWatcher() { protected void starting(Description description) { diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/pat/ApiKey.java b/common/data/src/main/java/org/thingsboard/server/common/data/pat/ApiKey.java index 81753322ba..a48be1c9c4 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/pat/ApiKey.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/pat/ApiKey.java @@ -32,7 +32,7 @@ public class ApiKey extends ApiKeyInfo { private static final long serialVersionUID = -2313196723950490263L; @NoXss - @Schema(description = "Api key value", requiredMode = Schema.RequiredMode.REQUIRED) + @Schema(description = "API key value", requiredMode = Schema.RequiredMode.REQUIRED) private String value; public ApiKey() { diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/pat/ApiKeyInfo.java b/common/data/src/main/java/org/thingsboard/server/common/data/pat/ApiKeyInfo.java index 770f4e64f6..41f7e49cdc 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/pat/ApiKeyInfo.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/pat/ApiKeyInfo.java @@ -38,26 +38,26 @@ public class ApiKeyInfo extends BaseData implements HasTenantId { @Serial private static final long serialVersionUID = -2313196723950490263L; - @Schema(description = "JSON object with Tenant Id. Tenant Id of the api key cannot be changed.", accessMode = Schema.AccessMode.READ_ONLY) + @Schema(description = "JSON object with Tenant Id. Tenant Id of the API key cannot be changed.", accessMode = Schema.AccessMode.READ_ONLY) private TenantId tenantId; - @Schema(description = "JSON object with User Id. User Id of the api key cannot be changed.") + @Schema(description = "JSON object with User Id. User Id of the API key cannot be changed.") private UserId userId; - @Schema(description = "Expiration time of the api key.") + @Schema(description = "Expiration time of the API key.") private long expirationTime; @NoXss @NotBlank @Length(fieldName = "description") - @Schema(description = "Api Key description.", example = "Api Key description") + @Schema(description = "API Key description.", example = "API Key description") private String description; - @Schema(description = "Enabled/disabled api key.", example = "true") + @Schema(description = "Enabled/disabled API key.", example = "true") private boolean enabled; @JsonProperty(access = JsonProperty.Access.READ_ONLY) - @Schema(description = "Indicates if the api key is expired based on current time. Returns false if expirationTime is 0 (no expiry).", + @Schema(description = "Indicates if the API key is expired based on current time. Returns false if expirationTime is 0 (no expiry).", example = "false", accessMode = Schema.AccessMode.READ_ONLY) public boolean isExpired() { @@ -67,10 +67,10 @@ public class ApiKeyInfo extends BaseData implements HasTenantId { return System.currentTimeMillis() > expirationTime; } - @Schema(description = "JSON object with the Api Key Id. " + - "Specify this field to update the Api Key. " + - "Referencing non-existing Api Key Id will cause error. " + - "Omit this field to create new Api Key.") + @Schema(description = "JSON object with the API Key Id. " + + "Specify this field to update the API Key. " + + "Referencing non-existing API Key Id will cause error. " + + "Omit this field to create new API Key.") @Override public ApiKeyId getId() { return super.getId(); diff --git a/dao/src/test/java/org/thingsboard/server/dao/service/ApiKeyServiceTest.java b/dao/src/test/java/org/thingsboard/server/dao/service/ApiKeyServiceTest.java index e7658d4fcf..64444a0d31 100644 --- a/dao/src/test/java/org/thingsboard/server/dao/service/ApiKeyServiceTest.java +++ b/dao/src/test/java/org/thingsboard/server/dao/service/ApiKeyServiceTest.java @@ -42,6 +42,7 @@ public class ApiKeyServiceTest extends AbstractServiceTest { @Autowired ApiKeyService apiKeyService; + @Autowired UserService userService; From 97c1eb87e7bd70b862e5f6c81db82e5cc74cdf12 Mon Sep 17 00:00:00 2001 From: Andrii Landiak Date: Thu, 20 Nov 2025 12:51:46 +0200 Subject: [PATCH 07/54] Fix mapping for few controllers --- .../controller/DashboardController.java | 54 ++++++++----------- .../server/controller/TenantController.java | 22 ++++---- .../controller/TenantProfileController.java | 27 ++++------ .../server/controller/UserController.java | 46 ++++++---------- 4 files changed, 56 insertions(+), 93 deletions(-) diff --git a/application/src/main/java/org/thingsboard/server/controller/DashboardController.java b/application/src/main/java/org/thingsboard/server/controller/DashboardController.java index 0e3fde0031..823b2c17c8 100644 --- a/application/src/main/java/org/thingsboard/server/controller/DashboardController.java +++ b/application/src/main/java/org/thingsboard/server/controller/DashboardController.java @@ -35,9 +35,7 @@ import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestHeader; import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RequestMethod; import org.springframework.web.bind.annotation.RequestParam; -import org.springframework.web.bind.annotation.ResponseBody; import org.springframework.web.bind.annotation.ResponseStatus; import org.springframework.web.bind.annotation.RestController; import org.thingsboard.common.util.JacksonUtil; @@ -120,7 +118,7 @@ public class DashboardController extends BaseController { @PreAuthorize("hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN', 'CUSTOMER_USER')") @GetMapping(value = "/dashboard/serverTime") @ApiResponse(responseCode = "200", description = "OK", content = @Content(mediaType = "application/json", examples = @ExampleObject(value = "1636023857137"))) - public long getServerTime() throws ThingsboardException { + public long getServerTime() { return System.currentTimeMillis(); } @@ -132,7 +130,7 @@ public class DashboardController extends BaseController { @PreAuthorize("hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN', 'CUSTOMER_USER')") @GetMapping(value = "/dashboard/maxDatapointsLimit") @ApiResponse(responseCode = "200", description = "OK", content = @Content(mediaType = "application/json", examples = @ExampleObject(value = "5000"))) - public long getMaxDatapointsLimit() throws ThingsboardException { + public long getMaxDatapointsLimit() { return maxDatapointsLimit; } @@ -154,11 +152,11 @@ public class DashboardController extends BaseController { @PreAuthorize("hasAnyAuthority('TENANT_ADMIN', 'CUSTOMER_USER')") @GetMapping(value = "/dashboard/{dashboardId}") public void getDashboardById(@Parameter(description = DASHBOARD_ID_PARAM_DESCRIPTION) - @PathVariable(DASHBOARD_ID) String strDashboardId, - @Parameter(description = INCLUDE_RESOURCES_DESCRIPTION) - @RequestParam(value = INCLUDE_RESOURCES, required = false) boolean includeResources, - @RequestHeader(name = HttpHeaders.ACCEPT_ENCODING, required = false) String acceptEncodingHeader, - HttpServletResponse response) throws Exception { + @PathVariable(DASHBOARD_ID) String strDashboardId, + @Parameter(description = INCLUDE_RESOURCES_DESCRIPTION) + @RequestParam(value = INCLUDE_RESOURCES, required = false) boolean includeResources, + @RequestHeader(name = HttpHeaders.ACCEPT_ENCODING, required = false) String acceptEncodingHeader, + HttpServletResponse response) throws Exception { checkParameter(DASHBOARD_ID, strDashboardId); DashboardId dashboardId = new DashboardId(toUUID(strDashboardId)); Dashboard dashboard = checkDashboardId(dashboardId, Operation.READ); @@ -179,9 +177,9 @@ public class DashboardController extends BaseController { @PreAuthorize("hasAuthority('TENANT_ADMIN')") @PostMapping(value = "/dashboard") public void saveDashboard(@io.swagger.v3.oas.annotations.parameters.RequestBody(description = "A JSON value representing the dashboard.") - @RequestBody Dashboard dashboard, - @RequestHeader(name = HttpHeaders.ACCEPT_ENCODING, required = false) String acceptEncodingHeader, - HttpServletResponse response) throws Exception { + @RequestBody Dashboard dashboard, + @RequestHeader(name = HttpHeaders.ACCEPT_ENCODING, required = false) String acceptEncodingHeader, + HttpServletResponse response) throws Exception { dashboard.setTenantId(getTenantId()); checkEntity(dashboard.getId(), dashboard, Resource.DASHBOARD); var savedDashboard = tbDashboardService.save(dashboard, getCurrentUser()); @@ -301,8 +299,7 @@ public class DashboardController extends BaseController { "[assign Device to Public Customer](#!/device-controller/assignDeviceToPublicCustomerUsingPOST) for this purpose. " + "Returns the Dashboard object." + TENANT_AUTHORITY_PARAGRAPH) @PreAuthorize("hasAuthority('TENANT_ADMIN')") - @RequestMapping(value = "/customer/public/dashboard/{dashboardId}", method = RequestMethod.POST) - @ResponseBody + @PostMapping(value = "/customer/public/dashboard/{dashboardId}") public Dashboard assignDashboardToPublicCustomer( @Parameter(description = DASHBOARD_ID_PARAM_DESCRIPTION) @PathVariable(DASHBOARD_ID) String strDashboardId) throws ThingsboardException { @@ -316,8 +313,7 @@ public class DashboardController extends BaseController { notes = "Unassigns the dashboard from a special, auto-generated 'Public' Customer. Once unassigned, unauthenticated users may no longer browse the dashboard. " + "Returns the Dashboard object." + TENANT_AUTHORITY_PARAGRAPH) @PreAuthorize("hasAuthority('TENANT_ADMIN')") - @RequestMapping(value = "/customer/public/dashboard/{dashboardId}", method = RequestMethod.DELETE) - @ResponseBody + @DeleteMapping(value = "/customer/public/dashboard/{dashboardId}") public Dashboard unassignDashboardFromPublicCustomer( @Parameter(description = DASHBOARD_ID_PARAM_DESCRIPTION) @PathVariable(DASHBOARD_ID) String strDashboardId) throws ThingsboardException { @@ -331,8 +327,7 @@ public class DashboardController extends BaseController { notes = "Returns a page of dashboard info objects owned by tenant. " + DASHBOARD_INFO_DEFINITION + " " + PAGE_DATA_PARAMETERS + SYSTEM_AUTHORITY_PARAGRAPH) @PreAuthorize("hasAuthority('SYS_ADMIN')") - @RequestMapping(value = "/tenant/{tenantId}/dashboards", params = {"pageSize", "page"}, method = RequestMethod.GET) - @ResponseBody + @GetMapping(value = "/tenant/{tenantId}/dashboards", params = {"pageSize", "page"}) public PageData getTenantDashboards( @Parameter(description = TENANT_ID_PARAM_DESCRIPTION, required = true) @PathVariable(TENANT_ID) String strTenantId, @@ -356,8 +351,7 @@ public class DashboardController extends BaseController { notes = "Returns a page of dashboard info objects owned by the tenant of a current user. " + DASHBOARD_INFO_DEFINITION + " " + PAGE_DATA_PARAMETERS + TENANT_AUTHORITY_PARAGRAPH) @PreAuthorize("hasAuthority('TENANT_ADMIN')") - @RequestMapping(value = "/tenant/dashboards", params = {"pageSize", "page"}, method = RequestMethod.GET) - @ResponseBody + @GetMapping(value = "/tenant/dashboards", params = {"pageSize", "page"}) public PageData getTenantDashboards( @Parameter(description = PAGE_SIZE_DESCRIPTION, required = true) @RequestParam int pageSize, @@ -384,8 +378,7 @@ public class DashboardController extends BaseController { notes = "Returns a page of dashboard info objects owned by the specified customer. " + DASHBOARD_INFO_DEFINITION + " " + PAGE_DATA_PARAMETERS + TENANT_OR_CUSTOMER_AUTHORITY_PARAGRAPH) @PreAuthorize("hasAnyAuthority('TENANT_ADMIN', 'CUSTOMER_USER')") - @RequestMapping(value = "/customer/{customerId}/dashboards", params = {"pageSize", "page"}, method = RequestMethod.GET) - @ResponseBody + @GetMapping(value = "/customer/{customerId}/dashboards", params = {"pageSize", "page"}) public PageData getCustomerDashboards( @Parameter(description = CUSTOMER_ID_PARAM_DESCRIPTION, required = true) @PathVariable(CUSTOMER_ID) String strCustomerId, @@ -454,8 +447,7 @@ public class DashboardController extends BaseController { "If 'homeDashboardId' parameter is not set on the User and Customer levels then checks the same parameter for the Tenant that owns the user. " + TENANT_OR_CUSTOMER_AUTHORITY_PARAGRAPH) @PreAuthorize("hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN', 'CUSTOMER_USER')") - @RequestMapping(value = "/dashboard/home/info", method = RequestMethod.GET) - @ResponseBody + @GetMapping(value = "/dashboard/home/info") public HomeDashboardInfo getHomeDashboardInfo() throws ThingsboardException { SecurityUser securityUser = getCurrentUser(); if (securityUser.isSystemAdmin()) { @@ -470,8 +462,7 @@ public class DashboardController extends BaseController { notes = "Returns the home dashboard info object that is configured as 'homeDashboardId' parameter in the 'additionalInfo' of the corresponding tenant. " + TENANT_AUTHORITY_PARAGRAPH) @PreAuthorize("hasAuthority('TENANT_ADMIN')") - @RequestMapping(value = "/tenant/dashboard/home/info", method = RequestMethod.GET) - @ResponseBody + @GetMapping(value = "/tenant/dashboard/home/info") public HomeDashboardInfo getTenantHomeDashboardInfo() throws ThingsboardException { Tenant tenant = tenantService.findTenantById(getTenantId()); JsonNode additionalInfo = tenant.getAdditionalInfo(); @@ -491,7 +482,7 @@ public class DashboardController extends BaseController { notes = "Update the home dashboard assignment for the current tenant. " + TENANT_AUTHORITY_PARAGRAPH) @PreAuthorize("hasAuthority('TENANT_ADMIN')") - @RequestMapping(value = "/tenant/dashboard/home/info", method = RequestMethod.POST) + @PostMapping(value = "/tenant/dashboard/home/info") @ResponseStatus(value = HttpStatus.OK) public void setTenantHomeDashboardInfo( @Parameter(description = "A JSON object that represents home dashboard id and other parameters", required = true) @@ -540,8 +531,7 @@ public class DashboardController extends BaseController { "Third, once dashboard will be delivered to edge service, it's going to be available for usage on remote edge instance." + TENANT_AUTHORITY_PARAGRAPH) @PreAuthorize("hasAuthority('TENANT_ADMIN')") - @RequestMapping(value = "/edge/{edgeId}/dashboard/{dashboardId}", method = RequestMethod.POST) - @ResponseBody + @PostMapping(value = "/edge/{edgeId}/dashboard/{dashboardId}") public Dashboard assignDashboardToEdge(@PathVariable("edgeId") String strEdgeId, @PathVariable(DASHBOARD_ID) String strDashboardId) throws ThingsboardException { checkParameter("edgeId", strEdgeId); @@ -563,8 +553,7 @@ public class DashboardController extends BaseController { "Third, once 'unassign' command will be delivered to edge service, it's going to remove dashboard locally." + TENANT_AUTHORITY_PARAGRAPH) @PreAuthorize("hasAuthority('TENANT_ADMIN')") - @RequestMapping(value = "/edge/{edgeId}/dashboard/{dashboardId}", method = RequestMethod.DELETE) - @ResponseBody + @DeleteMapping(value = "/edge/{edgeId}/dashboard/{dashboardId}") public Dashboard unassignDashboardFromEdge(@PathVariable("edgeId") String strEdgeId, @PathVariable(DASHBOARD_ID) String strDashboardId) throws ThingsboardException { checkParameter(EDGE_ID, strEdgeId); @@ -583,8 +572,7 @@ public class DashboardController extends BaseController { notes = "Returns a page of dashboard info objects assigned to the specified edge. " + DASHBOARD_INFO_DEFINITION + " " + PAGE_DATA_PARAMETERS + TENANT_OR_CUSTOMER_AUTHORITY_PARAGRAPH) @PreAuthorize("hasAnyAuthority('TENANT_ADMIN', 'CUSTOMER_USER')") - @RequestMapping(value = "/edge/{edgeId}/dashboards", params = {"pageSize", "page"}, method = RequestMethod.GET) - @ResponseBody + @GetMapping(value = "/edge/{edgeId}/dashboards", params = {"pageSize", "page"}) public PageData getEdgeDashboards( @Parameter(description = EDGE_ID_PARAM_DESCRIPTION, required = true) @PathVariable(EDGE_ID) String strEdgeId, diff --git a/application/src/main/java/org/thingsboard/server/controller/TenantController.java b/application/src/main/java/org/thingsboard/server/controller/TenantController.java index 2f8be6589f..83a99714ad 100644 --- a/application/src/main/java/org/thingsboard/server/controller/TenantController.java +++ b/application/src/main/java/org/thingsboard/server/controller/TenantController.java @@ -21,12 +21,13 @@ import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.http.HttpStatus; import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RequestMethod; import org.springframework.web.bind.annotation.RequestParam; -import org.springframework.web.bind.annotation.ResponseBody; import org.springframework.web.bind.annotation.ResponseStatus; import org.springframework.web.bind.annotation.RestController; import org.thingsboard.server.common.data.Tenant; @@ -70,8 +71,7 @@ public class TenantController extends BaseController { @ApiOperation(value = "Get Tenant (getTenantById)", notes = "Fetch the Tenant object based on the provided Tenant Id. " + SYSTEM_OR_TENANT_AUTHORITY_PARAGRAPH) @PreAuthorize("hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN')") - @RequestMapping(value = "/tenant/{tenantId}", method = RequestMethod.GET) - @ResponseBody + @GetMapping(value = "/tenant/{tenantId}") public Tenant getTenantById( @Parameter(description = TENANT_ID_PARAM_DESCRIPTION) @PathVariable(TENANT_ID) String strTenantId) throws ThingsboardException { @@ -86,8 +86,7 @@ public class TenantController extends BaseController { notes = "Fetch the Tenant Info object based on the provided Tenant Id. " + TENANT_INFO_DESCRIPTION + SYSTEM_OR_TENANT_AUTHORITY_PARAGRAPH) @PreAuthorize("hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN')") - @RequestMapping(value = "/tenant/info/{tenantId}", method = RequestMethod.GET) - @ResponseBody + @GetMapping(value = "/tenant/info/{tenantId}") public TenantInfo getTenantInfoById( @Parameter(description = TENANT_ID_PARAM_DESCRIPTION) @PathVariable(TENANT_ID) String strTenantId) throws ThingsboardException { @@ -105,8 +104,7 @@ public class TenantController extends BaseController { "Remove 'id', 'tenantId' from the request body example (below) to create new Tenant entity." + SYSTEM_AUTHORITY_PARAGRAPH) @PreAuthorize("hasAuthority('SYS_ADMIN')") - @RequestMapping(value = "/tenant", method = RequestMethod.POST) - @ResponseBody + @PostMapping(value = "/tenant") public Tenant saveTenant(@Parameter(description = "A JSON value representing the tenant.") @RequestBody Tenant tenant) throws Exception { checkEntity(tenant.getId(), tenant, Resource.TENANT); @@ -116,7 +114,7 @@ public class TenantController extends BaseController { @ApiOperation(value = "Delete Tenant (deleteTenant)", notes = "Deletes the tenant, it's customers, rule chains, devices and all other related entities. Referencing non-existing tenant Id will cause an error." + SYSTEM_AUTHORITY_PARAGRAPH) @PreAuthorize("hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN')") - @RequestMapping(value = "/tenant/{tenantId}", method = RequestMethod.DELETE) + @DeleteMapping(value = "/tenant/{tenantId}") @ResponseStatus(value = HttpStatus.OK) public void deleteTenant(@Parameter(description = TENANT_ID_PARAM_DESCRIPTION) @PathVariable(TENANT_ID) String strTenantId) throws Exception { @@ -128,8 +126,7 @@ public class TenantController extends BaseController { @ApiOperation(value = "Get Tenants (getTenants)", notes = "Returns a page of tenants registered in the platform. " + PAGE_DATA_PARAMETERS + SYSTEM_AUTHORITY_PARAGRAPH) @PreAuthorize("hasAuthority('SYS_ADMIN')") - @RequestMapping(value = "/tenants", params = {"pageSize", "page"}, method = RequestMethod.GET) - @ResponseBody + @GetMapping(value = "/tenants", params = {"pageSize", "page"}) public PageData getTenants( @Parameter(description = PAGE_SIZE_DESCRIPTION, required = true) @RequestParam int pageSize, @@ -148,8 +145,7 @@ public class TenantController extends BaseController { @ApiOperation(value = "Get Tenants Info (getTenants)", notes = "Returns a page of tenant info objects registered in the platform. " + TENANT_INFO_DESCRIPTION + PAGE_DATA_PARAMETERS + SYSTEM_AUTHORITY_PARAGRAPH) @PreAuthorize("hasAuthority('SYS_ADMIN')") - @RequestMapping(value = "/tenantInfos", params = {"pageSize", "page"}, method = RequestMethod.GET) - @ResponseBody + @GetMapping(value = "/tenantInfos", params = {"pageSize", "page"}) public PageData getTenantInfos( @Parameter(description = PAGE_SIZE_DESCRIPTION, required = true) @RequestParam int pageSize, diff --git a/application/src/main/java/org/thingsboard/server/controller/TenantProfileController.java b/application/src/main/java/org/thingsboard/server/controller/TenantProfileController.java index 6c0f70e17c..c2c42eef37 100644 --- a/application/src/main/java/org/thingsboard/server/controller/TenantProfileController.java +++ b/application/src/main/java/org/thingsboard/server/controller/TenantProfileController.java @@ -23,13 +23,13 @@ import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.http.HttpStatus; import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.DeleteMapping; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RequestMethod; import org.springframework.web.bind.annotation.RequestParam; -import org.springframework.web.bind.annotation.ResponseBody; import org.springframework.web.bind.annotation.ResponseStatus; import org.springframework.web.bind.annotation.RestController; import org.thingsboard.server.common.data.EntityInfo; @@ -74,8 +74,7 @@ public class TenantProfileController extends BaseController { @ApiOperation(value = "Get Tenant Profile (getTenantProfileById)", notes = "Fetch the Tenant Profile object based on the provided Tenant Profile Id. " + SYSTEM_AUTHORITY_PARAGRAPH) @PreAuthorize("hasAnyAuthority('SYS_ADMIN')") - @RequestMapping(value = "/tenantProfile/{tenantProfileId}", method = RequestMethod.GET) - @ResponseBody + @GetMapping(value = "/tenantProfile/{tenantProfileId}") public TenantProfile getTenantProfileById( @Parameter(description = TENANT_PROFILE_ID_PARAM_DESCRIPTION) @PathVariable("tenantProfileId") String strTenantProfileId) throws ThingsboardException { @@ -87,8 +86,7 @@ public class TenantProfileController extends BaseController { @ApiOperation(value = "Get Tenant Profile Info (getTenantProfileInfoById)", notes = "Fetch the Tenant Profile Info object based on the provided Tenant Profile Id. " + TENANT_PROFILE_INFO_DESCRIPTION + SYSTEM_AUTHORITY_PARAGRAPH) @PreAuthorize("hasAnyAuthority('SYS_ADMIN')") - @RequestMapping(value = "/tenantProfileInfo/{tenantProfileId}", method = RequestMethod.GET) - @ResponseBody + @GetMapping(value = "/tenantProfileInfo/{tenantProfileId}") public EntityInfo getTenantProfileInfoById( @Parameter(description = TENANT_PROFILE_ID_PARAM_DESCRIPTION) @PathVariable("tenantProfileId") String strTenantProfileId) throws ThingsboardException { @@ -100,8 +98,7 @@ public class TenantProfileController extends BaseController { @ApiOperation(value = "Get default Tenant Profile Info (getDefaultTenantProfileInfo)", notes = "Fetch the default Tenant Profile Info object based. " + TENANT_PROFILE_INFO_DESCRIPTION + SYSTEM_AUTHORITY_PARAGRAPH) @PreAuthorize("hasAnyAuthority('SYS_ADMIN')") - @RequestMapping(value = "/tenantProfileInfo/default", method = RequestMethod.GET) - @ResponseBody + @GetMapping(value = "/tenantProfileInfo/default") public EntityInfo getDefaultTenantProfileInfo() throws ThingsboardException { return checkNotNull(tenantProfileService.findDefaultTenantProfileInfo(getTenantId())); } @@ -180,8 +177,7 @@ public class TenantProfileController extends BaseController { "Remove 'id', from the request body example (below) to create new Tenant Profile entity." + SYSTEM_AUTHORITY_PARAGRAPH) @PreAuthorize("hasAuthority('SYS_ADMIN')") - @RequestMapping(value = "/tenantProfile", method = RequestMethod.POST) - @ResponseBody + @PostMapping(value = "/tenantProfile") public TenantProfile saveTenantProfile(@Parameter(description = "A JSON value representing the tenant profile.") @Valid @RequestBody TenantProfile tenantProfile) throws ThingsboardException { TenantProfile oldProfile; @@ -198,7 +194,7 @@ public class TenantProfileController extends BaseController { @ApiOperation(value = "Delete Tenant Profile (deleteTenantProfile)", notes = "Deletes the tenant profile. Referencing non-existing tenant profile Id will cause an error. Referencing profile that is used by the tenants will cause an error. " + SYSTEM_AUTHORITY_PARAGRAPH) @PreAuthorize("hasAuthority('SYS_ADMIN')") - @RequestMapping(value = "/tenantProfile/{tenantProfileId}", method = RequestMethod.DELETE) + @DeleteMapping(value = "/tenantProfile/{tenantProfileId}") @ResponseStatus(value = HttpStatus.OK) public void deleteTenantProfile(@Parameter(description = TENANT_PROFILE_ID_PARAM_DESCRIPTION) @PathVariable("tenantProfileId") String strTenantProfileId) throws ThingsboardException { @@ -211,8 +207,7 @@ public class TenantProfileController extends BaseController { @ApiOperation(value = "Make tenant profile default (setDefaultTenantProfile)", notes = "Makes specified tenant profile to be default. Referencing non-existing tenant profile Id will cause an error. " + SYSTEM_AUTHORITY_PARAGRAPH) @PreAuthorize("hasAnyAuthority('SYS_ADMIN')") - @RequestMapping(value = "/tenantProfile/{tenantProfileId}/default", method = RequestMethod.POST) - @ResponseBody + @PostMapping(value = "/tenantProfile/{tenantProfileId}/default") public TenantProfile setDefaultTenantProfile( @Parameter(description = TENANT_PROFILE_ID_PARAM_DESCRIPTION) @PathVariable("tenantProfileId") String strTenantProfileId) throws ThingsboardException { @@ -225,8 +220,7 @@ public class TenantProfileController extends BaseController { @ApiOperation(value = "Get Tenant Profiles (getTenantProfiles)", notes = "Returns a page of tenant profiles registered in the platform. " + PAGE_DATA_PARAMETERS + SYSTEM_AUTHORITY_PARAGRAPH) @PreAuthorize("hasAuthority('SYS_ADMIN')") - @RequestMapping(value = "/tenantProfiles", params = {"pageSize", "page"}, method = RequestMethod.GET) - @ResponseBody + @GetMapping(value = "/tenantProfiles", params = {"pageSize", "page"}) public PageData getTenantProfiles( @Parameter(description = PAGE_SIZE_DESCRIPTION, required = true) @RequestParam int pageSize, @@ -245,8 +239,7 @@ public class TenantProfileController extends BaseController { @ApiOperation(value = "Get Tenant Profiles Info (getTenantProfileInfos)", notes = "Returns a page of tenant profile info objects registered in the platform. " + TENANT_PROFILE_INFO_DESCRIPTION + PAGE_DATA_PARAMETERS + SYSTEM_AUTHORITY_PARAGRAPH) @PreAuthorize("hasAuthority('SYS_ADMIN')") - @RequestMapping(value = "/tenantProfileInfos", params = {"pageSize", "page"}, method = RequestMethod.GET) - @ResponseBody + @GetMapping(value = "/tenantProfileInfos", params = {"pageSize", "page"}) public PageData getTenantProfileInfos( @Parameter(description = PAGE_SIZE_DESCRIPTION, required = true) @RequestParam int pageSize, diff --git a/application/src/main/java/org/thingsboard/server/controller/UserController.java b/application/src/main/java/org/thingsboard/server/controller/UserController.java index a2a7c993e9..23ae40fac0 100644 --- a/application/src/main/java/org/thingsboard/server/controller/UserController.java +++ b/application/src/main/java/org/thingsboard/server/controller/UserController.java @@ -33,9 +33,7 @@ import org.springframework.web.bind.annotation.PutMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestHeader; import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RequestMethod; import org.springframework.web.bind.annotation.RequestParam; -import org.springframework.web.bind.annotation.ResponseBody; import org.springframework.web.bind.annotation.ResponseStatus; import org.springframework.web.bind.annotation.RestController; import org.thingsboard.common.util.JacksonUtil; @@ -133,8 +131,7 @@ public class UserController extends BaseController { "If the user has the authority of 'TENANT_ADMIN', the server checks that the requested user is owned by the same tenant. " + "If the user has the authority of 'CUSTOMER_USER', the server checks that the requested user is owned by the same customer.") @PreAuthorize("hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN', 'CUSTOMER_USER')") - @RequestMapping(value = "/user/{userId}", method = RequestMethod.GET) - @ResponseBody + @GetMapping(value = "/user/{userId}") public User getUserById( @Parameter(description = USER_ID_PARAM_DESCRIPTION) @PathVariable(USER_ID) String strUserId) throws ThingsboardException { @@ -150,8 +147,7 @@ public class UserController extends BaseController { "If the user who performs the request has the authority of 'SYS_ADMIN', it is possible to login as any tenant administrator. " + "If the user who performs the request has the authority of 'TENANT_ADMIN', it is possible to login as any customer user. ") @PreAuthorize("hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN')") - @RequestMapping(value = "/user/tokenAccessEnabled", method = RequestMethod.GET) - @ResponseBody + @GetMapping(value = "/user/tokenAccessEnabled") public boolean isUserTokenAccessEnabled() { return userTokenAccessEnabled; } @@ -161,8 +157,7 @@ public class UserController extends BaseController { "If the user who performs the request has the authority of 'SYS_ADMIN', it is possible to get the token of any tenant administrator. " + "If the user who performs the request has the authority of 'TENANT_ADMIN', it is possible to get the token of any customer user that belongs to the same tenant. ") @PreAuthorize("hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN')") - @RequestMapping(value = "/user/{userId}/token", method = RequestMethod.GET) - @ResponseBody + @GetMapping(value = "/user/{userId}/token") public JwtPair getUserToken( @Parameter(description = USER_ID_PARAM_DESCRIPTION) @PathVariable(USER_ID) String strUserId) throws ThingsboardException { @@ -189,8 +184,7 @@ public class UserController extends BaseController { "Remove 'id', 'tenantId' and optionally 'customerId' from the request body example (below) to create new User entity." + "\n\nAvailable for users with 'SYS_ADMIN', 'TENANT_ADMIN' or 'CUSTOMER_USER' authority.") @PreAuthorize("hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN', 'CUSTOMER_USER')") - @RequestMapping(value = "/user", method = RequestMethod.POST) - @ResponseBody + @PostMapping(value = "/user") public User saveUser( @Parameter(description = "A JSON value representing the User.", required = true) @RequestBody User user, @@ -206,7 +200,7 @@ public class UserController extends BaseController { @ApiOperation(value = "Send or re-send the activation email", notes = "Force send the activation email to the user. Useful to resend the email if user has accidentally deleted it. " + SYSTEM_OR_TENANT_AUTHORITY_PARAGRAPH) @PreAuthorize("hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN')") - @RequestMapping(value = "/user/sendActivationMail", method = RequestMethod.POST) + @PostMapping(value = "/user/sendActivationMail") @ResponseStatus(value = HttpStatus.OK) public void sendActivationEmail( @Parameter(description = "Email of the user", required = true) @@ -229,7 +223,6 @@ public class UserController extends BaseController { "The base url for activation link is configurable in the general settings of system administrator. " + SYSTEM_OR_TENANT_AUTHORITY_PARAGRAPH) @PreAuthorize("hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN')") @GetMapping(value = "/user/{userId}/activationLink", produces = "text/plain") - @ResponseBody public String getActivationLink(@Parameter(description = USER_ID_PARAM_DESCRIPTION) @PathVariable(USER_ID) String strUserId, HttpServletRequest request) throws ThingsboardException { @@ -255,7 +248,7 @@ public class UserController extends BaseController { notes = "Deletes the User, it's credentials and all the relations (from and to the User). " + "Referencing non-existing User Id will cause an error. " + SYSTEM_OR_TENANT_AUTHORITY_PARAGRAPH) @PreAuthorize("hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN')") - @RequestMapping(value = "/user/{userId}", method = RequestMethod.DELETE) + @DeleteMapping(value = "/user/{userId}") @ResponseStatus(value = HttpStatus.OK) public void deleteUser( @Parameter(description = USER_ID_PARAM_DESCRIPTION) @@ -276,8 +269,7 @@ public class UserController extends BaseController { notes = "Returns a page of users owned by tenant or customer. The scope depends on authority of the user that performs the request." + PAGE_DATA_PARAMETERS + TENANT_OR_CUSTOMER_AUTHORITY_PARAGRAPH) @PreAuthorize("hasAnyAuthority('TENANT_ADMIN', 'CUSTOMER_USER')") - @RequestMapping(value = "/users", params = {"pageSize", "page"}, method = RequestMethod.GET) - @ResponseBody + @GetMapping(value = "/users", params = {"pageSize", "page"}) public PageData getUsers( @Parameter(description = PAGE_SIZE_DESCRIPTION, required = true) @RequestParam int pageSize, @@ -302,8 +294,7 @@ public class UserController extends BaseController { notes = "Returns page of user data objects. Search is been executed by email, firstName and " + "lastName fields. " + PAGE_DATA_PARAMETERS + TENANT_OR_CUSTOMER_AUTHORITY_PARAGRAPH) @PreAuthorize("hasAnyAuthority('TENANT_ADMIN', 'CUSTOMER_USER')") - @RequestMapping(value = "/users/info", method = RequestMethod.GET) - @ResponseBody + @GetMapping(value = "/users/info") public PageData findUsersByQuery( @Parameter(description = PAGE_SIZE_DESCRIPTION, required = true) @RequestParam int pageSize, @@ -339,8 +330,7 @@ public class UserController extends BaseController { @ApiOperation(value = "Get Tenant Users (getTenantAdmins)", notes = "Returns a page of users owned by tenant. " + PAGE_DATA_PARAMETERS + SYSTEM_AUTHORITY_PARAGRAPH) @PreAuthorize("hasAuthority('SYS_ADMIN')") - @RequestMapping(value = "/tenant/{tenantId}/users", params = {"pageSize", "page"}, method = RequestMethod.GET) - @ResponseBody + @GetMapping(value = "/tenant/{tenantId}/users", params = {"pageSize", "page"}) public PageData getTenantAdmins( @Parameter(description = TENANT_ID_PARAM_DESCRIPTION, required = true) @PathVariable(TENANT_ID) String strTenantId, @@ -363,8 +353,7 @@ public class UserController extends BaseController { @ApiOperation(value = "Get Customer Users (getCustomerUsers)", notes = "Returns a page of users owned by customer. " + PAGE_DATA_PARAMETERS + TENANT_AUTHORITY_PARAGRAPH) @PreAuthorize("hasAuthority('TENANT_ADMIN')") - @RequestMapping(value = "/customer/{customerId}/users", params = {"pageSize", "page"}, method = RequestMethod.GET) - @ResponseBody + @GetMapping(value = "/customer/{customerId}/users", params = {"pageSize", "page"}) public PageData getCustomerUsers( @Parameter(description = CUSTOMER_ID_PARAM_DESCRIPTION, required = true) @PathVariable(CUSTOMER_ID) String strCustomerId, @@ -389,8 +378,7 @@ public class UserController extends BaseController { @ApiOperation(value = "Enable/Disable User credentials (setUserCredentialsEnabled)", notes = "Enables or Disables user credentials. Useful when you would like to block user account without deleting it. " + PAGE_DATA_PARAMETERS + TENANT_AUTHORITY_PARAGRAPH) @PreAuthorize("hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN')") - @RequestMapping(value = "/user/{userId}/userCredentialsEnabled", method = RequestMethod.POST) - @ResponseBody + @PostMapping(value = "/user/{userId}/userCredentialsEnabled") public void setUserCredentialsEnabled( @Parameter(description = USER_ID_PARAM_DESCRIPTION) @PathVariable(USER_ID) String strUserId, @@ -398,7 +386,7 @@ public class UserController extends BaseController { @RequestParam(required = false, defaultValue = "true") boolean userCredentialsEnabled) throws ThingsboardException { checkParameter(USER_ID, strUserId); UserId userId = new UserId(toUUID(strUserId)); - User user = checkUserId(userId, Operation.WRITE); + checkUserId(userId, Operation.WRITE); TenantId tenantId = getCurrentUser().getTenantId(); userService.setUserCredentialsEnabled(tenantId, userId, userCredentialsEnabled); @@ -412,8 +400,7 @@ public class UserController extends BaseController { "Search is been executed by email, firstName and lastName fields. " + PAGE_DATA_PARAMETERS + TENANT_OR_CUSTOMER_AUTHORITY_PARAGRAPH) @PreAuthorize("hasAnyAuthority('TENANT_ADMIN', 'CUSTOMER_USER')") - @RequestMapping(value = "/users/assign/{alarmId}", params = {"pageSize", "page"}, method = RequestMethod.GET) - @ResponseBody + @GetMapping(value = "/users/assign/{alarmId}", params = {"pageSize", "page"}) public PageData getUsersForAssign( @Parameter(description = ALARM_ID_PARAM_DESCRIPTION, required = true) @PathVariable("alarmId") String strAlarmId, @@ -491,7 +478,7 @@ public class UserController extends BaseController { notes = "Delete user settings by specifying list of json element xpaths. \n " + "Example: to delete B and C element in { \"A\": {\"B\": 5}, \"C\": 15} send A.B,C in jsonPaths request parameter") @PreAuthorize("hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN', 'CUSTOMER_USER')") - @RequestMapping(value = "/user/settings/{paths}", method = RequestMethod.DELETE) + @DeleteMapping(value = "/user/settings/{paths}") public void deleteUserSettings(@Parameter(description = PATHS) @PathVariable(PATHS) String paths) throws ThingsboardException { checkParameter(USER_ID, paths); @@ -531,7 +518,7 @@ public class UserController extends BaseController { notes = "Delete user settings by specifying list of json element xpaths. \n " + "Example: to delete B and C element in { \"A\": {\"B\": 5}, \"C\": 15} send A.B,C in jsonPaths request parameter") @PreAuthorize("hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN', 'CUSTOMER_USER')") - @RequestMapping(value = "/user/settings/{type}/{paths}", method = RequestMethod.DELETE) + @DeleteMapping(value = "/user/settings/{type}/{paths}") public void deleteUserSettings(@Parameter(description = PATHS) @PathVariable(PATHS) String paths, @Parameter(description = "Settings type, case insensitive, one of: \"general\", \"quick_links\", \"doc_links\" or \"dashboards\".") @@ -555,8 +542,7 @@ public class UserController extends BaseController { @ApiOperation(value = "Report action of User over the dashboard (reportUserDashboardAction)", notes = "Report action of User over the dashboard. " + TENANT_OR_CUSTOMER_AUTHORITY_PARAGRAPH) @PreAuthorize("hasAnyAuthority('TENANT_ADMIN', 'CUSTOMER_USER')") - @RequestMapping(value = "/user/dashboards/{dashboardId}/{action}", method = RequestMethod.GET) - @ResponseBody + @GetMapping(value = "/user/dashboards/{dashboardId}/{action}") public UserDashboardsInfo reportUserDashboardAction( @Parameter(description = DASHBOARD_ID_PARAM_DESCRIPTION) @PathVariable(DashboardController.DASHBOARD_ID) String strDashboardId, From 0ef4fa3b0ba20be755d6e4aaa6145f2d66e2c2a3 Mon Sep 17 00:00:00 2001 From: Andrii Landiak Date: Fri, 21 Nov 2025 10:47:51 +0200 Subject: [PATCH 08/54] Refactoring controllers --- .../controller/EntityViewController.java | 69 +++++++------------ .../server/controller/TbUrlConstants.java | 3 - .../controller/TelemetryController.java | 44 ++++-------- .../controller/TenantProfileController.java | 1 - .../controller/UiSettingsController.java | 9 +-- .../controller/UsageInfoController.java | 7 +- .../controller/WidgetTypeController.java | 42 ++++------- .../controller/WidgetsBundleController.java | 23 +++---- .../controller/BaseQueueControllerTest.java | 12 ++-- .../controller/ImageControllerTest.java | 2 +- .../controller/TenantControllerTest.java | 14 ++-- 11 files changed, 84 insertions(+), 142 deletions(-) diff --git a/application/src/main/java/org/thingsboard/server/controller/EntityViewController.java b/application/src/main/java/org/thingsboard/server/controller/EntityViewController.java index 67fc02ab29..3b5e25b628 100644 --- a/application/src/main/java/org/thingsboard/server/controller/EntityViewController.java +++ b/application/src/main/java/org/thingsboard/server/controller/EntityViewController.java @@ -22,12 +22,13 @@ import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.http.HttpStatus; import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RequestMethod; import org.springframework.web.bind.annotation.RequestParam; -import org.springframework.web.bind.annotation.ResponseBody; import org.springframework.web.bind.annotation.ResponseStatus; import org.springframework.web.bind.annotation.RestController; import org.thingsboard.server.common.data.Customer; @@ -84,9 +85,6 @@ import static org.thingsboard.server.controller.ControllerConstants.TENANT_OR_CU import static org.thingsboard.server.controller.ControllerConstants.UNIQUIFY_STRATEGY_DESC; import static org.thingsboard.server.controller.EdgeController.EDGE_ID; -/** - * Created by Victor Basanets on 8/28/2017. - */ @RestController @TbCoreComponent @RequiredArgsConstructor @@ -102,8 +100,7 @@ public class EntityViewController extends BaseController { notes = "Fetch the EntityView object based on the provided entity view id. " + ENTITY_VIEW_DESCRIPTION + MODEL_DESCRIPTION + TENANT_OR_CUSTOMER_AUTHORITY_PARAGRAPH) @PreAuthorize("hasAnyAuthority('TENANT_ADMIN', 'CUSTOMER_USER')") - @RequestMapping(value = "/entityView/{entityViewId}", method = RequestMethod.GET) - @ResponseBody + @GetMapping(value = "/entityView/{entityViewId}") public EntityView getEntityViewById( @Parameter(description = ENTITY_VIEW_ID_PARAM_DESCRIPTION) @PathVariable(ENTITY_VIEW_ID) String strEntityViewId) throws ThingsboardException { @@ -115,8 +112,7 @@ public class EntityViewController extends BaseController { notes = "Fetch the Entity View info object based on the provided Entity View Id. " + ENTITY_VIEW_INFO_DESCRIPTION + MODEL_DESCRIPTION + TENANT_OR_CUSTOMER_AUTHORITY_PARAGRAPH) @PreAuthorize("hasAnyAuthority('TENANT_ADMIN', 'CUSTOMER_USER')") - @RequestMapping(value = "/entityView/info/{entityViewId}", method = RequestMethod.GET) - @ResponseBody + @GetMapping(value = "/entityView/info/{entityViewId}") public EntityViewInfo getEntityViewInfoById( @Parameter(description = ENTITY_VIEW_ID_PARAM_DESCRIPTION) @PathVariable(ENTITY_VIEW_ID) String strEntityViewId) throws ThingsboardException { @@ -130,8 +126,7 @@ public class EntityViewController extends BaseController { "Remove 'id', 'tenantId' and optionally 'customerId' from the request body example (below) to create new Entity View entity." + TENANT_OR_CUSTOMER_AUTHORITY_PARAGRAPH) @PreAuthorize("hasAnyAuthority('TENANT_ADMIN', 'CUSTOMER_USER')") - @RequestMapping(value = "/entityView", method = RequestMethod.POST) - @ResponseBody + @PostMapping(value = "/entityView") public EntityView saveEntityView( @Parameter(description = "A JSON object representing the entity view.") @RequestBody EntityView entityView, @@ -156,7 +151,7 @@ public class EntityViewController extends BaseController { notes = "Delete the EntityView object based on the provided entity view id. " + TENANT_AUTHORITY_PARAGRAPH) @PreAuthorize("hasAuthority('TENANT_ADMIN')") - @RequestMapping(value = "/entityView/{entityViewId}", method = RequestMethod.DELETE) + @DeleteMapping(value = "/entityView/{entityViewId}") @ResponseStatus(value = HttpStatus.OK) public void deleteEntityView( @Parameter(description = ENTITY_VIEW_ID_PARAM_DESCRIPTION) @@ -170,8 +165,7 @@ public class EntityViewController extends BaseController { @ApiOperation(value = "Get Entity View by name (getTenantEntityView)", notes = "Fetch the Entity View object based on the tenant id and entity view name. " + TENANT_AUTHORITY_PARAGRAPH) @PreAuthorize("hasAuthority('TENANT_ADMIN')") - @RequestMapping(value = "/tenant/entityViews", params = {"entityViewName"}, method = RequestMethod.GET) - @ResponseBody + @GetMapping(value = "/tenant/entityViews", params = {"entityViewName"}) public EntityView getTenantEntityView( @Parameter(description = "Entity View name") @RequestParam String entityViewName) throws ThingsboardException { @@ -182,8 +176,7 @@ public class EntityViewController extends BaseController { @ApiOperation(value = "Assign Entity View to customer (assignEntityViewToCustomer)", notes = "Creates assignment of the Entity View to customer. Customer will be able to query Entity View afterwards." + TENANT_AUTHORITY_PARAGRAPH) @PreAuthorize("hasAuthority('TENANT_ADMIN')") - @RequestMapping(value = "/customer/{customerId}/entityView/{entityViewId}", method = RequestMethod.POST) - @ResponseBody + @PostMapping(value = "/customer/{customerId}/entityView/{entityViewId}") public EntityView assignEntityViewToCustomer( @Parameter(description = CUSTOMER_ID_PARAM_DESCRIPTION) @PathVariable(CUSTOMER_ID) String strCustomerId, @@ -204,8 +197,7 @@ public class EntityViewController extends BaseController { @ApiOperation(value = "Unassign Entity View from customer (unassignEntityViewFromCustomer)", notes = "Clears assignment of the Entity View to customer. Customer will not be able to query Entity View afterwards." + TENANT_AUTHORITY_PARAGRAPH) @PreAuthorize("hasAuthority('TENANT_ADMIN')") - @RequestMapping(value = "/customer/entityView/{entityViewId}", method = RequestMethod.DELETE) - @ResponseBody + @DeleteMapping(value = "/customer/entityView/{entityViewId}") public EntityView unassignEntityViewFromCustomer( @Parameter(description = ENTITY_VIEW_ID_PARAM_DESCRIPTION) @PathVariable(ENTITY_VIEW_ID) String strEntityViewId) throws ThingsboardException { @@ -225,8 +217,7 @@ public class EntityViewController extends BaseController { notes = "Returns a page of Entity View objects assigned to customer. " + PAGE_DATA_PARAMETERS + TENANT_OR_CUSTOMER_AUTHORITY_PARAGRAPH) @PreAuthorize("hasAnyAuthority('TENANT_ADMIN', 'CUSTOMER_USER')") - @RequestMapping(value = "/customer/{customerId}/entityViews", params = {"pageSize", "page"}, method = RequestMethod.GET) - @ResponseBody + @GetMapping(value = "/customer/{customerId}/entityViews", params = {"pageSize", "page"}) public PageData getCustomerEntityViews( @Parameter(description = CUSTOMER_ID_PARAM_DESCRIPTION, required = true) @PathVariable(CUSTOMER_ID) String strCustomerId, @@ -247,7 +238,7 @@ public class EntityViewController extends BaseController { CustomerId customerId = new CustomerId(toUUID(strCustomerId)); checkCustomerId(customerId, Operation.READ); PageLink pageLink = createPageLink(pageSize, page, textSearch, sortProperty, sortOrder); - if (type != null && type.trim().length() > 0) { + if (type != null && !type.trim().isEmpty()) { return checkNotNull(entityViewService.findEntityViewsByTenantIdAndCustomerIdAndType(tenantId, customerId, pageLink, type)); } else { return checkNotNull(entityViewService.findEntityViewsByTenantIdAndCustomerId(tenantId, customerId, pageLink)); @@ -258,8 +249,7 @@ public class EntityViewController extends BaseController { notes = "Returns a page of Entity View info objects assigned to customer. " + ENTITY_VIEW_DESCRIPTION + PAGE_DATA_PARAMETERS + TENANT_OR_CUSTOMER_AUTHORITY_PARAGRAPH) @PreAuthorize("hasAnyAuthority('TENANT_ADMIN', 'CUSTOMER_USER')") - @RequestMapping(value = "/customer/{customerId}/entityViewInfos", params = {"pageSize", "page"}, method = RequestMethod.GET) - @ResponseBody + @GetMapping(value = "/customer/{customerId}/entityViewInfos", params = {"pageSize", "page"}) public PageData getCustomerEntityViewInfos( @Parameter(description = CUSTOMER_ID_PARAM_DESCRIPTION, required = true) @PathVariable(CUSTOMER_ID) String strCustomerId, @@ -280,7 +270,7 @@ public class EntityViewController extends BaseController { CustomerId customerId = new CustomerId(toUUID(strCustomerId)); checkCustomerId(customerId, Operation.READ); PageLink pageLink = createPageLink(pageSize, page, textSearch, sortProperty, sortOrder); - if (type != null && type.trim().length() > 0) { + if (type != null && !type.trim().isEmpty()) { return checkNotNull(entityViewService.findEntityViewInfosByTenantIdAndCustomerIdAndType(tenantId, customerId, type, pageLink)); } else { return checkNotNull(entityViewService.findEntityViewInfosByTenantIdAndCustomerId(tenantId, customerId, pageLink)); @@ -291,8 +281,7 @@ public class EntityViewController extends BaseController { notes = "Returns a page of entity views owned by tenant. " + ENTITY_VIEW_DESCRIPTION + PAGE_DATA_PARAMETERS + TENANT_AUTHORITY_PARAGRAPH) @PreAuthorize("hasAuthority('TENANT_ADMIN')") - @RequestMapping(value = "/tenant/entityViews", params = {"pageSize", "page"}, method = RequestMethod.GET) - @ResponseBody + @GetMapping(value = "/tenant/entityViews", params = {"pageSize", "page"}) public PageData getTenantEntityViews( @Parameter(description = PAGE_SIZE_DESCRIPTION, required = true) @RequestParam int pageSize, @@ -309,7 +298,7 @@ public class EntityViewController extends BaseController { TenantId tenantId = getCurrentUser().getTenantId(); PageLink pageLink = createPageLink(pageSize, page, textSearch, sortProperty, sortOrder); - if (type != null && type.trim().length() > 0) { + if (type != null && !type.trim().isEmpty()) { return checkNotNull(entityViewService.findEntityViewByTenantIdAndType(tenantId, pageLink, type)); } else { return checkNotNull(entityViewService.findEntityViewByTenantId(tenantId, pageLink)); @@ -320,8 +309,7 @@ public class EntityViewController extends BaseController { notes = "Returns a page of entity views info owned by tenant. " + ENTITY_VIEW_DESCRIPTION + PAGE_DATA_PARAMETERS + TENANT_AUTHORITY_PARAGRAPH) @PreAuthorize("hasAuthority('TENANT_ADMIN')") - @RequestMapping(value = "/tenant/entityViewInfos", params = {"pageSize", "page"}, method = RequestMethod.GET) - @ResponseBody + @GetMapping(value = "/tenant/entityViewInfos", params = {"pageSize", "page"}) public PageData getTenantEntityViewInfos( @Parameter(description = PAGE_SIZE_DESCRIPTION, required = true) @RequestParam int pageSize, @@ -337,7 +325,7 @@ public class EntityViewController extends BaseController { @RequestParam(required = false) String sortOrder) throws ThingsboardException { TenantId tenantId = getCurrentUser().getTenantId(); PageLink pageLink = createPageLink(pageSize, page, textSearch, sortProperty, sortOrder); - if (type != null && type.trim().length() > 0) { + if (type != null && !type.trim().isEmpty()) { return checkNotNull(entityViewService.findEntityViewInfosByTenantIdAndType(tenantId, type, pageLink)); } else { return checkNotNull(entityViewService.findEntityViewInfosByTenantId(tenantId, pageLink)); @@ -349,8 +337,7 @@ public class EntityViewController extends BaseController { "The entity id, relation type, entity view types, depth of the search, and other query parameters defined using complex 'EntityViewSearchQuery' object. " + "See 'Model' tab of the Parameters for more info." + TENANT_OR_CUSTOMER_AUTHORITY_PARAGRAPH) @PreAuthorize("hasAnyAuthority('TENANT_ADMIN', 'CUSTOMER_USER')") - @RequestMapping(value = "/entityViews", method = RequestMethod.POST) - @ResponseBody + @PostMapping(value = "/entityViews") public List findByQuery( @Parameter(description = "The entity view search query JSON") @RequestBody EntityViewSearchQuery query) throws ThingsboardException, ExecutionException, InterruptedException { @@ -374,8 +361,7 @@ public class EntityViewController extends BaseController { notes = "Returns a set of unique entity view types based on entity views that are either owned by the tenant or assigned to the customer which user is performing the request." + TENANT_OR_CUSTOMER_AUTHORITY_PARAGRAPH) @PreAuthorize("hasAnyAuthority('TENANT_ADMIN', 'CUSTOMER_USER')") - @RequestMapping(value = "/entityView/types", method = RequestMethod.GET) - @ResponseBody + @GetMapping(value = "/entityView/types") public List getEntityViewTypes() throws ThingsboardException, ExecutionException, InterruptedException { SecurityUser user = getCurrentUser(); TenantId tenantId = user.getTenantId(); @@ -388,8 +374,7 @@ public class EntityViewController extends BaseController { "This is useful to create dashboards that you plan to share/embed on a publicly available website. " + "However, users that are logged-in and belong to different tenant will not be able to access the entity view." + TENANT_AUTHORITY_PARAGRAPH) @PreAuthorize("hasAuthority('TENANT_ADMIN')") - @RequestMapping(value = "/customer/public/entityView/{entityViewId}", method = RequestMethod.POST) - @ResponseBody + @PostMapping(value = "/customer/public/entityView/{entityViewId}") public EntityView assignEntityViewToPublicCustomer( @Parameter(description = ENTITY_VIEW_ID_PARAM_DESCRIPTION) @PathVariable(ENTITY_VIEW_ID) String strEntityViewId) throws ThingsboardException { @@ -406,8 +391,7 @@ public class EntityViewController extends BaseController { EDGE_ASSIGN_RECEIVE_STEP_DESCRIPTION + "Third, once entity view will be delivered to edge service, it's going to be available for usage on remote edge instance.") @PreAuthorize("hasAuthority('TENANT_ADMIN')") - @RequestMapping(value = "/edge/{edgeId}/entityView/{entityViewId}", method = RequestMethod.POST) - @ResponseBody + @PostMapping(value = "/edge/{edgeId}/entityView/{entityViewId}") public EntityView assignEntityViewToEdge(@PathVariable(EDGE_ID) String strEdgeId, @PathVariable(ENTITY_VIEW_ID) String strEntityViewId) throws ThingsboardException { checkParameter(EDGE_ID, strEdgeId); @@ -430,8 +414,7 @@ public class EntityViewController extends BaseController { EDGE_UNASSIGN_RECEIVE_STEP_DESCRIPTION + "Third, once 'unassign' command will be delivered to edge service, it's going to remove entity view locally.") @PreAuthorize("hasAuthority('TENANT_ADMIN')") - @RequestMapping(value = "/edge/{edgeId}/entityView/{entityViewId}", method = RequestMethod.DELETE) - @ResponseBody + @DeleteMapping(value = "/edge/{edgeId}/entityView/{entityViewId}") public EntityView unassignEntityViewFromEdge(@PathVariable(EDGE_ID) String strEdgeId, @PathVariable(ENTITY_VIEW_ID) String strEntityViewId) throws ThingsboardException { checkParameter(EDGE_ID, strEdgeId); @@ -448,8 +431,7 @@ public class EntityViewController extends BaseController { } @PreAuthorize("hasAnyAuthority('TENANT_ADMIN', 'CUSTOMER_USER')") - @RequestMapping(value = "/edge/{edgeId}/entityViews", params = {"pageSize", "page"}, method = RequestMethod.GET) - @ResponseBody + @GetMapping(value = "/edge/{edgeId}/entityViews", params = {"pageSize", "page"}) public PageData getEdgeEntityViews( @PathVariable(EDGE_ID) String strEdgeId, @RequestParam int pageSize, @@ -466,7 +448,7 @@ public class EntityViewController extends BaseController { checkEdgeId(edgeId, Operation.READ); TimePageLink pageLink = createTimePageLink(pageSize, page, textSearch, sortProperty, sortOrder, startTime, endTime); PageData nonFilteredResult; - if (type != null && type.trim().length() > 0) { + if (type != null && !type.trim().isEmpty()) { nonFilteredResult = entityViewService.findEntityViewsByTenantIdAndEdgeIdAndType(tenantId, edgeId, type, pageLink); } else { nonFilteredResult = entityViewService.findEntityViewsByTenantIdAndEdgeId(tenantId, edgeId, pageLink); @@ -485,4 +467,5 @@ public class EntityViewController extends BaseController { nonFilteredResult.hasNext()); return checkNotNull(filteredResult); } + } diff --git a/application/src/main/java/org/thingsboard/server/controller/TbUrlConstants.java b/application/src/main/java/org/thingsboard/server/controller/TbUrlConstants.java index 6368954e48..ec9bb01f61 100644 --- a/application/src/main/java/org/thingsboard/server/controller/TbUrlConstants.java +++ b/application/src/main/java/org/thingsboard/server/controller/TbUrlConstants.java @@ -15,9 +15,6 @@ */ package org.thingsboard.server.controller; -/** - * Created by ashvayka on 17.05.18. - */ public class TbUrlConstants { public static final String TELEMETRY_URL_PREFIX = "/api/plugins/telemetry"; public static final String RPC_V1_URL_PREFIX = "/api/plugins/rpc"; diff --git a/application/src/main/java/org/thingsboard/server/controller/TelemetryController.java b/application/src/main/java/org/thingsboard/server/controller/TelemetryController.java index 2f526e2037..d37fe2d81a 100644 --- a/application/src/main/java/org/thingsboard/server/controller/TelemetryController.java +++ b/application/src/main/java/org/thingsboard/server/controller/TelemetryController.java @@ -37,13 +37,13 @@ import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.util.MultiValueMap; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RequestMethod; import org.springframework.web.bind.annotation.RequestParam; -import org.springframework.web.bind.annotation.ResponseBody; import org.springframework.web.bind.annotation.RestController; import org.springframework.web.context.request.async.DeferredResult; import org.thingsboard.common.util.JacksonUtil; @@ -131,10 +131,6 @@ import static org.thingsboard.server.controller.ControllerConstants.TELEMETRY_SC import static org.thingsboard.server.controller.ControllerConstants.TENANT_OR_CUSTOMER_AUTHORITY_PARAGRAPH; import static org.thingsboard.server.controller.ControllerConstants.TS_STRICT_DATA_EXAMPLE; - -/** - * Created by ashvayka on 22.03.18. - */ @RestController @TbCoreComponent @RequestMapping(TbUrlConstants.TELEMETRY_URL_PREFIX) @@ -172,8 +168,7 @@ public class TelemetryController extends BaseController { "\n * SHARED_SCOPE - supported for devices. " + "\n\n" + INVALID_ENTITY_ID_OR_ENTITY_TYPE_DESCRIPTION + TENANT_OR_CUSTOMER_AUTHORITY_PARAGRAPH) @PreAuthorize("hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN', 'CUSTOMER_USER')") - @RequestMapping(value = "/{entityType}/{entityId}/keys/attributes", method = RequestMethod.GET) - @ResponseBody + @GetMapping(value = "/{entityType}/{entityId}/keys/attributes") public DeferredResult getAttributeKeys( @Parameter(description = ENTITY_TYPE_PARAM_DESCRIPTION, required = true, schema = @Schema(defaultValue = "DEVICE")) @PathVariable("entityType") String entityType, @Parameter(description = ENTITY_ID_PARAM_DESCRIPTION, required = true) @PathVariable("entityId") String entityIdStr) throws ThingsboardException { @@ -187,8 +182,7 @@ public class TelemetryController extends BaseController { "\n * SHARED_SCOPE - supported for devices. " + "\n\n" + INVALID_ENTITY_ID_OR_ENTITY_TYPE_DESCRIPTION + TENANT_OR_CUSTOMER_AUTHORITY_PARAGRAPH) @PreAuthorize("hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN', 'CUSTOMER_USER')") - @RequestMapping(value = "/{entityType}/{entityId}/keys/attributes/{scope}", method = RequestMethod.GET) - @ResponseBody + @GetMapping(value = "/{entityType}/{entityId}/keys/attributes/{scope}") public DeferredResult getAttributeKeysByScope( @Parameter(description = ENTITY_TYPE_PARAM_DESCRIPTION, required = true, schema = @Schema(defaultValue = "DEVICE")) @PathVariable("entityType") String entityType, @Parameter(description = ENTITY_ID_PARAM_DESCRIPTION, required = true) @PathVariable("entityId") String entityIdStr, @@ -205,8 +199,7 @@ public class TelemetryController extends BaseController { + MARKDOWN_CODE_BLOCK_END + "\n\n " + INVALID_ENTITY_ID_OR_ENTITY_TYPE_DESCRIPTION + TENANT_OR_CUSTOMER_AUTHORITY_PARAGRAPH) @PreAuthorize("hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN', 'CUSTOMER_USER')") - @RequestMapping(value = "/{entityType}/{entityId}/values/attributes", method = RequestMethod.GET) - @ResponseBody + @GetMapping(value = "/{entityType}/{entityId}/values/attributes") public DeferredResult getAttributes( @Parameter(description = ENTITY_TYPE_PARAM_DESCRIPTION, required = true, schema = @Schema(defaultValue = "DEVICE")) @PathVariable("entityType") String entityType, @Parameter(description = ENTITY_ID_PARAM_DESCRIPTION, required = true) @PathVariable("entityId") String entityIdStr, @@ -229,8 +222,7 @@ public class TelemetryController extends BaseController { + MARKDOWN_CODE_BLOCK_END + "\n\n " + INVALID_ENTITY_ID_OR_ENTITY_TYPE_DESCRIPTION + TENANT_OR_CUSTOMER_AUTHORITY_PARAGRAPH) @PreAuthorize("hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN', 'CUSTOMER_USER')") - @RequestMapping(value = "/{entityType}/{entityId}/values/attributes/{scope}", method = RequestMethod.GET) - @ResponseBody + @GetMapping(value = "/{entityType}/{entityId}/values/attributes/{scope}") public DeferredResult getAttributesByScope( @Parameter(description = ENTITY_TYPE_PARAM_DESCRIPTION, required = true, schema = @Schema(defaultValue = "DEVICE")) @PathVariable("entityType") String entityType, @Parameter(description = ENTITY_ID_PARAM_DESCRIPTION, required = true) @PathVariable("entityId") String entityIdStr, @@ -247,8 +239,7 @@ public class TelemetryController extends BaseController { notes = "Returns a set of unique time series key names for the selected entity. " + "\n\n" + INVALID_ENTITY_ID_OR_ENTITY_TYPE_DESCRIPTION + TENANT_OR_CUSTOMER_AUTHORITY_PARAGRAPH) @PreAuthorize("hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN', 'CUSTOMER_USER')") - @RequestMapping(value = "/{entityType}/{entityId}/keys/timeseries", method = RequestMethod.GET) - @ResponseBody + @GetMapping(value = "/{entityType}/{entityId}/keys/timeseries") public DeferredResult getTimeseriesKeys( @Parameter(description = ENTITY_TYPE_PARAM_DESCRIPTION, required = true, schema = @Schema(defaultValue = "DEVICE")) @PathVariable("entityType") String entityType, @Parameter(description = ENTITY_ID_PARAM_DESCRIPTION, required = true) @PathVariable("entityId") String entityIdStr) throws ThingsboardException { @@ -269,8 +260,7 @@ public class TelemetryController extends BaseController { + MARKDOWN_CODE_BLOCK_END + "\n\n " + INVALID_ENTITY_ID_OR_ENTITY_TYPE_DESCRIPTION + TENANT_OR_CUSTOMER_AUTHORITY_PARAGRAPH) @PreAuthorize("hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN', 'CUSTOMER_USER')") - @RequestMapping(value = "/{entityType}/{entityId}/values/timeseries", method = RequestMethod.GET) - @ResponseBody + @GetMapping(value = "/{entityType}/{entityId}/values/timeseries") public DeferredResult getLatestTimeseries( @Parameter(description = ENTITY_TYPE_PARAM_DESCRIPTION, required = true, schema = @Schema(defaultValue = "DEVICE")) @PathVariable("entityType") String entityType, @Parameter(description = ENTITY_ID_PARAM_DESCRIPTION, required = true) @PathVariable("entityId") String entityIdStr, @@ -294,8 +284,7 @@ public class TelemetryController extends BaseController { + MARKDOWN_CODE_BLOCK_END + "\n\n" + INVALID_ENTITY_ID_OR_ENTITY_TYPE_DESCRIPTION + TENANT_OR_CUSTOMER_AUTHORITY_PARAGRAPH) @PreAuthorize("hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN', 'CUSTOMER_USER')") - @RequestMapping(value = "/{entityType}/{entityId}/values/timeseries", method = RequestMethod.GET, params = {"keys", "startTs", "endTs"}) - @ResponseBody + @GetMapping(value = "/{entityType}/{entityId}/values/timeseries", params = {"keys", "startTs", "endTs"}) public DeferredResult getTimeseries( @Parameter(description = ENTITY_TYPE_PARAM_DESCRIPTION, required = true, schema = @Schema(defaultValue = "DEVICE")) @PathVariable("entityType") String entityType, @Parameter(description = ENTITY_ID_PARAM_DESCRIPTION, required = true) @PathVariable("entityId") String entityIdStr, @@ -418,8 +407,7 @@ public class TelemetryController extends BaseController { @ApiResponse(responseCode = "500", description = SAVE_ENTITY_TIMESERIES_STATUS_INTERNAL_SERVER_ERROR), }) @PreAuthorize("hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN', 'CUSTOMER_USER')") - @RequestMapping(value = "/{entityType}/{entityId}/timeseries/{scope}", method = RequestMethod.POST) - @ResponseBody + @PostMapping(value = "/{entityType}/{entityId}/timeseries/{scope}") public DeferredResult saveEntityTelemetry( @Parameter(description = ENTITY_TYPE_PARAM_DESCRIPTION, required = true, schema = @Schema(defaultValue = "DEVICE")) @PathVariable("entityType") String entityType, @Parameter(description = ENTITY_ID_PARAM_DESCRIPTION, required = true) @PathVariable("entityId") String entityIdStr, @@ -442,8 +430,7 @@ public class TelemetryController extends BaseController { @ApiResponse(responseCode = "500", description = SAVE_ENTITY_TIMESERIES_STATUS_INTERNAL_SERVER_ERROR), }) @PreAuthorize("hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN', 'CUSTOMER_USER')") - @RequestMapping(value = "/{entityType}/{entityId}/timeseries/{scope}/{ttl}", method = RequestMethod.POST) - @ResponseBody + @PostMapping(value = "/{entityType}/{entityId}/timeseries/{scope}/{ttl}") public DeferredResult saveEntityTelemetryWithTTL( @Parameter(description = ENTITY_TYPE_PARAM_DESCRIPTION, required = true, schema = @Schema(defaultValue = "DEVICE")) @PathVariable("entityType") String entityType, @Parameter(description = ENTITY_ID_PARAM_DESCRIPTION, required = true) @PathVariable("entityId") String entityIdStr, @@ -471,8 +458,7 @@ public class TelemetryController extends BaseController { "Platform creates an audit log event about entity time series removal with action type 'TIMESERIES_DELETED' that includes an error stacktrace."), }) @PreAuthorize("hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN', 'CUSTOMER_USER')") - @RequestMapping(value = "/{entityType}/{entityId}/timeseries/delete", method = RequestMethod.DELETE) - @ResponseBody + @DeleteMapping(value = "/{entityType}/{entityId}/timeseries/delete") public DeferredResult deleteEntityTimeseries( @Parameter(description = ENTITY_TYPE_PARAM_DESCRIPTION, required = true, schema = @Schema(defaultValue = "DEVICE")) @PathVariable("entityType") String entityType, @Parameter(description = ENTITY_ID_PARAM_DESCRIPTION, required = true) @PathVariable("entityId") String entityIdStr, @@ -553,8 +539,7 @@ public class TelemetryController extends BaseController { "Platform creates an audit log event about device attributes removal with action type 'ATTRIBUTES_DELETED' that includes an error stacktrace."), }) @PreAuthorize("hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN', 'CUSTOMER_USER')") - @RequestMapping(value = "/{deviceId}/{scope}", method = RequestMethod.DELETE) - @ResponseBody + @DeleteMapping(value = "/{deviceId}/{scope}") public DeferredResult deleteDeviceAttributes( @Parameter(description = DEVICE_ID_PARAM_DESCRIPTION, required = true) @PathVariable(DEVICE_ID) String deviceIdStr, @Parameter(description = ATTRIBUTES_SCOPE_DESCRIPTION, schema = @Schema(allowableValues = {"SERVER_SCOPE", "SHARED_SCOPE", "CLIENT_SCOPE"}, requiredMode = Schema.RequiredMode.REQUIRED)) @PathVariable("scope") AttributeScope scope, @@ -577,8 +562,7 @@ public class TelemetryController extends BaseController { "Platform creates an audit log event about entity attributes removal with action type 'ATTRIBUTES_DELETED' that includes an error stacktrace."), }) @PreAuthorize("hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN', 'CUSTOMER_USER')") - @RequestMapping(value = "/{entityType}/{entityId}/{scope}", method = RequestMethod.DELETE) - @ResponseBody + @DeleteMapping(value = "/{entityType}/{entityId}/{scope}") public DeferredResult deleteEntityAttributes( @Parameter(description = ENTITY_TYPE_PARAM_DESCRIPTION, required = true, schema = @Schema(defaultValue = "DEVICE")) @PathVariable("entityType") String entityType, @Parameter(description = ENTITY_ID_PARAM_DESCRIPTION, required = true) @PathVariable("entityId") String entityIdStr, diff --git a/application/src/main/java/org/thingsboard/server/controller/TenantProfileController.java b/application/src/main/java/org/thingsboard/server/controller/TenantProfileController.java index c2c42eef37..19cc2341ad 100644 --- a/application/src/main/java/org/thingsboard/server/controller/TenantProfileController.java +++ b/application/src/main/java/org/thingsboard/server/controller/TenantProfileController.java @@ -262,5 +262,4 @@ public class TenantProfileController extends BaseController { return tenantProfileService.findTenantProfilesByIds(TenantId.SYS_TENANT_ID, ids); } - } diff --git a/application/src/main/java/org/thingsboard/server/controller/UiSettingsController.java b/application/src/main/java/org/thingsboard/server/controller/UiSettingsController.java index 220c5c45f8..77f13f7c2c 100644 --- a/application/src/main/java/org/thingsboard/server/controller/UiSettingsController.java +++ b/application/src/main/java/org/thingsboard/server/controller/UiSettingsController.java @@ -17,11 +17,9 @@ package org.thingsboard.server.controller; import org.springframework.beans.factory.annotation.Value; import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RequestMethod; -import org.springframework.web.bind.annotation.ResponseBody; import org.springframework.web.bind.annotation.RestController; -import org.thingsboard.server.common.data.exception.ThingsboardException; import org.thingsboard.server.config.annotations.ApiOperation; import org.thingsboard.server.queue.util.TbCoreComponent; @@ -37,9 +35,8 @@ public class UiSettingsController extends BaseController { notes = "Get UI help base url used to fetch help assets. " + "The actual value of the base url is configurable in the system configuration file.") @PreAuthorize("hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN', 'CUSTOMER_USER')") - @RequestMapping(value = "/uiSettings/helpBaseUrl", method = RequestMethod.GET) - @ResponseBody - public String getHelpBaseUrl() throws ThingsboardException { + @GetMapping(value = "/uiSettings/helpBaseUrl") + public String getHelpBaseUrl() { return helpBaseUrl; } diff --git a/application/src/main/java/org/thingsboard/server/controller/UsageInfoController.java b/application/src/main/java/org/thingsboard/server/controller/UsageInfoController.java index 877ef8a9b5..b2b2b59d61 100644 --- a/application/src/main/java/org/thingsboard/server/controller/UsageInfoController.java +++ b/application/src/main/java/org/thingsboard/server/controller/UsageInfoController.java @@ -18,9 +18,8 @@ package org.thingsboard.server.controller; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RequestMethod; -import org.springframework.web.bind.annotation.ResponseBody; import org.springframework.web.bind.annotation.RestController; import org.thingsboard.server.common.data.UsageInfo; import org.thingsboard.server.common.data.exception.ThingsboardException; @@ -37,9 +36,9 @@ public class UsageInfoController extends BaseController { private UsageInfoService usageInfoService; @PreAuthorize("hasAuthority('TENANT_ADMIN')") - @RequestMapping(value = "/usage", method = RequestMethod.GET) - @ResponseBody + @GetMapping(value = "/usage") public UsageInfo getTenantUsageInfo() throws ThingsboardException { return checkNotNull(usageInfoService.getUsageInfo(getCurrentUser().getTenantId())); } + } diff --git a/application/src/main/java/org/thingsboard/server/controller/WidgetTypeController.java b/application/src/main/java/org/thingsboard/server/controller/WidgetTypeController.java index a5ca93badd..857659d0e0 100644 --- a/application/src/main/java/org/thingsboard/server/controller/WidgetTypeController.java +++ b/application/src/main/java/org/thingsboard/server/controller/WidgetTypeController.java @@ -21,13 +21,13 @@ import io.swagger.v3.oas.annotations.media.Schema; import lombok.RequiredArgsConstructor; import org.springframework.http.HttpStatus; import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.DeleteMapping; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RequestMethod; import org.springframework.web.bind.annotation.RequestParam; -import org.springframework.web.bind.annotation.ResponseBody; import org.springframework.web.bind.annotation.ResponseStatus; import org.springframework.web.bind.annotation.RestController; import org.thingsboard.server.common.data.StringUtils; @@ -111,8 +111,7 @@ public class WidgetTypeController extends AutoCommitController { @ApiOperation(value = "Get Widget Type Info (getWidgetTypeInfoById)", notes = "Get the Widget Type Info based on the provided Widget Type Id. " + WIDGET_TYPE_DETAILS_DESCRIPTION + SYSTEM_OR_TENANT_AUTHORITY_PARAGRAPH) @PreAuthorize("hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN')") - @RequestMapping(value = "/widgetTypeInfo/{widgetTypeId}", method = RequestMethod.GET) - @ResponseBody + @GetMapping(value = "/widgetTypeInfo/{widgetTypeId}") public WidgetTypeInfo getWidgetTypeInfoById( @Parameter(description = WIDGET_TYPE_ID_PARAM_DESCRIPTION, required = true) @PathVariable("widgetTypeId") String strWidgetTypeId) throws ThingsboardException { @@ -132,8 +131,7 @@ public class WidgetTypeController extends AutoCommitController { "Remove 'id', 'tenantId' rom the request body example (below) to create new Widget Type entity." + SYSTEM_OR_TENANT_AUTHORITY_PARAGRAPH) @PreAuthorize("hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN')") - @RequestMapping(value = "/widgetType", method = RequestMethod.POST) - @ResponseBody + @PostMapping(value = "/widgetType") public WidgetTypeDetails saveWidgetType( @Parameter(description = "A JSON value representing the Widget Type Details.", required = true) @RequestBody WidgetTypeDetails widgetTypeDetails, @@ -153,7 +151,7 @@ public class WidgetTypeController extends AutoCommitController { @ApiOperation(value = "Delete widget type (deleteWidgetType)", notes = "Deletes the Widget Type. Referencing non-existing Widget Type Id will cause an error." + SYSTEM_OR_TENANT_AUTHORITY_PARAGRAPH) @PreAuthorize("hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN')") - @RequestMapping(value = "/widgetType/{widgetTypeId}", method = RequestMethod.DELETE) + @DeleteMapping(value = "/widgetType/{widgetTypeId}") @ResponseStatus(value = HttpStatus.OK) public void deleteWidgetType( @Parameter(description = WIDGET_TYPE_ID_PARAM_DESCRIPTION, required = true) @@ -168,8 +166,7 @@ public class WidgetTypeController extends AutoCommitController { notes = "Returns a page of Widget Type objects available for current user. " + WIDGET_TYPE_DESCRIPTION + " " + PAGE_DATA_PARAMETERS + AVAILABLE_FOR_ANY_AUTHORIZED_USER) @PreAuthorize("hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN', 'CUSTOMER_USER')") - @RequestMapping(value = "/widgetTypes", params = {"pageSize", "page"}, method = RequestMethod.GET) - @ResponseBody + @GetMapping(value = "/widgetTypes", params = {"pageSize", "page"}) public PageData getWidgetTypes( @Parameter(description = PAGE_SIZE_DESCRIPTION, required = true) @RequestParam int pageSize, @@ -215,8 +212,7 @@ public class WidgetTypeController extends AutoCommitController { @ApiOperation(value = "Get all Widget types for specified Bundle (getBundleWidgetTypesByBundleAlias) (Deprecated)", notes = "Returns an array of Widget Type objects that belong to specified Widget Bundle." + WIDGET_TYPE_DESCRIPTION + " " + SYSTEM_OR_TENANT_AUTHORITY_PARAGRAPH) @PreAuthorize("hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN')") - @RequestMapping(value = "/widgetTypes", params = {"isSystem", "bundleAlias"}, method = RequestMethod.GET) - @ResponseBody + @GetMapping(value = "/widgetTypes", params = {"isSystem", "bundleAlias"}) @Deprecated public List getBundleWidgetTypesByBundleAlias( @Parameter(description = "System or Tenant", required = true) @@ -236,8 +232,7 @@ public class WidgetTypeController extends AutoCommitController { @ApiOperation(value = "Get all Widget types for specified Bundle (getBundleWidgetTypes)", notes = "Returns an array of Widget Type objects that belong to specified Widget Bundle." + WIDGET_TYPE_DESCRIPTION + " " + SYSTEM_OR_TENANT_AUTHORITY_PARAGRAPH) @PreAuthorize("hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN', 'CUSTOMER_USER')") - @RequestMapping(value = "/widgetTypes", params = {"widgetsBundleId"}, method = RequestMethod.GET) - @ResponseBody + @GetMapping(value = "/widgetTypes", params = {"widgetsBundleId"}) public List getBundleWidgetTypes( @Parameter(description = "Widget Bundle Id", required = true) @RequestParam("widgetsBundleId") String strWidgetsBundleId) throws ThingsboardException { @@ -248,8 +243,7 @@ public class WidgetTypeController extends AutoCommitController { @ApiOperation(value = "Get all Widget types details for specified Bundle (getBundleWidgetTypesDetailsByBundleAlias) (Deprecated)", notes = "Returns an array of Widget Type Details objects that belong to specified Widget Bundle." + WIDGET_TYPE_DETAILS_DESCRIPTION + " " + SYSTEM_OR_TENANT_AUTHORITY_PARAGRAPH) @PreAuthorize("hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN')") - @RequestMapping(value = "/widgetTypesDetails", params = {"isSystem", "bundleAlias"}, method = RequestMethod.GET) - @ResponseBody + @GetMapping(value = "/widgetTypesDetails", params = {"isSystem", "bundleAlias"}) @Deprecated public List getBundleWidgetTypesDetailsByBundleAlias( @Parameter(description = "System or Tenant", required = true) @@ -269,8 +263,7 @@ public class WidgetTypeController extends AutoCommitController { @ApiOperation(value = "Get all Widget types details for specified Bundle (getBundleWidgetTypesDetails)", notes = "Returns an array of Widget Type Details objects that belong to specified Widget Bundle." + WIDGET_TYPE_DETAILS_DESCRIPTION + " " + SYSTEM_OR_TENANT_AUTHORITY_PARAGRAPH) @PreAuthorize("hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN', 'CUSTOMER_USER')") - @RequestMapping(value = "/widgetTypesDetails", params = {"widgetsBundleId"}, method = RequestMethod.GET) - @ResponseBody + @GetMapping(value = "/widgetTypesDetails", params = {"widgetsBundleId"}) public List getBundleWidgetTypesDetails( @Parameter(description = "Widget Bundle Id", required = true) @RequestParam("widgetsBundleId") String strWidgetsBundleId, @@ -291,8 +284,7 @@ public class WidgetTypeController extends AutoCommitController { @ApiOperation(value = "Get all Widget type fqns for specified Bundle (getBundleWidgetTypeFqns)", notes = "Returns an array of Widget Type fqns that belong to specified Widget Bundle." + SYSTEM_OR_TENANT_AUTHORITY_PARAGRAPH) @PreAuthorize("hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN')") - @RequestMapping(value = "/widgetTypeFqns", params = {"widgetsBundleId"}, method = RequestMethod.GET) - @ResponseBody + @GetMapping(value = "/widgetTypeFqns", params = {"widgetsBundleId"}) public List getBundleWidgetTypeFqns( @Parameter(description = "Widget Bundle Id", required = true) @RequestParam("widgetsBundleId") String strWidgetsBundleId) throws ThingsboardException { @@ -303,8 +295,7 @@ public class WidgetTypeController extends AutoCommitController { @ApiOperation(value = "Get Widget Type Info objects (getBundleWidgetTypesInfosByBundleAlias) (Deprecated)", notes = "Get the Widget Type Info objects based on the provided parameters. " + WIDGET_TYPE_INFO_DESCRIPTION + AVAILABLE_FOR_ANY_AUTHORIZED_USER) @PreAuthorize("hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN', 'CUSTOMER_USER')") - @RequestMapping(value = "/widgetTypesInfos", params = {"isSystem", "bundleAlias"}, method = RequestMethod.GET) - @ResponseBody + @GetMapping(value = "/widgetTypesInfos", params = {"isSystem", "bundleAlias"}) @Deprecated public List getBundleWidgetTypesInfosByBundleAlias( @Parameter(description = "System or Tenant", required = true) @@ -325,8 +316,7 @@ public class WidgetTypeController extends AutoCommitController { @ApiOperation(value = "Get Widget Type Info objects (getBundleWidgetTypesInfos)", notes = "Get the Widget Type Info objects based on the provided parameters. " + WIDGET_TYPE_INFO_DESCRIPTION + AVAILABLE_FOR_ANY_AUTHORIZED_USER) @PreAuthorize("hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN', 'CUSTOMER_USER')") - @RequestMapping(value = "/widgetTypesInfos", params = {"widgetsBundleId", "pageSize", "page"}, method = RequestMethod.GET) - @ResponseBody + @GetMapping(value = "/widgetTypesInfos", params = {"widgetsBundleId", "pageSize", "page"}) public PageData getBundleWidgetTypesInfos( @Parameter(description = "Widget Bundle Id", required = true) @RequestParam("widgetsBundleId") String strWidgetsBundleId, @@ -357,8 +347,7 @@ public class WidgetTypeController extends AutoCommitController { @ApiOperation(value = "Get Widget Type (getWidgetTypeByBundleAliasAndTypeAlias) (Deprecated)", notes = "Get the Widget Type based on the provided parameters. " + WIDGET_TYPE_DESCRIPTION + AVAILABLE_FOR_ANY_AUTHORIZED_USER) @PreAuthorize("hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN', 'CUSTOMER_USER')") - @RequestMapping(value = "/widgetType", params = {"isSystem", "bundleAlias", "alias"}, method = RequestMethod.GET) - @ResponseBody + @GetMapping(value = "/widgetType", params = {"isSystem", "bundleAlias", "alias"}) @Deprecated public WidgetType getWidgetTypeByBundleAliasAndTypeAlias( @Parameter(description = "System or Tenant", required = true) @@ -382,8 +371,7 @@ public class WidgetTypeController extends AutoCommitController { @ApiOperation(value = "Get Widget Type (getWidgetType)", notes = "Get the Widget Type by FQN. " + WIDGET_TYPE_DESCRIPTION + AVAILABLE_FOR_ANY_AUTHORIZED_USER, hidden = true) @PreAuthorize("hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN', 'CUSTOMER_USER')") - @RequestMapping(value = "/widgetType", params = {"fqn"}, method = RequestMethod.GET) - @ResponseBody + @GetMapping(value = "/widgetType", params = {"fqn"}) public WidgetType getWidgetType( @Parameter(description = "Widget Type fqn", required = true) @RequestParam String fqn) throws ThingsboardException { diff --git a/application/src/main/java/org/thingsboard/server/controller/WidgetsBundleController.java b/application/src/main/java/org/thingsboard/server/controller/WidgetsBundleController.java index 7c3133d6d9..75f4c7bcf1 100644 --- a/application/src/main/java/org/thingsboard/server/controller/WidgetsBundleController.java +++ b/application/src/main/java/org/thingsboard/server/controller/WidgetsBundleController.java @@ -20,12 +20,13 @@ import io.swagger.v3.oas.annotations.media.Schema; import lombok.RequiredArgsConstructor; import org.springframework.http.HttpStatus; import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RequestMethod; import org.springframework.web.bind.annotation.RequestParam; -import org.springframework.web.bind.annotation.ResponseBody; import org.springframework.web.bind.annotation.ResponseStatus; import org.springframework.web.bind.annotation.RestController; import org.thingsboard.server.common.data.exception.ThingsboardException; @@ -79,8 +80,7 @@ public class WidgetsBundleController extends BaseController { @ApiOperation(value = "Get Widget Bundle (getWidgetsBundleById)", notes = "Get the Widget Bundle based on the provided Widget Bundle Id. " + WIDGET_BUNDLE_DESCRIPTION + AVAILABLE_FOR_ANY_AUTHORIZED_USER) @PreAuthorize("hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN', 'CUSTOMER_USER')") - @RequestMapping(value = "/widgetsBundle/{widgetsBundleId}", method = RequestMethod.GET) - @ResponseBody + @GetMapping(value = "/widgetsBundle/{widgetsBundleId}") public WidgetsBundle getWidgetsBundleById( @Parameter(description = WIDGET_BUNDLE_ID_PARAM_DESCRIPTION, required = true) @PathVariable("widgetsBundleId") String strWidgetsBundleId, @@ -106,8 +106,7 @@ public class WidgetsBundleController extends BaseController { "Remove 'id', 'tenantId' from the request body example (below) to create new Widgets Bundle entity." + SYSTEM_OR_TENANT_AUTHORITY_PARAGRAPH) @PreAuthorize("hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN')") - @RequestMapping(value = "/widgetsBundle", method = RequestMethod.POST) - @ResponseBody + @PostMapping(value = "/widgetsBundle") public WidgetsBundle saveWidgetsBundle( @Parameter(description = "A JSON value representing the Widget Bundle.", required = true) @RequestBody WidgetsBundle widgetsBundle) throws Exception { @@ -126,7 +125,7 @@ public class WidgetsBundleController extends BaseController { @ApiOperation(value = "Update widgets bundle widgets types list (updateWidgetsBundleWidgetTypes)", notes = "Updates widgets bundle widgets list." + SYSTEM_OR_TENANT_AUTHORITY_PARAGRAPH) @PreAuthorize("hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN')") - @RequestMapping(value = "/widgetsBundle/{widgetsBundleId}/widgetTypes", method = RequestMethod.POST) + @PostMapping(value = "/widgetsBundle/{widgetsBundleId}/widgetTypes") @ResponseStatus(value = HttpStatus.OK) public void updateWidgetsBundleWidgetTypes( @Parameter(description = WIDGET_BUNDLE_ID_PARAM_DESCRIPTION, required = true) @@ -152,7 +151,7 @@ public class WidgetsBundleController extends BaseController { @ApiOperation(value = "Update widgets bundle widgets list from widget type FQNs list (updateWidgetsBundleWidgetFqns)", notes = "Updates widgets bundle widgets list from widget type FQNs list." + SYSTEM_OR_TENANT_AUTHORITY_PARAGRAPH) @PreAuthorize("hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN')") - @RequestMapping(value = "/widgetsBundle/{widgetsBundleId}/widgetTypeFqns", method = RequestMethod.POST) + @PostMapping(value = "/widgetsBundle/{widgetsBundleId}/widgetTypeFqns") @ResponseStatus(value = HttpStatus.OK) public void updateWidgetsBundleWidgetFqns( @Parameter(description = WIDGET_BUNDLE_ID_PARAM_DESCRIPTION, required = true) @@ -169,7 +168,7 @@ public class WidgetsBundleController extends BaseController { @ApiOperation(value = "Delete widgets bundle (deleteWidgetsBundle)", notes = "Deletes the widget bundle. Referencing non-existing Widget Bundle Id will cause an error." + SYSTEM_OR_TENANT_AUTHORITY_PARAGRAPH) @PreAuthorize("hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN')") - @RequestMapping(value = "/widgetsBundle/{widgetsBundleId}", method = RequestMethod.DELETE) + @DeleteMapping(value = "/widgetsBundle/{widgetsBundleId}") @ResponseStatus(value = HttpStatus.OK) public void deleteWidgetsBundle( @Parameter(description = WIDGET_BUNDLE_ID_PARAM_DESCRIPTION, required = true) @@ -184,8 +183,7 @@ public class WidgetsBundleController extends BaseController { notes = "Returns a page of Widget Bundle objects available for current user. " + WIDGET_BUNDLE_DESCRIPTION + " " + PAGE_DATA_PARAMETERS + AVAILABLE_FOR_ANY_AUTHORIZED_USER) @PreAuthorize("hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN', 'CUSTOMER_USER')") - @RequestMapping(value = "/widgetsBundles", params = {"pageSize", "page"}, method = RequestMethod.GET) - @ResponseBody + @GetMapping(value = "/widgetsBundles", params = {"pageSize", "page"}) public PageData getWidgetsBundles( @Parameter(description = PAGE_SIZE_DESCRIPTION, required = true) @RequestParam int pageSize, @@ -223,8 +221,7 @@ public class WidgetsBundleController extends BaseController { @ApiOperation(value = "Get all Widget Bundles (getWidgetsBundles)", notes = "Returns an array of Widget Bundle objects that are available for current user." + WIDGET_BUNDLE_DESCRIPTION + " " + AVAILABLE_FOR_ANY_AUTHORIZED_USER) @PreAuthorize("hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN', 'CUSTOMER_USER')") - @RequestMapping(value = "/widgetsBundles", method = RequestMethod.GET) - @ResponseBody + @GetMapping(value = "/widgetsBundles") public List getWidgetsBundles() throws ThingsboardException { if (Authority.SYS_ADMIN.equals(getCurrentUser().getAuthority())) { return checkNotNull(widgetsBundleService.findSystemWidgetsBundles(getTenantId())); diff --git a/application/src/test/java/org/thingsboard/server/controller/BaseQueueControllerTest.java b/application/src/test/java/org/thingsboard/server/controller/BaseQueueControllerTest.java index b0a3518e60..1f2af52f3e 100644 --- a/application/src/test/java/org/thingsboard/server/controller/BaseQueueControllerTest.java +++ b/application/src/test/java/org/thingsboard/server/controller/BaseQueueControllerTest.java @@ -24,8 +24,8 @@ import org.junit.Test; import org.mockito.ArgumentCaptor; import org.mockito.Mockito; import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.boot.test.mock.mockito.SpyBean; import org.springframework.test.context.TestPropertySource; +import org.springframework.test.context.bean.override.mockito.MockitoSpyBean; import org.thingsboard.common.util.JacksonUtil; import org.thingsboard.server.actors.ActorSystemContext; import org.thingsboard.server.common.data.DataConstants; @@ -96,15 +96,15 @@ public class BaseQueueControllerTest extends AbstractControllerTest { private RuleEngineStatisticsService ruleEngineStatisticsService; @Autowired private StatsFactory statsFactory; - @SpyBean - private TimeseriesDao timeseriesDao; @Autowired private QueueStatsService queueStatsService; - @SpyBean + @MockitoSpyBean + private TimeseriesDao timeseriesDao; + @MockitoSpyBean private PartitionService partitionService; - @SpyBean + @MockitoSpyBean private TimeseriesService timeseriesService; - @SpyBean + @MockitoSpyBean private ActorSystemContext actorSystemContext; @Test diff --git a/application/src/test/java/org/thingsboard/server/controller/ImageControllerTest.java b/application/src/test/java/org/thingsboard/server/controller/ImageControllerTest.java index 86ba070bba..a04b14cb9a 100644 --- a/application/src/test/java/org/thingsboard/server/controller/ImageControllerTest.java +++ b/application/src/test/java/org/thingsboard/server/controller/ImageControllerTest.java @@ -189,7 +189,7 @@ public class ImageControllerTest extends AbstractControllerTest { assertThat(newImageInfo.getTitle()).isEqualTo(newTitle); assertThat(newImageInfo.getDescriptor(ImageDescriptor.class)).isEqualTo(imageDescriptor); assertThat(newImageInfo.getResourceKey()).isEqualTo(imageInfo.getResourceKey()); - assertThat(newImageInfo.getPublicResourceKey()).isEqualTo(newImageInfo.getPublicResourceKey()); + assertThat(newImageInfo.getPublicResourceKey()).isEqualTo(imageInfo.getPublicResourceKey()); } @Test diff --git a/application/src/test/java/org/thingsboard/server/controller/TenantControllerTest.java b/application/src/test/java/org/thingsboard/server/controller/TenantControllerTest.java index a33e8ca411..6f68b97619 100644 --- a/application/src/test/java/org/thingsboard/server/controller/TenantControllerTest.java +++ b/application/src/test/java/org/thingsboard/server/controller/TenantControllerTest.java @@ -27,8 +27,8 @@ import org.junit.Before; import org.junit.Test; import org.mockito.ArgumentMatcher; import org.mockito.Mockito; -import org.springframework.boot.test.mock.mockito.SpyBean; import org.springframework.test.context.TestPropertySource; +import org.springframework.test.context.bean.override.mockito.MockitoSpyBean; import org.springframework.test.web.servlet.ResultActions; import org.thingsboard.common.util.ThingsBoardExecutors; import org.thingsboard.server.actors.ActorSystemContext; @@ -109,11 +109,11 @@ public class TenantControllerTest extends AbstractControllerTest { ListeningExecutorService executor; - @SpyBean + @MockitoSpyBean private PartitionService partitionService; - @SpyBean + @MockitoSpyBean private ActorSystemContext actorContext; - @SpyBean + @MockitoSpyBean private TbQueueAdmin queueAdmin; @Before @@ -269,12 +269,11 @@ public class TenantControllerTest extends AbstractControllerTest { @Test public void testFindTenants() throws Exception { loginSysAdmin(); - List tenants = new ArrayList<>(); PageLink pageLink = new PageLink(17); PageData pageData = doGetTypedWithPageLink("/api/tenants?", PAGE_DATA_TENANT_TYPE_REF, pageLink); Assert.assertFalse(pageData.hasNext()); Assert.assertEquals(1, pageData.getData().size()); - tenants.addAll(pageData.getData()); + List tenants = new ArrayList<>(pageData.getData()); Mockito.reset(tbClusterService); @@ -400,12 +399,11 @@ public class TenantControllerTest extends AbstractControllerTest { @Test public void testFindTenantInfos() throws Exception { loginSysAdmin(); - List tenants = new ArrayList<>(); PageLink pageLink = new PageLink(17); PageData pageData = doGetTypedWithPageLink("/api/tenantInfos?", PAGE_DATA_TENANT_INFO_TYPE_REF, pageLink); Assert.assertFalse(pageData.hasNext()); Assert.assertEquals(1, pageData.getData().size()); - tenants.addAll(pageData.getData()); + List tenants = new ArrayList<>(pageData.getData()); List> createFutures = new ArrayList<>(56); for (int i = 0; i < 56; i++) { From 9085b42d4304c000f5a66cfab275899458300ce1 Mon Sep 17 00:00:00 2001 From: Nikita Mazurenko Date: Fri, 21 Nov 2025 14:26:47 +0200 Subject: [PATCH 09/54] Add handling of ENTITY_DELETED_RPC_MESSAGE during edge user message processing --- .../rpc/processor/user/BaseUserProcessor.java | 10 +++++++- .../rpc/processor/user/UserEdgeProcessor.java | 14 ++++++++++- .../thingsboard/server/edge/UserEdgeTest.java | 25 +++++++++++++++++++ 3 files changed, 47 insertions(+), 2 deletions(-) diff --git a/application/src/main/java/org/thingsboard/server/service/edge/rpc/processor/user/BaseUserProcessor.java b/application/src/main/java/org/thingsboard/server/service/edge/rpc/processor/user/BaseUserProcessor.java index c836c14233..14e4da53c0 100644 --- a/application/src/main/java/org/thingsboard/server/service/edge/rpc/processor/user/BaseUserProcessor.java +++ b/application/src/main/java/org/thingsboard/server/service/edge/rpc/processor/user/BaseUserProcessor.java @@ -78,6 +78,15 @@ public abstract class BaseUserProcessor extends BaseEdgeProcessor { return Pair.of(isCreated, userEmailUpdated); } + protected User deleteUser(TenantId tenantId, UserId userId) { + User userById = edgeCtx.getUserService().findUserById(tenantId, userId); + if (userById == null) { + throw new IllegalArgumentException(String.format("[%s] Failed to find User with id [%s]", tenantId, userId)); + } + edgeCtx.getUserService().deleteUser(tenantId, userById); + return userById; + } + protected void updateUserCredentials(TenantId tenantId, UserCredentialsUpdateMsg updateMsg) { UserCredentials userCredentialsFromUpdateMsg = JacksonUtil.fromString(updateMsg.getEntity(), UserCredentials.class, true); if (userCredentialsFromUpdateMsg == null) { @@ -117,7 +126,6 @@ public abstract class BaseUserProcessor extends BaseEdgeProcessor { tenantId, user.getName(), updateMsg, e); throw new RuntimeException(e); } - } protected abstract void setCustomerId(TenantId tenantId, CustomerId customerId, User user, UserUpdateMsg userUpdateMsg); diff --git a/application/src/main/java/org/thingsboard/server/service/edge/rpc/processor/user/UserEdgeProcessor.java b/application/src/main/java/org/thingsboard/server/service/edge/rpc/processor/user/UserEdgeProcessor.java index e7813caae9..cfe888b1a5 100644 --- a/application/src/main/java/org/thingsboard/server/service/edge/rpc/processor/user/UserEdgeProcessor.java +++ b/application/src/main/java/org/thingsboard/server/service/edge/rpc/processor/user/UserEdgeProcessor.java @@ -61,6 +61,10 @@ public class UserEdgeProcessor extends BaseUserProcessor implements UserProcesso saveOrUpdateUser(tenantId, userId, userUpdateMsg, edge); yield Futures.immediateFuture(null); } + case ENTITY_DELETED_RPC_MESSAGE -> { + deleteUserAndPushEntityDeletedEventToRuleEngine(tenantId, userId, edge); + yield Futures.immediateFuture(null); + } default -> handleUnsupportedMsgType(userUpdateMsg.getMsgType()); }; } catch (DataValidationException e) { @@ -116,7 +120,15 @@ public class UserEdgeProcessor extends BaseUserProcessor implements UserProcesso } } - @Override + private void deleteUserAndPushEntityDeletedEventToRuleEngine(TenantId tenantId, UserId userId, Edge edge) { + User removedUser = deleteUser(tenantId, userId); + CustomerId userCustomerId = removedUser.getCustomerId(); + String userAsString = JacksonUtil.toString(removedUser); + TbMsgMetaData msgMetaData = getEdgeActionTbMsgMetaData(edge, userCustomerId); + pushEntityEventToRuleEngine(tenantId, userId, userCustomerId, TbMsgType.ENTITY_DELETED, userAsString, msgMetaData); + } + + @Override public DownlinkMsg convertEdgeEventToDownlink(EdgeEvent edgeEvent, EdgeVersion edgeVersion) { UserId userId = new UserId(edgeEvent.getEntityId()); switch (edgeEvent.getAction()) { diff --git a/application/src/test/java/org/thingsboard/server/edge/UserEdgeTest.java b/application/src/test/java/org/thingsboard/server/edge/UserEdgeTest.java index 8b5261150b..f590b21b84 100644 --- a/application/src/test/java/org/thingsboard/server/edge/UserEdgeTest.java +++ b/application/src/test/java/org/thingsboard/server/edge/UserEdgeTest.java @@ -148,6 +148,31 @@ public class UserEdgeTest extends AbstractEdgeTest { testAutoGeneratedCodeByProtobuf(userCredentialsUpdateMsg); } + @Test + public void testSendUserDeleteFromEdgeToCloud() throws Exception { + // create customer + Customer savedCustomer = createAndAssignCustomerToEdge(); + + // create user + User customerUser = buildUser(Authority.CUSTOMER_USER, savedCustomer.getId()); + User savedCustomerUser = createAndVerifyUserOnEdge(customerUser); + + // simulate user removal event from edge to cloud + UplinkMsg.Builder uplinkMsgBuilder = UplinkMsg.newBuilder(); + UserUpdateMsg.newBuilder().setMsgType(UpdateMsgType.ENTITY_DELETED_RPC_MESSAGE) + .setIdMSB(savedCustomerUser.getUuidId().getMostSignificantBits()) + .setIdLSB(savedCustomerUser.getUuidId().getLeastSignificantBits()); + + testAutoGeneratedCodeByProtobuf(uplinkMsgBuilder); + + // expect edge message sent & cloud message response + edgeImitator.expectResponsesAmount(1); + edgeImitator.expectMessageAmount(1); + edgeImitator.sendUplinkMsg(uplinkMsgBuilder.build()); + Assert.assertTrue(edgeImitator.waitForResponses()); + Assert.assertTrue(edgeImitator.waitForMessages()); + } + private Customer createAndAssignCustomerToEdge() throws Exception { edgeImitator.expectMessageAmount(1); Customer customer = new Customer(); From 12ed2a56be5e728a6e8e3f127db40c534fa595c5 Mon Sep 17 00:00:00 2001 From: Nikita Mazurenko Date: Fri, 21 Nov 2025 15:40:37 +0200 Subject: [PATCH 10/54] Remove exception when user doesn't exist during edge user delete event processing --- .../service/edge/rpc/processor/user/BaseUserProcessor.java | 3 ++- .../service/edge/rpc/processor/user/UserEdgeProcessor.java | 3 +++ 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/application/src/main/java/org/thingsboard/server/service/edge/rpc/processor/user/BaseUserProcessor.java b/application/src/main/java/org/thingsboard/server/service/edge/rpc/processor/user/BaseUserProcessor.java index 14e4da53c0..897e49a7cd 100644 --- a/application/src/main/java/org/thingsboard/server/service/edge/rpc/processor/user/BaseUserProcessor.java +++ b/application/src/main/java/org/thingsboard/server/service/edge/rpc/processor/user/BaseUserProcessor.java @@ -81,7 +81,8 @@ public abstract class BaseUserProcessor extends BaseEdgeProcessor { protected User deleteUser(TenantId tenantId, UserId userId) { User userById = edgeCtx.getUserService().findUserById(tenantId, userId); if (userById == null) { - throw new IllegalArgumentException(String.format("[%s] Failed to find User with id [%s]", tenantId, userId)); + log.trace("[{}] User with id {} does not exist", tenantId, userId); + return null; } edgeCtx.getUserService().deleteUser(tenantId, userById); return userById; diff --git a/application/src/main/java/org/thingsboard/server/service/edge/rpc/processor/user/UserEdgeProcessor.java b/application/src/main/java/org/thingsboard/server/service/edge/rpc/processor/user/UserEdgeProcessor.java index cfe888b1a5..7905e802bd 100644 --- a/application/src/main/java/org/thingsboard/server/service/edge/rpc/processor/user/UserEdgeProcessor.java +++ b/application/src/main/java/org/thingsboard/server/service/edge/rpc/processor/user/UserEdgeProcessor.java @@ -122,6 +122,9 @@ public class UserEdgeProcessor extends BaseUserProcessor implements UserProcesso private void deleteUserAndPushEntityDeletedEventToRuleEngine(TenantId tenantId, UserId userId, Edge edge) { User removedUser = deleteUser(tenantId, userId); + if (removedUser == null) { + return; + } CustomerId userCustomerId = removedUser.getCustomerId(); String userAsString = JacksonUtil.toString(removedUser); TbMsgMetaData msgMetaData = getEdgeActionTbMsgMetaData(edge, userCustomerId); From bc1af0ba3bc290ecd7bf5f95a01782a977be5171 Mon Sep 17 00:00:00 2001 From: Nikita Mazurenko Date: Fri, 21 Nov 2025 17:04:42 +0200 Subject: [PATCH 11/54] Fix testSendUserDeleteFromEdgeToCloud --- .../rpc/processor/user/UserEdgeProcessor.java | 2 +- .../thingsboard/server/edge/UserEdgeTest.java | 28 +++++++++++++++---- 2 files changed, 23 insertions(+), 7 deletions(-) diff --git a/application/src/main/java/org/thingsboard/server/service/edge/rpc/processor/user/UserEdgeProcessor.java b/application/src/main/java/org/thingsboard/server/service/edge/rpc/processor/user/UserEdgeProcessor.java index 7905e802bd..968d573618 100644 --- a/application/src/main/java/org/thingsboard/server/service/edge/rpc/processor/user/UserEdgeProcessor.java +++ b/application/src/main/java/org/thingsboard/server/service/edge/rpc/processor/user/UserEdgeProcessor.java @@ -131,7 +131,7 @@ public class UserEdgeProcessor extends BaseUserProcessor implements UserProcesso pushEntityEventToRuleEngine(tenantId, userId, userCustomerId, TbMsgType.ENTITY_DELETED, userAsString, msgMetaData); } - @Override + @Override public DownlinkMsg convertEdgeEventToDownlink(EdgeEvent edgeEvent, EdgeVersion edgeVersion) { UserId userId = new UserId(edgeEvent.getEntityId()); switch (edgeEvent.getAction()) { diff --git a/application/src/test/java/org/thingsboard/server/edge/UserEdgeTest.java b/application/src/test/java/org/thingsboard/server/edge/UserEdgeTest.java index f590b21b84..6bf51eb159 100644 --- a/application/src/test/java/org/thingsboard/server/edge/UserEdgeTest.java +++ b/application/src/test/java/org/thingsboard/server/edge/UserEdgeTest.java @@ -20,8 +20,11 @@ import org.junit.Assert; import org.junit.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; +import org.springframework.test.web.servlet.ResultMatcher; +import org.testcontainers.shaded.org.awaitility.Awaitility; import org.thingsboard.common.util.JacksonUtil; import org.thingsboard.server.common.data.Customer; +import org.thingsboard.server.common.data.EdgeUtils; import org.thingsboard.server.common.data.StringUtils; import org.thingsboard.server.common.data.User; import org.thingsboard.server.common.data.edge.Edge; @@ -41,6 +44,7 @@ import org.thingsboard.server.service.security.model.ChangePasswordRequest; import java.util.Optional; import java.util.UUID; +import java.util.concurrent.TimeUnit; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; import static org.thingsboard.server.dao.user.UserServiceImpl.DEFAULT_TOKEN_LENGTH; @@ -158,19 +162,31 @@ public class UserEdgeTest extends AbstractEdgeTest { User savedCustomerUser = createAndVerifyUserOnEdge(customerUser); // simulate user removal event from edge to cloud - UplinkMsg.Builder uplinkMsgBuilder = UplinkMsg.newBuilder(); - UserUpdateMsg.newBuilder().setMsgType(UpdateMsgType.ENTITY_DELETED_RPC_MESSAGE) + UserUpdateMsg.Builder userUpdateMsg = UserUpdateMsg.newBuilder().setMsgType(UpdateMsgType.ENTITY_DELETED_RPC_MESSAGE) .setIdMSB(savedCustomerUser.getUuidId().getMostSignificantBits()) .setIdLSB(savedCustomerUser.getUuidId().getLeastSignificantBits()); - testAutoGeneratedCodeByProtobuf(uplinkMsgBuilder); + UplinkMsg uplink = UplinkMsg.newBuilder() + .setUplinkMsgId(EdgeUtils.nextPositiveInt()) + .addUserUpdateMsg(userUpdateMsg).build(); + + testAutoGeneratedCodeByProtobuf(userUpdateMsg); // expect edge message sent & cloud message response edgeImitator.expectResponsesAmount(1); - edgeImitator.expectMessageAmount(1); - edgeImitator.sendUplinkMsg(uplinkMsgBuilder.build()); + edgeImitator.sendUplinkMsg(uplink); Assert.assertTrue(edgeImitator.waitForResponses()); - Assert.assertTrue(edgeImitator.waitForMessages()); + + loginTenantAdmin(); + Awaitility.await().atMost(10, TimeUnit.SECONDS) + .until(() -> { + try { + doGet("/api/user/" + savedCustomerUser.getId(), User.class, status().isNotFound()); + return true; + } catch (Throwable ex) { + return false; + } + }); } private Customer createAndAssignCustomerToEdge() throws Exception { From ba37f7c8705df735f2d9d333c0aec4274693d13f Mon Sep 17 00:00:00 2001 From: Andrii Landiak Date: Mon, 24 Nov 2025 10:47:11 +0200 Subject: [PATCH 12/54] Minor changes of EdgeController --- .../server/controller/EdgeController.java | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) diff --git a/application/src/main/java/org/thingsboard/server/controller/EdgeController.java b/application/src/main/java/org/thingsboard/server/controller/EdgeController.java index 90ee697cf4..1b3e494802 100644 --- a/application/src/main/java/org/thingsboard/server/controller/EdgeController.java +++ b/application/src/main/java/org/thingsboard/server/controller/EdgeController.java @@ -53,7 +53,6 @@ import org.thingsboard.server.common.data.rule.RuleChain; import org.thingsboard.server.common.data.sync.ie.importing.csv.BulkImportRequest; import org.thingsboard.server.common.data.sync.ie.importing.csv.BulkImportResult; import org.thingsboard.server.common.msg.edge.FromEdgeSyncResponse; -import org.thingsboard.server.common.msg.edge.ToEdgeSyncRequest; import org.thingsboard.server.config.annotations.ApiOperation; import org.thingsboard.server.dao.exception.DataValidationException; import org.thingsboard.server.dao.exception.IncorrectParameterException; @@ -71,7 +70,6 @@ import org.thingsboard.server.service.security.permission.Resource; import java.util.ArrayList; import java.util.List; import java.util.Optional; -import java.util.UUID; import java.util.concurrent.ExecutionException; import java.util.stream.Collectors; @@ -268,7 +266,7 @@ public class EdgeController extends BaseController { @RequestParam(required = false) String sortOrder) throws ThingsboardException { TenantId tenantId = getCurrentUser().getTenantId(); PageLink pageLink = createPageLink(pageSize, page, textSearch, sortProperty, sortOrder); - if (type != null && type.trim().length() > 0) { + if (type != null && !type.trim().isEmpty()) { return checkNotNull(edgeService.findEdgesByTenantIdAndType(tenantId, type, pageLink)); } else { return checkNotNull(edgeService.findEdgesByTenantId(tenantId, pageLink)); @@ -295,7 +293,7 @@ public class EdgeController extends BaseController { @RequestParam(required = false) String sortOrder) throws ThingsboardException { TenantId tenantId = getCurrentUser().getTenantId(); PageLink pageLink = createPageLink(pageSize, page, textSearch, sortProperty, sortOrder); - if (type != null && type.trim().length() > 0) { + if (type != null && !type.trim().isEmpty()) { return checkNotNull(edgeService.findEdgeInfosByTenantIdAndType(tenantId, type, pageLink)); } else { return checkNotNull(edgeService.findEdgeInfosByTenantId(tenantId, pageLink)); @@ -359,7 +357,7 @@ public class EdgeController extends BaseController { checkCustomerId(customerId, Operation.READ); PageLink pageLink = createPageLink(pageSize, page, textSearch, sortProperty, sortOrder); PageData result; - if (type != null && type.trim().length() > 0) { + if (type != null && !type.trim().isEmpty()) { result = edgeService.findEdgesByTenantIdAndCustomerIdAndType(tenantId, customerId, type, pageLink); } else { result = edgeService.findEdgesByTenantIdAndCustomerId(tenantId, customerId, pageLink); @@ -394,7 +392,7 @@ public class EdgeController extends BaseController { checkCustomerId(customerId, Operation.READ); PageLink pageLink = createPageLink(pageSize, page, textSearch, sortProperty, sortOrder); PageData result; - if (type != null && type.trim().length() > 0) { + if (type != null && !type.trim().isEmpty()) { result = edgeService.findEdgeInfosByTenantIdAndCustomerIdAndType(tenantId, customerId, type, pageLink); } else { result = edgeService.findEdgeInfosByTenantIdAndCustomerId(tenantId, customerId, pageLink); @@ -470,7 +468,7 @@ public class EdgeController extends BaseController { @PreAuthorize("hasAuthority('TENANT_ADMIN')") @PostMapping(value = "/edge/sync/{edgeId}") public DeferredResult syncEdge(@Parameter(description = EDGE_ID_PARAM_DESCRIPTION, required = true) - @PathVariable("edgeId") String strEdgeId) throws ThingsboardException { + @PathVariable("edgeId") String strEdgeId) throws ThingsboardException { checkParameter("edgeId", strEdgeId); final DeferredResult response = new DeferredResult<>(); if (isEdgesEnabled() && edgeRpcServiceOpt.isPresent()) { From 1315733d1e2db20c1f904dbd5db1a120eddefd45 Mon Sep 17 00:00:00 2001 From: Nikita Mazurenko Date: Mon, 24 Nov 2025 12:11:41 +0200 Subject: [PATCH 13/54] Revert userCredentials.setId(null); in UserServiceImpl#replaceUserCredentialsInternal --- .../java/org/thingsboard/server/dao/user/UserServiceImpl.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dao/src/main/java/org/thingsboard/server/dao/user/UserServiceImpl.java b/dao/src/main/java/org/thingsboard/server/dao/user/UserServiceImpl.java index f2b9e476b5..bdcac80d4a 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/user/UserServiceImpl.java +++ b/dao/src/main/java/org/thingsboard/server/dao/user/UserServiceImpl.java @@ -361,7 +361,7 @@ public class UserServiceImpl extends AbstractCachedEntityService Date: Mon, 24 Nov 2025 14:23:26 +0200 Subject: [PATCH 14/54] Fix overriding exiting user credential ID before calling replaceUserCredentials on BaseUserProcessor --- .../service/edge/rpc/processor/user/BaseUserProcessor.java | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/application/src/main/java/org/thingsboard/server/service/edge/rpc/processor/user/BaseUserProcessor.java b/application/src/main/java/org/thingsboard/server/service/edge/rpc/processor/user/BaseUserProcessor.java index 897e49a7cd..0da0e4aade 100644 --- a/application/src/main/java/org/thingsboard/server/service/edge/rpc/processor/user/BaseUserProcessor.java +++ b/application/src/main/java/org/thingsboard/server/service/edge/rpc/processor/user/BaseUserProcessor.java @@ -23,6 +23,7 @@ import org.thingsboard.server.common.data.StringUtils; import org.thingsboard.server.common.data.User; import org.thingsboard.server.common.data.id.CustomerId; import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.common.data.id.UserCredentialsId; import org.thingsboard.server.common.data.id.UserId; import org.thingsboard.server.common.data.security.UserCredentials; import org.thingsboard.server.dao.service.DataValidator; @@ -107,6 +108,7 @@ public abstract class BaseUserProcessor extends BaseEdgeProcessor { try { UserCredentials existing = edgeCtx.getUserService().findUserCredentialsByUserId(tenantId, user.getId()); boolean created = existing == null; + UserCredentialsId oldCredentialsId = created ? null : existing.getId(); UserCredentials updated = created ? new UserCredentials() : existing; updated.setId(userCredentialsFromUpdateMsg.getId()); @@ -120,7 +122,7 @@ public abstract class BaseUserProcessor extends BaseEdgeProcessor { if (created) { edgeCtx.getUserService().saveUserCredentials(tenantId, updated, false); } else { - edgeCtx.getUserService().replaceUserCredentials(tenantId, updated, existing.getId(), false); + edgeCtx.getUserService().replaceUserCredentials(tenantId, updated, oldCredentialsId, false); } } catch (Exception e) { log.error("[{}] Can't update user credentials for user [{}], userCredentialsUpdateMsg [{}]", From 1e01b326e42a79eb450a8786ff9ce6d3cd9f67c8 Mon Sep 17 00:00:00 2001 From: Maksym Tsymbarov Date: Mon, 24 Nov 2025 14:52:37 +0200 Subject: [PATCH 15/54] moved to new ui and fixed data bug --- .../system/widget_types/timeseries_table.json | 2 +- ...eries-table-widget-settings.component.html | 25 ++++-- ...eseries-table-widget-settings.component.ts | 38 ++++++-- .../lib/timeseries-table-widget.component.ts | 88 +++++++++++++++---- .../assets/locale/locale.constant-en_US.json | 6 +- ui-ngx/src/form.scss | 6 ++ 6 files changed, 130 insertions(+), 35 deletions(-) diff --git a/application/src/main/data/json/system/widget_types/timeseries_table.json b/application/src/main/data/json/system/widget_types/timeseries_table.json index 625f7a1e7d..0283899134 100644 --- a/application/src/main/data/json/system/widget_types/timeseries_table.json +++ b/application/src/main/data/json/system/widget_types/timeseries_table.json @@ -17,7 +17,7 @@ "latestDataKeySettingsDirective": "tb-timeseries-table-latest-key-settings", "hasBasicMode": true, "basicModeDirective": "tb-timeseries-table-basic-config", - "defaultConfig": "{\"datasources\":[{\"type\":\"function\",\"name\":\"function\",\"entityAliasId\":null,\"filterId\":null,\"dataKeys\":[{\"name\":\"f(x)\",\"type\":\"function\",\"label\":\"Temperature °C\",\"color\":\"#2196f3\",\"settings\":{\"useCellStyleFunction\":true,\"cellStyleFunction\":\"if (value) {\\n var percent = (value + 60)/120 * 100;\\n var color = tinycolor.mix('blue', 'red', percent);\\n color.setAlpha(.5);\\n return {\\n paddingLeft: '20px',\\n color: '#ffffff',\\n background: color.toRgbString(),\\n fontSize: '18px'\\n };\\n} else {\\n return {};\\n}\",\"useCellContentFunction\":false},\"_hash\":0.8587686344902596,\"funcBody\":\"var value = prevValue + Math.random() * 40 - 20;\\nvar multiplier = Math.pow(10, 1 || 0);\\nvar value = Math.round(value * multiplier) / multiplier;\\nif (value < -60) {\\n\\tvalue = -60;\\n} else if (value > 60) {\\n\\tvalue = 60;\\n}\\nreturn value;\",\"units\":null,\"decimals\":null,\"usePostProcessing\":null,\"postFuncBody\":null},{\"name\":\"f(x)\",\"type\":\"function\",\"label\":\"Humidity, %\",\"color\":\"#ffc107\",\"settings\":{\"useCellStyleFunction\":true,\"cellStyleFunction\":\"if (value) {\\n var percent = value;\\n var backgroundColor = tinycolor('blue');\\n backgroundColor.setAlpha(value/100);\\n var color = 'blue';\\n if (value > 50) {\\n color = 'white';\\n }\\n \\n return {\\n paddingLeft: '20px',\\n color: color,\\n background: backgroundColor.toRgbString(),\\n fontSize: '18px'\\n };\\n} else {\\n return {};\\n}\",\"useCellContentFunction\":false},\"_hash\":0.12775350966079668,\"funcBody\":\"var value = prevValue + Math.random() * 20 - 10;\\nvar multiplier = Math.pow(10, 1 || 0);\\nvar value = Math.round(value * multiplier) / multiplier;\\nif (value < 5) {\\n\\tvalue = 5;\\n} else if (value > 100) {\\n\\tvalue = 100;\\n}\\nreturn value;\"}],\"latestDataKeys\":[]}],\"showTitle\":true,\"backgroundColor\":\"rgb(255, 255, 255)\",\"color\":\"rgba(0, 0, 0, 0.87)\",\"padding\":\"8px\",\"settings\":{\"enableSearch\":true,\"enableSelectColumnDisplay\":true,\"enableStickyHeader\":true,\"enableStickyAction\":true,\"showCellActionsMenu\":true,\"reserveSpaceForHiddenAction\":\"true\",\"showTimestamp\":true,\"dateFormat\":{\"format\":\"yyyy-MM-dd HH:mm:ss\"},\"displayPagination\":true,\"useEntityLabel\":false,\"defaultPageSize\":10,\"pageStepCount\":3,\"pageStepIncrement\":10,\"hideEmptyLines\":false,\"disableStickyHeader\":false,\"useRowStyleFunction\":false,\"rowStyleFunction\":\"\",\"tabSortKey\":\"timestamp\"},\"title\":\"Time series table\",\"dropShadow\":true,\"enableFullscreen\":true,\"titleStyle\":{\"fontSize\":\"16px\",\"fontWeight\":400,\"padding\":\"5px 10px 5px 10px\"},\"useDashboardTimewindow\":true,\"showLegend\":false,\"widgetStyle\":{},\"actions\":{},\"showTitleIcon\":false,\"iconColor\":\"rgba(0, 0, 0, 0.87)\",\"iconSize\":\"24px\",\"configMode\":\"basic\",\"titleFont\":null,\"titleColor\":null,\"titleIcon\":null}" + "defaultConfig": "{\"datasources\":[{\"type\":\"function\",\"name\":\"function\",\"entityAliasId\":null,\"filterId\":null,\"dataKeys\":[{\"name\":\"f(x)\",\"type\":\"function\",\"label\":\"Temperature °C\",\"color\":\"#2196f3\",\"settings\":{\"useCellStyleFunction\":true,\"cellStyleFunction\":\"if (value) {\\n var percent = (value + 60)/120 * 100;\\n var color = tinycolor.mix('blue', 'red', percent);\\n color.setAlpha(.5);\\n return {\\n paddingLeft: '20px',\\n color: '#ffffff',\\n background: color.toRgbString(),\\n fontSize: '18px'\\n };\\n} else {\\n return {};\\n}\",\"useCellContentFunction\":false},\"_hash\":0.8587686344902596,\"funcBody\":\"var value = prevValue + Math.random() * 40 - 20;\\nvar multiplier = Math.pow(10, 1 || 0);\\nvar value = Math.round(value * multiplier) / multiplier;\\nif (value < -60) {\\n\\tvalue = -60;\\n} else if (value > 60) {\\n\\tvalue = 60;\\n}\\nreturn value;\",\"units\":null,\"decimals\":null,\"usePostProcessing\":null,\"postFuncBody\":null},{\"name\":\"f(x)\",\"type\":\"function\",\"label\":\"Humidity, %\",\"color\":\"#ffc107\",\"settings\":{\"useCellStyleFunction\":true,\"cellStyleFunction\":\"if (value) {\\n var percent = value;\\n var backgroundColor = tinycolor('blue');\\n backgroundColor.setAlpha(value/100);\\n var color = 'blue';\\n if (value > 50) {\\n color = 'white';\\n }\\n \\n return {\\n paddingLeft: '20px',\\n color: color,\\n background: backgroundColor.toRgbString(),\\n fontSize: '18px'\\n };\\n} else {\\n return {};\\n}\",\"useCellContentFunction\":false},\"_hash\":0.12775350966079668,\"funcBody\":\"var value = prevValue + Math.random() * 20 - 10;\\nvar multiplier = Math.pow(10, 1 || 0);\\nvar value = Math.round(value * multiplier) / multiplier;\\nif (value < 5) {\\n\\tvalue = 5;\\n} else if (value > 100) {\\n\\tvalue = 100;\\n}\\nreturn value;\"}],\"latestDataKeys\":[]}],\"showTitle\":true,\"backgroundColor\":\"rgb(255, 255, 255)\",\"color\":\"rgba(0, 0, 0, 0.87)\",\"padding\":\"8px\",\"settings\":{\"enableSearch\":true,\"enableSelectColumnDisplay\":true,\"enableStickyHeader\":true,\"enableStickyAction\":true,\"showCellActionsMenu\":true,\"reserveSpaceForHiddenAction\":\"true\",\"showTimestamp\":true,\"dateFormat\":{\"format\":\"yyyy-MM-dd HH:mm:ss\"},\"displayPagination\":true,\"useEntityLabel\":false,\"defaultPageSize\":10,\"pageStepCount\":3,\"pageStepIncrement\":10,\"hideEmptyLines\":false,\"disableStickyHeader\":false,\"useRowStyleFunction\":false,\"rowStyleFunction\":\"\",\"sortOrder\":{\"property\":\"createdTime\",\"direction\":\"ASC\"}},\"title\":\"Time series table\",\"dropShadow\":true,\"enableFullscreen\":true,\"titleStyle\":{\"fontSize\":\"16px\",\"fontWeight\":400,\"padding\":\"5px 10px 5px 10px\"},\"useDashboardTimewindow\":true,\"showLegend\":false,\"widgetStyle\":{},\"actions\":{},\"showTitleIcon\":false,\"iconColor\":\"rgba(0, 0, 0, 0.87)\",\"iconSize\":\"24px\",\"configMode\":\"basic\",\"titleFont\":null,\"titleColor\":null,\"titleIcon\":null}" }, "resources": [ { diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/cards/timeseries-table-widget-settings.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/settings/cards/timeseries-table-widget-settings.component.html index f236dd1acd..ae07674162 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/cards/timeseries-table-widget-settings.component.html +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/cards/timeseries-table-widget-settings.component.html @@ -113,17 +113,24 @@
widgets.table.table-tabs
- {{ 'widgets.table.use-entity-label-tab-name' | translate }} + {{ 'widgets.table.use-entity-label-header-title' | translate }} -
+
widgets.table.sort-by
- - - {{ 'widgets.table.sort-timestamp-option' | translate }} - {{ 'widgets.table.sort-asc' | translate }} - {{ 'widgets.table.sort-desc' | translate }} - - +
+ + + {{ entityFields.createdTime.name | translate }} + {{ entityFields.name.name | translate }} + + + + + {{ 'widgets.table.sort-asc' | translate }} + {{ 'widgets.table.sort-desc' | translate }} + + +
diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/cards/timeseries-table-widget-settings.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/settings/cards/timeseries-table-widget-settings.component.ts index ccbb582679..95a9eb8014 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/cards/timeseries-table-widget-settings.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/cards/timeseries-table-widget-settings.component.ts @@ -20,7 +20,8 @@ import { UntypedFormBuilder, UntypedFormGroup, Validators } from '@angular/forms import { Store } from '@ngrx/store'; import { AppState } from '@core/core.state'; import { buildPageStepSizeValues } from '@home/components/widget/lib/table-widget.models'; -import { TabSortKey } from '@app/modules/home/components/widget/lib/timeseries-table-widget.component' +import { Direction } from '@shared/models/page/sort-order'; +import { entityFields } from '@shared/models/entity.models'; @Component({ selector: 'tb-timeseries-table-widget-settings', @@ -29,7 +30,8 @@ import { TabSortKey } from '@app/modules/home/components/widget/lib/timeseries-t }) export class TimeseriesTableWidgetSettingsComponent extends WidgetSettingsComponent { - TabSortKey = TabSortKey; + entityFields = entityFields; + Direction = Direction; timeseriesTableWidgetSettingsForm: UntypedFormGroup; pageStepSizeValues = []; @@ -44,6 +46,7 @@ export class TimeseriesTableWidgetSettingsComponent extends WidgetSettingsCompon } protected defaultSettings(): WidgetSettings { + console.log("default") return { enableSearch: true, enableSelectColumnDisplay: true, @@ -62,14 +65,21 @@ export class TimeseriesTableWidgetSettingsComponent extends WidgetSettingsCompon disableStickyHeader: false, useRowStyleFunction: false, rowStyleFunction: '', - tabSortKey: TabSortKey.TIMESTAMP + sortOrder: { + property: this.entityFields.name.keyName, + direction: Direction.ASC + } }; } protected prepareInputSettings(settings: WidgetSettings): WidgetSettings { settings.pageStepIncrement = settings.pageStepIncrement ?? settings.defaultPageSize; - settings.tabSortKey = settings.tabSortKey ?? TabSortKey.TIMESTAMP; + settings.sortOrder = { + property: settings.sortOrder?.property || this.entityFields.name.keyName, + direction: settings.sortOrder?.direction || Direction.ASC + }; this.pageStepSizeValues = buildPageStepSizeValues(settings.pageStepCount, settings.pageStepIncrement); + console.log("input",settings) return settings; } @@ -99,7 +109,16 @@ export class TimeseriesTableWidgetSettingsComponent extends WidgetSettingsCompon disableStickyHeader: [settings.disableStickyHeader, []], useRowStyleFunction: [settings.useRowStyleFunction, []], rowStyleFunction: [settings.rowStyleFunction, [Validators.required]], - tabSortKey: [settings.tabSortKey, []], + sortOrder: this.fb.group({ + property: [ + settings.sortOrder.property, + Validators.required + ], + direction: [ + settings.sortOrder.direction, + Validators.required + ] + }) }); } @@ -107,6 +126,15 @@ export class TimeseriesTableWidgetSettingsComponent extends WidgetSettingsCompon return ['useRowStyleFunction', 'displayPagination', 'pageStepCount', 'pageStepIncrement']; } + protected prepareOutputSettings(settings: WidgetSettings): WidgetSettings { + settings.sortOrder = { + property: settings.sortOrder?.property || this.entityFields.name.keyName, + direction: settings.sortOrder?.direction || Direction.ASC + }; + console.log("output",settings) + return settings; + } + protected updateValidators(emitEvent: boolean, trigger: string) { if (trigger === 'pageStepCount' || trigger === 'pageStepIncrement') { this.timeseriesTableWidgetSettingsForm.get('defaultPageSize').reset(); diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/timeseries-table-widget.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/timeseries-table-widget.component.ts index dbd4905299..d6c73d7f82 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/timeseries-table-widget.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/timeseries-table-widget.component.ts @@ -106,19 +106,14 @@ import { ComponentPortal } from '@angular/cdk/portal'; import { FormBuilder } from '@angular/forms'; import { DEFAULT_OVERLAY_POSITIONS } from '@shared/models/overlay.models'; import { DateFormatSettings, ValueFormatProcessor } from '@shared/models/widget-settings.models'; - -export enum TabSortKey { - NAME_ASC = 'NAME_ASC', - NAME_DESC = 'NAME_DESC', - TIMESTAMP = 'timestamp' -} +import { entityFields } from '@shared/models/entity.models'; export interface TimeseriesTableWidgetSettings extends TableWidgetSettings { showTimestamp: boolean; showMilliseconds: boolean; hideEmptyLines: boolean; dateFormat: DateFormatSettings; - tabSortKey: TabSortKey; + sortOrder: SortOrder; } interface TimeseriesWidgetLatestDataKeySettings extends TableWidgetDataKeySettings { @@ -405,7 +400,7 @@ export class TimeseriesTableWidgetComponent extends PageComponent implements OnI return entityLabelCache.get(source.entityId); } - const value = this.useEntityLabel + const value = this.useEntityLabel ? (source.entityLabel || source.entityName) : source.entityName; @@ -414,19 +409,78 @@ export class TimeseriesTableWidgetComponent extends PageComponent implements OnI return translated; } - private sortDatasources(source: Datasource[], entityLabelCache:Map): Datasource[] { + private sortDatasources(source: Datasource[], entityLabelCache: Map): Datasource[] { + const property = this.settings?.sortOrder?.property; + const direction = this.settings?.sortOrder?.direction; + const isAsc = direction === Direction.ASC; + let sortedSource = [...source]; + source.forEach(ds => this.getTabLabel(ds, entityLabelCache)); - if (this.settings.tabSortKey === TabSortKey.TIMESTAMP) { - return source; + if (property === entityFields.name.keyName) { + const collator = new Intl.Collator(undefined, { + sensitivity: "variant", + numeric: true, + ignorePunctuation: false + }); + + sortedSource.sort((a, b) => { + const valueA = entityLabelCache.get(a.entityId) || ''; + const valueB = entityLabelCache.get(b.entityId) || ''; + + return isAsc + ? collator.compare(valueA, valueB) + : collator.compare(valueB, valueA); + }); + } else if (property === entityFields.createdTime.keyName) { + if (!isAsc) { + sortedSource.reverse(); + } } - return source.sort((a, b) => { - const valueA = entityLabelCache.get(a.entityId); - const valueB = entityLabelCache.get(b.entityId); - return this.settings.tabSortKey === TabSortKey.NAME_ASC - ? valueA.localeCompare(valueB) - : valueB.localeCompare(valueA); + this.reorderDataArrays(source, sortedSource); + return sortedSource; + } + + private reorderDataArrays(originalOrder: Datasource[], newOrder: Datasource[]): void { + const indexMap = new Map(); + originalOrder.forEach((ds, oldIndex) => { + const newIndex = newOrder.findIndex(newDs => newDs.entityId === ds.entityId); + indexMap.set(oldIndex, newIndex); }); + + const newData: Array = []; + originalOrder.forEach((ds, oldIndex) => { + const dataKeys = ds.dataKeys; + const startIdx = oldIndex * dataKeys.length; + const endIdx = startIdx + dataKeys.length; + const datasourceData = this.data.slice(startIdx, endIdx); + + const newIndex = indexMap.get(oldIndex); + const newStartIdx = newIndex * dataKeys.length; + + datasourceData.forEach((data, i) => { + newData[newStartIdx + i] = data; + }); + }); + this.data = newData; + + if (this.latestData && this.latestData.length > 0) { + const newLatestData: Array = []; + originalOrder.forEach((ds, oldIndex) => { + const latestDataKeys = ds.latestDataKeys || []; + const startIdx = oldIndex * latestDataKeys.length; + const endIdx = startIdx + latestDataKeys.length; + const datasourceLatestData = this.latestData.slice(startIdx, endIdx); + + const newIndex = indexMap.get(oldIndex); + const newStartIdx = newIndex * latestDataKeys.length; + + datasourceLatestData.forEach((data, i) => { + newLatestData[newStartIdx + i] = data; + }); + }); + this.latestData = newLatestData; + } } private updateDatasources() { 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 538b3a8352..9850ec73d7 100644 --- a/ui-ngx/src/assets/locale/locale.constant-en_US.json +++ b/ui-ngx/src/assets/locale/locale.constant-en_US.json @@ -9482,7 +9482,7 @@ "page-step-increment": "Step increment", "page-step-count-format-message": "Should be an integer value, in the range from 1 to 100.", "page-step-increment-format-message": "Should be an integer value, greater or equal to 1.", - "use-entity-label-tab-name": "Use entity label in tab name", + "use-entity-label-header-title": "Use entity label in header title", "hide-empty-lines": "Hide empty lines", "row-style": "Row style", "use-row-style-function": "Use row style function", @@ -9538,8 +9538,8 @@ "show-cell-actions-menu-mobile": "Show cell actions dropdown menu in mobile mode", "disable-sorting": "Disable sorting", "sort-by": "Sort tabs by", - "sort-asc": "Name Ascending", - "sort-desc": "Name Descending", + "sort-asc": "Ascending", + "sort-desc": "Descending", "sort-timestamp-option": "Created time" }, "latest-chart": { diff --git a/ui-ngx/src/form.scss b/ui-ngx/src/form.scss index af166b406d..b8e269c14e 100644 --- a/ui-ngx/src/form.scss +++ b/ui-ngx/src/form.scss @@ -257,6 +257,12 @@ flex: 1; } } + &.flex-lt-lg { + @media #{$mat-lt-lg} { + width: auto; + flex: 1; + } + } } .fixed-title-width { min-width: 200px; From 9e416e7dee40865dae6650d3738f846bfa19a56e Mon Sep 17 00:00:00 2001 From: dshvaika Date: Mon, 24 Nov 2025 19:31:07 +0200 Subject: [PATCH 16/54] bugfixes for propagation and geofencing CFs --- .../ctx/state/BaseCalculatedFieldState.java | 5 ++ .../cf/ctx/state/CalculatedFieldState.java | 16 +++++- .../GeofencingCalculatedFieldState.java | 26 ++++++++-- .../geofencing/GeofencingEvalResult.java | 2 +- .../state/geofencing/GeofencingZoneState.java | 10 ++-- .../cf/CalculatedFieldIntegrationTest.java | 21 +++----- .../GeofencingCalculatedFieldStateTest.java | 52 +++++++++++++------ .../cf/ctx/state/GeofencingZoneStateTest.java | 22 ++++---- .../PropagationCalculatedFieldStateTest.java | 33 ++++++++---- .../server/msa/cf/CalculatedFieldTest.java | 7 ++- 10 files changed, 124 insertions(+), 70 deletions(-) diff --git a/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/BaseCalculatedFieldState.java b/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/BaseCalculatedFieldState.java index 741c94c796..d6cf2bc845 100644 --- a/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/BaseCalculatedFieldState.java +++ b/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/BaseCalculatedFieldState.java @@ -25,6 +25,8 @@ import org.thingsboard.server.common.msg.queue.TopicPartitionInfo; import org.thingsboard.server.service.cf.ctx.CalculatedFieldEntityCtxId; import org.thingsboard.server.service.cf.ctx.state.aggregation.RelatedEntitiesArgumentEntry; import org.thingsboard.server.service.cf.ctx.state.aggregation.single.EntityAggregationArgumentEntry; +import org.thingsboard.server.service.cf.ctx.state.geofencing.GeofencingArgumentEntry; +import org.thingsboard.server.service.cf.ctx.state.geofencing.GeofencingZoneState; import org.thingsboard.server.utils.CalculatedFieldUtils; import java.io.Closeable; @@ -164,6 +166,9 @@ public abstract class BaseCalculatedFieldState implements CalculatedFieldState, .mapToLong(e -> (e instanceof SingleValueArgumentEntry s) ? s.getTs() : 0L) .max() .orElse(0L); + } else if (entry instanceof GeofencingArgumentEntry geofencingArgumentEntry) { + newTs = geofencingArgumentEntry.getZoneStates().values().stream() + .mapToLong(GeofencingZoneState::getTs).max().orElse(0L); } this.latestTimestamp = Math.max(this.latestTimestamp, newTs); } diff --git a/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/CalculatedFieldState.java b/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/CalculatedFieldState.java index e3914cc125..ec5681202f 100644 --- a/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/CalculatedFieldState.java +++ b/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/CalculatedFieldState.java @@ -38,6 +38,7 @@ import java.io.Closeable; import java.util.List; import java.util.Map; +import static org.thingsboard.server.common.data.cf.configuration.PropagationCalculatedFieldConfiguration.PROPAGATION_CONFIG_ARGUMENT; import static org.thingsboard.server.utils.CalculatedFieldUtils.toSingleValueArgumentProto; @JsonTypeInfo(use = JsonTypeInfo.Id.NAME, property = "type") @@ -102,14 +103,25 @@ public interface CalculatedFieldState extends Closeable { record ReadinessStatus(boolean ready, String errorMsg) { - private static final String ERROR_MESSAGE = "Required arguments are missing: "; + private static final String MISSING_REQUIRED_ARGUMENTS_ERROR = "Required arguments are missing: "; + private static final String MISSING_PROPAGATION_TARGETS_ERROR = "No entities found via 'Propagation path to related entities'. " + + "Verify the relation type and direction configured."; + private static final String MISSING_PROPAGATION_TARGETS_AND_ARGUMENTS_ERROR = MISSING_PROPAGATION_TARGETS_ERROR + " Missing arguments to propagate: "; private static final ReadinessStatus READY = new ReadinessStatus(true, null); public static ReadinessStatus from(List emptyOrMissingArguments) { if (CollectionsUtil.isEmpty(emptyOrMissingArguments)) { return ReadinessStatus.READY; } - return new ReadinessStatus(false, ERROR_MESSAGE + String.join(", ", emptyOrMissingArguments)); + boolean propagationCtxIsEmpty = emptyOrMissingArguments.remove(PROPAGATION_CONFIG_ARGUMENT); + if (!propagationCtxIsEmpty) { + return new ReadinessStatus(false, MISSING_REQUIRED_ARGUMENTS_ERROR + String.join(", ", emptyOrMissingArguments)); + } + if (emptyOrMissingArguments.isEmpty()) { + return new ReadinessStatus(false, MISSING_PROPAGATION_TARGETS_ERROR); + } + return new ReadinessStatus(false, MISSING_PROPAGATION_TARGETS_AND_ARGUMENTS_ERROR + + String.join(", ", emptyOrMissingArguments)); } } diff --git a/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/geofencing/GeofencingCalculatedFieldState.java b/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/geofencing/GeofencingCalculatedFieldState.java index b3ea94e62c..6d1e4e99ef 100644 --- a/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/geofencing/GeofencingCalculatedFieldState.java +++ b/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/geofencing/GeofencingCalculatedFieldState.java @@ -29,6 +29,7 @@ import org.thingsboard.common.util.geo.Coordinates; import org.thingsboard.server.common.data.cf.CalculatedFieldType; import org.thingsboard.server.common.data.cf.configuration.OutputType; import org.thingsboard.server.common.data.cf.configuration.geofencing.GeofencingCalculatedFieldConfiguration; +import org.thingsboard.server.common.data.cf.configuration.geofencing.GeofencingPresenceStatus; import org.thingsboard.server.common.data.cf.configuration.geofencing.GeofencingReportStrategy; import org.thingsboard.server.common.data.cf.configuration.geofencing.GeofencingTransitionEvent; import org.thingsboard.server.common.data.cf.configuration.geofencing.ZoneGroupConfiguration; @@ -112,7 +113,12 @@ public class GeofencingCalculatedFieldState extends BaseCalculatedFieldState { if (createRelationsWithMatchedZones) { GeofencingTransitionEvent transitionEvent = eval.transition(); if (transitionEvent == null) { - return; + if (!eval.firstEvaluation()) { + return; + } + transitionEvent = eval.status() == GeofencingPresenceStatus.INSIDE ? + GeofencingTransitionEvent.ENTERED : + GeofencingTransitionEvent.LEFT; } EntityRelation relation = switch (zoneGroupCfg.getDirection()) { case TO -> new EntityRelation(zoneId, entityId, zoneGroupCfg.getRelationType()); @@ -178,15 +184,27 @@ public class GeofencingCalculatedFieldState extends BaseCalculatedFieldState { private GeofencingEvalResult aggregateZoneGroup(List zoneResults) { boolean nowInside = zoneResults.stream().anyMatch(r -> INSIDE.equals(r.status())); - boolean prevInside = zoneResults.stream() - .anyMatch(r -> GeofencingTransitionEvent.LEFT.equals(r.transition()) || r.transition() == null && r.status() == INSIDE); + + boolean firstEvaluation = zoneResults.stream().allMatch(GeofencingEvalResult::firstEvaluation); + if (firstEvaluation) { + return new GeofencingEvalResult(null, nowInside ? INSIDE : OUTSIDE, true); + } + + boolean prevInside = zoneResults.stream().anyMatch(r -> { + if (r.firstEvaluation()) { + return false; + } + return GeofencingTransitionEvent.LEFT.equals(r.transition()) + || (r.transition() == null && r.status() == INSIDE); + }); + GeofencingTransitionEvent transition = null; if (!prevInside && nowInside) { transition = GeofencingTransitionEvent.ENTERED; } else if (prevInside && !nowInside) { transition = GeofencingTransitionEvent.LEFT; } - return new GeofencingEvalResult(transition, nowInside ? INSIDE : OUTSIDE); + return new GeofencingEvalResult(transition, nowInside ? INSIDE : OUTSIDE, false); } private void addTransitionEventIfExists(ObjectNode resultNode, GeofencingEvalResult aggregationResult, String eventKey) { diff --git a/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/geofencing/GeofencingEvalResult.java b/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/geofencing/GeofencingEvalResult.java index c6bf3dd65e..edc3f01ae7 100644 --- a/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/geofencing/GeofencingEvalResult.java +++ b/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/geofencing/GeofencingEvalResult.java @@ -20,5 +20,5 @@ import org.thingsboard.server.common.data.cf.configuration.geofencing.Geofencing import org.thingsboard.server.common.data.cf.configuration.geofencing.GeofencingTransitionEvent; public record GeofencingEvalResult(@Nullable GeofencingTransitionEvent transition, - GeofencingPresenceStatus status) { + GeofencingPresenceStatus status, boolean firstEvaluation) { } diff --git a/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/geofencing/GeofencingZoneState.java b/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/geofencing/GeofencingZoneState.java index c849f5d169..8a49461d31 100644 --- a/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/geofencing/GeofencingZoneState.java +++ b/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/geofencing/GeofencingZoneState.java @@ -86,21 +86,17 @@ public class GeofencingZoneState { // first evaluation if (this.lastPresence == null) { this.lastPresence = status; - GeofencingTransitionEvent transition = null; - if (status == GeofencingPresenceStatus.INSIDE) { - transition = GeofencingTransitionEvent.ENTERED; - } - return new GeofencingEvalResult(transition, status); + return new GeofencingEvalResult(null, status, true); } // State changed if (this.lastPresence != status) { this.lastPresence = status; GeofencingTransitionEvent transition = (status == GeofencingPresenceStatus.INSIDE) ? GeofencingTransitionEvent.ENTERED : GeofencingTransitionEvent.LEFT; - return new GeofencingEvalResult(transition, status); + return new GeofencingEvalResult(transition, status, false); } // State unchanged - return new GeofencingEvalResult(null, status); + return new GeofencingEvalResult(null, status, false); } } diff --git a/application/src/test/java/org/thingsboard/server/cf/CalculatedFieldIntegrationTest.java b/application/src/test/java/org/thingsboard/server/cf/CalculatedFieldIntegrationTest.java index af0c1b9909..01dbf9ae2c 100644 --- a/application/src/test/java/org/thingsboard/server/cf/CalculatedFieldIntegrationTest.java +++ b/application/src/test/java/org/thingsboard/server/cf/CalculatedFieldIntegrationTest.java @@ -714,7 +714,7 @@ public class CalculatedFieldIntegrationTest extends CalculatedFieldControllerTes doPost("/api/calculatedField", cf, CalculatedField.class); - // --- Assert initial evaluation (ENTERED / OUTSIDE) --- + // --- Assert initial evaluation (INSIDE / OUTSIDE) --- await().alias("initial geofencing evaluation") .atMost(TIMEOUT, TimeUnit.SECONDS) .pollInterval(POLL_INTERVAL, TimeUnit.SECONDS) @@ -722,10 +722,9 @@ public class CalculatedFieldIntegrationTest extends CalculatedFieldControllerTes ArrayNode attrs = getServerAttributes(device.getId(), "allowedZonesEvent", "allowedZonesStatus", "restrictedZonesStatus", "restrictedZonesEvent"); // --- no restrictedZonesEvent as no transition happened yet - assertThat(attrs).isNotNull().isNotEmpty().hasSize(3); + assertThat(attrs).isNotNull().isNotEmpty().hasSize(2); Map m = kv(attrs); - assertThat(m).containsEntry("allowedZonesEvent", "ENTERED") - .containsEntry("allowedZonesStatus", "INSIDE") + assertThat(m).containsEntry("allowedZonesStatus", "INSIDE") .containsEntry("restrictedZonesStatus", "OUTSIDE"); }); @@ -737,8 +736,6 @@ public class CalculatedFieldIntegrationTest extends CalculatedFieldControllerTes JacksonUtil.toJsonNode("{\"restrictedZone\":" + restrictedPolygon + "}")).andExpect(status().isOk()); // --- Assert no transition --- - // --- Assert attributes updated with the same values for restrictedZones --- - // --- Assert attributes updated with the new values for allowedZones --- await().alias("evaluation after version bump of geo argument") .atMost(TIMEOUT, TimeUnit.SECONDS) .pollInterval(POLL_INTERVAL, TimeUnit.SECONDS) @@ -824,17 +821,16 @@ public class CalculatedFieldIntegrationTest extends CalculatedFieldControllerTes doPost("/api/calculatedField", cf, CalculatedField.class); - // --- Assert initial evaluation (ENTERED / OUTSIDE) --- + // --- Assert initial evaluation (INSIDE / OUTSIDE) --- await().alias("initial geofencing evaluation") .atMost(TIMEOUT, TimeUnit.SECONDS) .pollInterval(POLL_INTERVAL, TimeUnit.SECONDS) .untilAsserted(() -> { ArrayNode attrs = getServerAttributes(device.getId(), "allowedZonesEvent", "allowedZonesStatus", "restrictedZonesStatus"); - assertThat(attrs).isNotNull().isNotEmpty().hasSize(3); + assertThat(attrs).isNotNull().isNotEmpty().hasSize(2); Map m = kv(attrs); - assertThat(m).containsEntry("allowedZonesEvent", "ENTERED") - .containsEntry("allowedZonesStatus", "INSIDE") + assertThat(m).containsEntry("allowedZonesStatus", "INSIDE") .containsEntry("restrictedZonesStatus", "OUTSIDE"); }); @@ -935,10 +931,9 @@ public class CalculatedFieldIntegrationTest extends CalculatedFieldControllerTes .pollInterval(POLL_INTERVAL, TimeUnit.SECONDS) .untilAsserted(() -> { ArrayNode attrs = getServerAttributes(device.getId(), "allowedZonesEvent", "allowedZonesStatus"); - assertThat(attrs).isNotNull().isNotEmpty().hasSize(2); + assertThat(attrs).isNotNull().isNotEmpty().hasSize(1); Map m = kv(attrs); - assertThat(m).containsEntry("allowedZonesEvent", "ENTERED") - .containsEntry("allowedZonesStatus", "INSIDE"); + assertThat(m).containsEntry("allowedZonesStatus", "INSIDE"); }); // --- Move device OUTSIDE Zone A (expect LEFT) --- diff --git a/application/src/test/java/org/thingsboard/server/service/cf/ctx/state/GeofencingCalculatedFieldStateTest.java b/application/src/test/java/org/thingsboard/server/service/cf/ctx/state/GeofencingCalculatedFieldStateTest.java index 24a0f0294a..ecba5acc77 100644 --- a/application/src/test/java/org/thingsboard/server/service/cf/ctx/state/GeofencingCalculatedFieldStateTest.java +++ b/application/src/test/java/org/thingsboard/server/service/cf/ctx/state/GeofencingCalculatedFieldStateTest.java @@ -221,7 +221,7 @@ public class GeofencingCalculatedFieldStateTest { ENTITY_ID_LATITUDE_ARGUMENT_KEY, latitudeArgEntry, ENTITY_ID_LONGITUDE_ARGUMENT_KEY, longitudeArgEntry, "allowedZones", geofencingAllowedZoneArgEntry, - "restrictedZones", new GeofencingArgumentEntry() + "restrictedZones", new GeofencingArgumentEntry(Collections.emptyMap()) ), ctx); assertThat(state.isReady()).isFalse(); assertThat(state.getReadinessStatus().errorMsg()).contains("restrictedZones"); @@ -249,7 +249,6 @@ public class GeofencingCalculatedFieldStateTest { assertThat(result.getScope()).isEqualTo(output.getScope()); assertThat(result.getResult()).isEqualTo( JacksonUtil.newObjectNode() - .put("allowedZonesEvent", "ENTERED") .put("allowedZonesStatus", "INSIDE") .put("restrictedZonesStatus", "OUTSIDE") ); @@ -290,10 +289,17 @@ public class GeofencingCalculatedFieldStateTest { assertThat(relationFromSecondIteration.getType()).isEqualTo("CurrentZone"); ArgumentCaptor deleteCaptor = ArgumentCaptor.forClass(EntityRelation.class); - verify(relationService).deleteRelationAsync(eq(ctx.getTenantId()), deleteCaptor.capture()); - EntityRelation leftRelation = deleteCaptor.getValue(); - assertThat(leftRelation.getFrom()).isEqualTo(ZONE_1_ID); - assertThat(leftRelation.getTo()).isEqualTo(ctx.getEntityId()); + verify(relationService, times(2)).deleteRelationAsync(eq(ctx.getTenantId()), deleteCaptor.capture()); + List deleteValues = deleteCaptor.getAllValues(); + assertThat(deleteValues).hasSize(2); + + EntityRelation deleteRelationFromFirstIteration = deleteValues.get(0); + assertThat(deleteRelationFromFirstIteration.getFrom()).isEqualTo(ZONE_2_ID); + assertThat(deleteRelationFromFirstIteration.getTo()).isEqualTo(ctx.getEntityId()); + + EntityRelation deleteRelationFromSecondIteration = deleteValues.get(1); + assertThat(deleteRelationFromSecondIteration.getFrom()).isEqualTo(ZONE_1_ID); + assertThat(deleteRelationFromSecondIteration.getTo()).isEqualTo(ctx.getEntityId()); } @Test @@ -322,9 +328,7 @@ public class GeofencingCalculatedFieldStateTest { assertThat(result).isNotNull(); assertThat(result.getType()).isEqualTo(output.getType()); assertThat(result.getScope()).isEqualTo(output.getScope()); - assertThat(result.getResult()).isEqualTo( - JacksonUtil.newObjectNode().put("allowedZonesEvent", "ENTERED") - ); + assertThat(result.getResult()).isEqualTo(JacksonUtil.newObjectNode()); SingleValueArgumentEntry newLatitude = new SingleValueArgumentEntry(System.currentTimeMillis(), new DoubleDataEntry("latitude", 50.4760), 146L); SingleValueArgumentEntry newLongitude = new SingleValueArgumentEntry(System.currentTimeMillis(), new DoubleDataEntry("longitude", 30.5110), 166L); @@ -360,10 +364,17 @@ public class GeofencingCalculatedFieldStateTest { assertThat(relationFromSecondIteration.getType()).isEqualTo("CurrentZone"); ArgumentCaptor deleteCaptor = ArgumentCaptor.forClass(EntityRelation.class); - verify(relationService).deleteRelationAsync(eq(ctx.getTenantId()), deleteCaptor.capture()); - EntityRelation leftRelation = deleteCaptor.getValue(); - assertThat(leftRelation.getFrom()).isEqualTo(ZONE_1_ID); - assertThat(leftRelation.getTo()).isEqualTo(ctx.getEntityId()); + verify(relationService, times(2)).deleteRelationAsync(eq(ctx.getTenantId()), deleteCaptor.capture()); + List deleteValues = deleteCaptor.getAllValues(); + assertThat(deleteValues).hasSize(2); + + EntityRelation deleteRelationFromFirstIteration = deleteValues.get(0); + assertThat(deleteRelationFromFirstIteration.getFrom()).isEqualTo(ZONE_2_ID); + assertThat(deleteRelationFromFirstIteration.getTo()).isEqualTo(ctx.getEntityId()); + + EntityRelation deleteRelationFromSecondIteration = deleteValues.get(1); + assertThat(deleteRelationFromSecondIteration.getFrom()).isEqualTo(ZONE_1_ID); + assertThat(deleteRelationFromSecondIteration.getTo()).isEqualTo(ctx.getEntityId()); } @Test @@ -432,10 +443,17 @@ public class GeofencingCalculatedFieldStateTest { assertThat(relationFromSecondIteration.getType()).isEqualTo("CurrentZone"); ArgumentCaptor deleteCaptor = ArgumentCaptor.forClass(EntityRelation.class); - verify(relationService).deleteRelationAsync(eq(ctx.getTenantId()), deleteCaptor.capture()); - EntityRelation leftRelation = deleteCaptor.getValue(); - assertThat(leftRelation.getFrom()).isEqualTo(ZONE_1_ID); - assertThat(leftRelation.getTo()).isEqualTo(ctx.getEntityId()); + verify(relationService, times(2)).deleteRelationAsync(eq(ctx.getTenantId()), deleteCaptor.capture()); + List deleteValues = deleteCaptor.getAllValues(); + assertThat(deleteValues).hasSize(2); + + EntityRelation deleteRelationFromFirstIteration = deleteValues.get(0); + assertThat(deleteRelationFromFirstIteration.getFrom()).isEqualTo(ZONE_2_ID); + assertThat(deleteRelationFromFirstIteration.getTo()).isEqualTo(ctx.getEntityId()); + + EntityRelation deleteRelationFromSecondIteration = deleteValues.get(1); + assertThat(deleteRelationFromSecondIteration.getFrom()).isEqualTo(ZONE_1_ID); + assertThat(deleteRelationFromSecondIteration.getTo()).isEqualTo(ctx.getEntityId()); } private CalculatedField getCalculatedField() { diff --git a/application/src/test/java/org/thingsboard/server/service/cf/ctx/state/GeofencingZoneStateTest.java b/application/src/test/java/org/thingsboard/server/service/cf/ctx/state/GeofencingZoneStateTest.java index f6c6778ced..7c2cfa56b3 100644 --- a/application/src/test/java/org/thingsboard/server/service/cf/ctx/state/GeofencingZoneStateTest.java +++ b/application/src/test/java/org/thingsboard/server/service/cf/ctx/state/GeofencingZoneStateTest.java @@ -48,30 +48,30 @@ public class GeofencingZoneStateTest { void evaluate_initialInside_thenInsideAgain() { var inside = new Coordinates(50.4730, 30.5050); // first evaluation: no prior state -> ENTERED - assertThat(state.evaluate(inside)).isEqualTo(new GeofencingEvalResult(ENTERED, INSIDE)); + assertThat(state.evaluate(inside)).isEqualTo(new GeofencingEvalResult(null, INSIDE, true)); // same position again -> INSIDE (steady state) - assertThat(state.evaluate(inside)).isEqualTo(new GeofencingEvalResult(null, INSIDE)); + assertThat(state.evaluate(inside)).isEqualTo(new GeofencingEvalResult(null, INSIDE, false)); } @Test void evaluate_initialOutside_thenOutsideAgain() { var outside = new Coordinates(50.4760, 30.5110); // first evaluation: no prior state -> OUTSIDE - assertThat(state.evaluate(outside)).isEqualTo(new GeofencingEvalResult(null, OUTSIDE)); + assertThat(state.evaluate(outside)).isEqualTo(new GeofencingEvalResult(null, OUTSIDE, true)); // same position again -> OUTSIDE (steady state) - assertThat(state.evaluate(outside)).isEqualTo(new GeofencingEvalResult(null, OUTSIDE)); + assertThat(state.evaluate(outside)).isEqualTo(new GeofencingEvalResult(null, OUTSIDE, false)); } @Test void evaluate_inside_thenLeave() { var inside = new Coordinates(50.4730, 30.5050); var outside = new Coordinates(50.4760, 30.5110); - // enter - assertThat(state.evaluate(inside)).isEqualTo(new GeofencingEvalResult(ENTERED, INSIDE)); + // inside + assertThat(state.evaluate(inside)).isEqualTo(new GeofencingEvalResult(null, INSIDE, true)); // leave -> LEFT - assertThat(state.evaluate(outside)).isEqualTo(new GeofencingEvalResult(LEFT, OUTSIDE)); + assertThat(state.evaluate(outside)).isEqualTo(new GeofencingEvalResult(LEFT, OUTSIDE, false)); // still outside -> OUTSIDE - assertThat(state.evaluate(outside)).isEqualTo(new GeofencingEvalResult(null, OUTSIDE)); + assertThat(state.evaluate(outside)).isEqualTo(new GeofencingEvalResult(null, OUTSIDE, false)); } @Test @@ -79,11 +79,11 @@ public class GeofencingZoneStateTest { var outside = new Coordinates(50.4760, 30.5110); var inside = new Coordinates(50.4730, 30.5050); // start outside - assertThat(state.evaluate(outside)).isEqualTo(new GeofencingEvalResult(null, OUTSIDE)); + assertThat(state.evaluate(outside)).isEqualTo(new GeofencingEvalResult(null, OUTSIDE, true)); // cross boundary -> ENTERED - assertThat(state.evaluate(inside)).isEqualTo(new GeofencingEvalResult(ENTERED, INSIDE)); + assertThat(state.evaluate(inside)).isEqualTo(new GeofencingEvalResult(ENTERED, INSIDE, false)); // remain inside -> INSIDE - assertThat(state.evaluate(inside)).isEqualTo(new GeofencingEvalResult(null, INSIDE)); + assertThat(state.evaluate(inside)).isEqualTo(new GeofencingEvalResult(null, INSIDE, false)); } @Test diff --git a/application/src/test/java/org/thingsboard/server/service/cf/ctx/state/PropagationCalculatedFieldStateTest.java b/application/src/test/java/org/thingsboard/server/service/cf/ctx/state/PropagationCalculatedFieldStateTest.java index 6ef945e4c6..e5bd6720e0 100644 --- a/application/src/test/java/org/thingsboard/server/service/cf/ctx/state/PropagationCalculatedFieldStateTest.java +++ b/application/src/test/java/org/thingsboard/server/service/cf/ctx/state/PropagationCalculatedFieldStateTest.java @@ -19,6 +19,8 @@ import com.fasterxml.jackson.databind.node.ObjectNode; import io.micrometer.core.instrument.simple.SimpleMeterRegistry; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.MethodSource; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.context.SpringBootTest; import org.springframework.test.context.bean.override.mockito.MockitoBean; @@ -52,10 +54,12 @@ import org.thingsboard.server.service.cf.ctx.state.propagation.PropagationCalcul import java.util.ArrayList; import java.util.Collections; +import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.UUID; import java.util.concurrent.ExecutionException; +import java.util.stream.Stream; import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.ArgumentMatchers.any; @@ -126,21 +130,28 @@ public class PropagationCalculatedFieldStateTest { assertThat(state.isReady()).isFalse(); } - @Test - void testIsReadyWhenPropagationArgIsNull() { - initCtxAndState(false); - state.update(Map.of(TEMPERATURE_ARGUMENT_NAME, singleValueArgEntry), ctx); - assertThat(state.isReady()).isFalse(); - assertThat(state.getReadinessStatus().errorMsg()).contains(PROPAGATION_CONFIG_ARGUMENT); + private static Stream provideInvalidPropagationArgs() { + return Stream.of( + null, + new PropagationArgumentEntry(Collections.emptyList()) + ); } - @Test - void testIsReadyWhenPropagationArgIsEmpty() { + @ParameterizedTest + @MethodSource("provideInvalidPropagationArgs") + void testIsReadyWhenPropagationArgIsNullOrEmpty(ArgumentEntry propagationEntry) { initCtxAndState(false); - state.update(Map.of(TEMPERATURE_ARGUMENT_NAME, singleValueArgEntry, - PROPAGATION_CONFIG_ARGUMENT, new PropagationArgumentEntry(Collections.emptyList())), ctx); + + Map args = new HashMap<>(); + args.put(TEMPERATURE_ARGUMENT_NAME, singleValueArgEntry); // Valid user arg + + if (propagationEntry != null) { + args.put(PROPAGATION_CONFIG_ARGUMENT, propagationEntry); + } + state.update(args, ctx); assertThat(state.isReady()).isFalse(); - assertThat(state.getReadinessStatus().errorMsg()).contains(PROPAGATION_CONFIG_ARGUMENT); + assertThat(state.getReadinessStatus().errorMsg()) + .isEqualTo("No entities found via 'Propagation path to related entities'. Verify the relation type and direction configured."); } @Test diff --git a/msa/black-box-tests/src/test/java/org/thingsboard/server/msa/cf/CalculatedFieldTest.java b/msa/black-box-tests/src/test/java/org/thingsboard/server/msa/cf/CalculatedFieldTest.java index 43d9a159fa..43fcbda5af 100644 --- a/msa/black-box-tests/src/test/java/org/thingsboard/server/msa/cf/CalculatedFieldTest.java +++ b/msa/black-box-tests/src/test/java/org/thingsboard/server/msa/cf/CalculatedFieldTest.java @@ -409,11 +409,10 @@ public class CalculatedFieldTest extends AbstractContainerTest { .pollInterval(POLL_INTERVAL, TimeUnit.SECONDS) .untilAsserted(() -> { ArrayNode attrs = testRestClient.getAttributes(device.getId(), SERVER_SCOPE, - "allowedZonesEvent,allowedZonesStatus,restrictedZonesStatus"); - assertThat(attrs).isNotNull().hasSize(3); + "allowedZonesEvent,allowedZonesStatus,restrictedZonesEvent,restrictedZonesStatus"); + assertThat(attrs).isNotNull().hasSize(2); Map m = kv(attrs); - assertThat(m).containsEntry("allowedZonesEvent", "ENTERED") - .containsEntry("allowedZonesStatus", "INSIDE") + assertThat(m).containsEntry("allowedZonesStatus", "INSIDE") .containsEntry("restrictedZonesStatus", "OUTSIDE"); }); From 19b438ffd97201d15fde5514d66e595907a60f8d Mon Sep 17 00:00:00 2001 From: dshvaika Date: Tue, 25 Nov 2025 10:58:39 +0200 Subject: [PATCH 17/54] Fixed test due to logic changes --- .../org/thingsboard/server/utils/CalculatedFieldUtilsTest.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/application/src/test/java/org/thingsboard/server/utils/CalculatedFieldUtilsTest.java b/application/src/test/java/org/thingsboard/server/utils/CalculatedFieldUtilsTest.java index 64b2fb032e..db24e51123 100644 --- a/application/src/test/java/org/thingsboard/server/utils/CalculatedFieldUtilsTest.java +++ b/application/src/test/java/org/thingsboard/server/utils/CalculatedFieldUtilsTest.java @@ -107,7 +107,7 @@ class CalculatedFieldUtilsTest { assertThat(fromProto) .usingRecursiveComparison() - .ignoringFields("ctx", "requiredArguments", "readinessStatus") + .ignoringFields("ctx", "requiredArguments", "readinessStatus", "latestTimestamp") .isEqualTo(state); ArgumentEntry fromProtoArgument = fromProto.getArguments().get("geofencingArgumentTest"); From e1c169786b178a0301b7eae33715ba52ba6c2a8c Mon Sep 17 00:00:00 2001 From: Vladyslav_Prykhodko Date: Tue, 25 Nov 2025 15:04:37 +0200 Subject: [PATCH 18/54] UI: Improved CF strategy settings style --- .../calculated-field-output.component.html | 158 +++++++++--------- .../calculated-field-output.component.ts | 27 ++- .../assets/locale/locale.constant-en_US.json | 8 +- 3 files changed, 103 insertions(+), 90 deletions(-) diff --git a/ui-ngx/src/app/modules/home/components/calculated-fields/components/output/calculated-field-output.component.html b/ui-ngx/src/app/modules/home/components/calculated-fields/components/output/calculated-field-output.component.html index a4cd8e3a3c..7e23acceae 100644 --- a/ui-ngx/src/app/modules/home/components/calculated-fields/components/output/calculated-field-output.component.html +++ b/ui-ngx/src/app/modules/home/components/calculated-fields/components/output/calculated-field-output.component.html @@ -46,7 +46,7 @@ @if (hiddenName) {
- +
} @else {
@@ -74,93 +74,94 @@
- + } }
-
-
- {{ 'calculated-fields.output-strategy.strategy' | translate }} -
- - @for (outputStrategyType of OutputStrategyTypes; track outputStrategyType) { - {{ OutputStrategyTypeTranslations.get(outputStrategyType) | translate }} + + + +
+
+ {{ 'calculated-fields.output-strategy.strategy' | translate }} +
+ + @for (outputStrategyType of OutputStrategyTypes; track outputStrategyType) { + {{ OutputStrategyTypeTranslations.get(outputStrategyType) | translate }} + } + +
+
+
+ @if (outputForm.get('strategy.type').value === OutputStrategyType.IMMEDIATE) { + @if (outputForm.get('type').value === OutputType.Timeseries) { +
+ +
+
calculated-fields.output-strategy.save-time-series
+
+
+
+
+ +
+
calculated-fields.output-strategy.save-latest-values
+
+
+
+ } @else { +
+ +
+
calculated-fields.output-strategy.save-database
+
+
+
} -
-
- @if (outputForm.get('strategy.type').value === OutputStrategyType.IMMEDIATE) { -
-
- {{ 'calculated-fields.output-strategy.processing-options' | translate }} +
+ +
+
calculated-fields.output-strategy.send-web-sockets
+
+
- - @if (outputForm.get('type').value === OutputType.Timeseries) { - - {{ 'calculated-fields.output-strategy.save-time-series' | translate }} - - - {{ 'calculated-fields.output-strategy.save-latest-values' | translate }} - - } @else { - - {{ 'calculated-fields.output-strategy.save-database' | translate }} - - } - - {{ 'calculated-fields.output-strategy.send-web-sockets' | translate }} - - - {{ 'calculated-fields.output-strategy.save-calculated-fields' | translate }} - - -
- @if (outputForm.get('type').value === OutputType.Attribute) {
- -
-
calculated-fields.output-strategy.update-attributes-only-on-value-change
+ +
+
calculated-fields.output-strategy.save-calculated-fields
- } @else { - - - help_outline - - + @if (outputForm.get('type').value === OutputType.Attribute) { +
+ +
+
calculated-fields.output-strategy.update-attributes-only-on-value-change
+
+
+
+ } @else { +
+ +
+
calculated-fields.output-strategy.ttl
+
+
+ + +
+ } } - } +
@@ -172,3 +173,6 @@ } + + + diff --git a/ui-ngx/src/app/modules/home/components/calculated-fields/components/output/calculated-field-output.component.ts b/ui-ngx/src/app/modules/home/components/calculated-fields/components/output/calculated-field-output.component.ts index 4ed8c4ffba..4a975e59b9 100644 --- a/ui-ngx/src/app/modules/home/components/calculated-fields/components/output/calculated-field-output.component.ts +++ b/ui-ngx/src/app/modules/home/components/calculated-fields/components/output/calculated-field-output.component.ts @@ -38,6 +38,8 @@ import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { EntityId } from '@shared/models/id/entity-id'; import { EntityType } from '@shared/models/entity-type.models'; import { coerceBoolean } from '@shared/decorators/coercion'; +import { merge } from 'rxjs'; +import { deepClone } from '@core/utils'; @Component({ selector: 'tb-calculate-field-output', @@ -102,6 +104,7 @@ export class CalculatedFieldOutputComponent implements ControlValueAccessor, Val sendWsUpdate: [true], processCfs: [true], updateAttributesOnlyOnValueChange: [true], + useCustomTtl: [false], ttl: [0] }) }); @@ -116,7 +119,10 @@ export class CalculatedFieldOutputComponent implements ControlValueAccessor, Val this.updatedStrategy(); }); - this.outputForm.get('strategy.type').valueChanges + merge( + this.outputForm.get('strategy.type').valueChanges, + this.outputForm.get('strategy.useCustomTtl').valueChanges + ) .pipe(takeUntilDestroyed(this.destroyRef)) .subscribe(() => { this.updatedStrategy(); @@ -151,6 +157,9 @@ export class CalculatedFieldOutputComponent implements ControlValueAccessor, Val writeValue(value: CalculatedFieldOutput | CalculatedFieldSimpleOutput): void { this.outputForm.patchValue(value, {emitEvent: false}); + if (value.type === OutputType.Timeseries && value.strategy?.type === OutputStrategyType.IMMEDIATE && value.strategy?.ttl) { + this.outputForm.get('strategy.useCustomTtl').setValue(true, {emitEvent: false}); + } this.outputForm.get('type').updateValueAndValidity({onlySelf: true}); } @@ -171,13 +180,6 @@ export class CalculatedFieldOutputComponent implements ControlValueAccessor, Val } } - toggleChip(controlName: string) { - const control = this.outputForm.get('strategy').get(controlName); - if (control && control.enabled) { - control.setValue(!control.value); - } - } - private updatedModel(value: CalculatedFieldOutput | CalculatedFieldSimpleOutput) { if (this.simpleMode && 'name' in value) { value.name = value.name?.trim() ?? ''; @@ -185,6 +187,10 @@ export class CalculatedFieldOutputComponent implements ControlValueAccessor, Val if (this.disableType) { value.type = this.outputForm.get('type').value; } + if (value.type === OutputType.Timeseries && value.strategy.type === OutputStrategyType.IMMEDIATE) { + value = deepClone(value); + delete (value.strategy as any).useCustomTtl + } this.propagateChange(value); } @@ -227,7 +233,10 @@ export class CalculatedFieldOutputComponent implements ControlValueAccessor, Val } else { this.outputForm.get('strategy.saveTimeSeries').enable({emitEvent: false}); this.outputForm.get('strategy.saveLatest').enable({emitEvent: false}); - this.outputForm.get('strategy.ttl').enable({emitEvent: false}); + this.outputForm.get('strategy.useCustomTtl').enable({emitEvent: false}); + if (this.outputForm.get('strategy.useCustomTtl').value) { + this.outputForm.get('strategy.ttl').enable({emitEvent: false}); + } } } } 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 c0623c49fc..09dfb9cb2c 100644 --- a/ui-ngx/src/assets/locale/locale.constant-en_US.json +++ b/ui-ngx/src/assets/locale/locale.constant-en_US.json @@ -1248,15 +1248,15 @@ "send-web-sockets": "Send to WebSockets", "save-calculated-fields": "Send to Calculated fields", "update-attributes-only-on-value-change": "Save attributes only if the value changes", - "ttl": "TTL", - "ttl-required": "TTL is required.", - "ttl-min": "Only 0 minimum TTL is allowed.", + "ttl": "Custom TTL", + "ttl-required": "TTL is required", + "ttl-min": "Only 0 minimum TTL is allowed", "hint": { "strategy": "Strategy", "processing-options": "Processing options", "update-attributes-only-on-value-change": "Updates the attributes on every incoming message disregarding if their value has changed. Increases API usage and reduces performance.", "update-attributes-only-on-value-change-enabled": "Updates the attributes only if their value has changed. If the value is not changed, no update to the attribute timestamp nor attribute change notification will be sent.", - "ttl": "If no value is present, it defaults to the TTL specified in the configuration. If the value is set to 0, the TTL from the tenant profile configuration will be applied." + "ttl": "Uncheck to use the default system settings" } }, "aggregate-interval-type": "Aggregate interval type", From 95d297b55bcb5703223af2d8e3e5f4c2c6ef3a45 Mon Sep 17 00:00:00 2001 From: dshvaika Date: Tue, 25 Nov 2025 15:59:04 +0200 Subject: [PATCH 19/54] rollback to previous geofencing design + bugfixes --- .../ctx/state/BaseCalculatedFieldState.java | 5 ++ .../cf/ctx/state/CalculatedFieldState.java | 16 ++++++- .../GeofencingCalculatedFieldState.java | 31 ++++++------ .../GeofencingCalculatedFieldStateTest.java | 47 ++++++++++++++----- .../PropagationCalculatedFieldStateTest.java | 33 ++++++++----- .../utils/CalculatedFieldUtilsTest.java | 2 +- 6 files changed, 93 insertions(+), 41 deletions(-) diff --git a/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/BaseCalculatedFieldState.java b/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/BaseCalculatedFieldState.java index 741c94c796..d6cf2bc845 100644 --- a/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/BaseCalculatedFieldState.java +++ b/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/BaseCalculatedFieldState.java @@ -25,6 +25,8 @@ import org.thingsboard.server.common.msg.queue.TopicPartitionInfo; import org.thingsboard.server.service.cf.ctx.CalculatedFieldEntityCtxId; import org.thingsboard.server.service.cf.ctx.state.aggregation.RelatedEntitiesArgumentEntry; import org.thingsboard.server.service.cf.ctx.state.aggregation.single.EntityAggregationArgumentEntry; +import org.thingsboard.server.service.cf.ctx.state.geofencing.GeofencingArgumentEntry; +import org.thingsboard.server.service.cf.ctx.state.geofencing.GeofencingZoneState; import org.thingsboard.server.utils.CalculatedFieldUtils; import java.io.Closeable; @@ -164,6 +166,9 @@ public abstract class BaseCalculatedFieldState implements CalculatedFieldState, .mapToLong(e -> (e instanceof SingleValueArgumentEntry s) ? s.getTs() : 0L) .max() .orElse(0L); + } else if (entry instanceof GeofencingArgumentEntry geofencingArgumentEntry) { + newTs = geofencingArgumentEntry.getZoneStates().values().stream() + .mapToLong(GeofencingZoneState::getTs).max().orElse(0L); } this.latestTimestamp = Math.max(this.latestTimestamp, newTs); } diff --git a/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/CalculatedFieldState.java b/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/CalculatedFieldState.java index e3914cc125..ec5681202f 100644 --- a/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/CalculatedFieldState.java +++ b/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/CalculatedFieldState.java @@ -38,6 +38,7 @@ import java.io.Closeable; import java.util.List; import java.util.Map; +import static org.thingsboard.server.common.data.cf.configuration.PropagationCalculatedFieldConfiguration.PROPAGATION_CONFIG_ARGUMENT; import static org.thingsboard.server.utils.CalculatedFieldUtils.toSingleValueArgumentProto; @JsonTypeInfo(use = JsonTypeInfo.Id.NAME, property = "type") @@ -102,14 +103,25 @@ public interface CalculatedFieldState extends Closeable { record ReadinessStatus(boolean ready, String errorMsg) { - private static final String ERROR_MESSAGE = "Required arguments are missing: "; + private static final String MISSING_REQUIRED_ARGUMENTS_ERROR = "Required arguments are missing: "; + private static final String MISSING_PROPAGATION_TARGETS_ERROR = "No entities found via 'Propagation path to related entities'. " + + "Verify the relation type and direction configured."; + private static final String MISSING_PROPAGATION_TARGETS_AND_ARGUMENTS_ERROR = MISSING_PROPAGATION_TARGETS_ERROR + " Missing arguments to propagate: "; private static final ReadinessStatus READY = new ReadinessStatus(true, null); public static ReadinessStatus from(List emptyOrMissingArguments) { if (CollectionsUtil.isEmpty(emptyOrMissingArguments)) { return ReadinessStatus.READY; } - return new ReadinessStatus(false, ERROR_MESSAGE + String.join(", ", emptyOrMissingArguments)); + boolean propagationCtxIsEmpty = emptyOrMissingArguments.remove(PROPAGATION_CONFIG_ARGUMENT); + if (!propagationCtxIsEmpty) { + return new ReadinessStatus(false, MISSING_REQUIRED_ARGUMENTS_ERROR + String.join(", ", emptyOrMissingArguments)); + } + if (emptyOrMissingArguments.isEmpty()) { + return new ReadinessStatus(false, MISSING_PROPAGATION_TARGETS_ERROR); + } + return new ReadinessStatus(false, MISSING_PROPAGATION_TARGETS_AND_ARGUMENTS_ERROR + + String.join(", ", emptyOrMissingArguments)); } } diff --git a/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/geofencing/GeofencingCalculatedFieldState.java b/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/geofencing/GeofencingCalculatedFieldState.java index b3ea94e62c..a9e8eb5731 100644 --- a/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/geofencing/GeofencingCalculatedFieldState.java +++ b/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/geofencing/GeofencingCalculatedFieldState.java @@ -107,23 +107,26 @@ public class GeofencingCalculatedFieldState extends BaseCalculatedFieldState { boolean createRelationsWithMatchedZones = zoneGroupCfg.isCreateRelationsWithMatchedZones(); List zoneResults = new ArrayList<>(argumentEntry.getZoneStates().size()); argumentEntry.getZoneStates().forEach((zoneId, zoneState) -> { + boolean firstEval = zoneState.getLastPresence() == null; GeofencingEvalResult eval = zoneState.evaluate(entityCoordinates); zoneResults.add(eval); - if (createRelationsWithMatchedZones) { - GeofencingTransitionEvent transitionEvent = eval.transition(); - if (transitionEvent == null) { - return; - } - EntityRelation relation = switch (zoneGroupCfg.getDirection()) { - case TO -> new EntityRelation(zoneId, entityId, zoneGroupCfg.getRelationType()); - case FROM -> new EntityRelation(entityId, zoneId, zoneGroupCfg.getRelationType()); - }; - ListenableFuture f = switch (transitionEvent) { - case ENTERED -> ctx.getRelationService().saveRelationAsync(ctx.getTenantId(), relation); - case LEFT -> ctx.getRelationService().deleteRelationAsync(ctx.getTenantId(), relation); - }; - relationFutures.add(f); + if (!createRelationsWithMatchedZones) { + return; } + GeofencingTransitionEvent transitionEvent = eval.transition(); + if (transitionEvent == null && !firstEval) { + return; + } + transitionEvent = transitionEvent == null ? GeofencingTransitionEvent.LEFT : transitionEvent; + EntityRelation relation = switch (zoneGroupCfg.getDirection()) { + case TO -> new EntityRelation(zoneId, entityId, zoneGroupCfg.getRelationType()); + case FROM -> new EntityRelation(entityId, zoneId, zoneGroupCfg.getRelationType()); + }; + ListenableFuture f = switch (transitionEvent) { + case ENTERED -> ctx.getRelationService().saveRelationAsync(ctx.getTenantId(), relation); + case LEFT -> ctx.getRelationService().deleteRelationAsync(ctx.getTenantId(), relation); + }; + relationFutures.add(f); }); updateValuesNode(argumentKey, zoneResults, zoneGroupCfg.getReportStrategy(), valuesNode); }); diff --git a/application/src/test/java/org/thingsboard/server/service/cf/ctx/state/GeofencingCalculatedFieldStateTest.java b/application/src/test/java/org/thingsboard/server/service/cf/ctx/state/GeofencingCalculatedFieldStateTest.java index 24a0f0294a..b3ce7bbb44 100644 --- a/application/src/test/java/org/thingsboard/server/service/cf/ctx/state/GeofencingCalculatedFieldStateTest.java +++ b/application/src/test/java/org/thingsboard/server/service/cf/ctx/state/GeofencingCalculatedFieldStateTest.java @@ -221,7 +221,7 @@ public class GeofencingCalculatedFieldStateTest { ENTITY_ID_LATITUDE_ARGUMENT_KEY, latitudeArgEntry, ENTITY_ID_LONGITUDE_ARGUMENT_KEY, longitudeArgEntry, "allowedZones", geofencingAllowedZoneArgEntry, - "restrictedZones", new GeofencingArgumentEntry() + "restrictedZones", new GeofencingArgumentEntry(Collections.emptyMap()) ), ctx); assertThat(state.isReady()).isFalse(); assertThat(state.getReadinessStatus().errorMsg()).contains("restrictedZones"); @@ -290,10 +290,17 @@ public class GeofencingCalculatedFieldStateTest { assertThat(relationFromSecondIteration.getType()).isEqualTo("CurrentZone"); ArgumentCaptor deleteCaptor = ArgumentCaptor.forClass(EntityRelation.class); - verify(relationService).deleteRelationAsync(eq(ctx.getTenantId()), deleteCaptor.capture()); - EntityRelation leftRelation = deleteCaptor.getValue(); - assertThat(leftRelation.getFrom()).isEqualTo(ZONE_1_ID); - assertThat(leftRelation.getTo()).isEqualTo(ctx.getEntityId()); + verify(relationService, times(2)).deleteRelationAsync(eq(ctx.getTenantId()), deleteCaptor.capture()); + List deleteValues = deleteCaptor.getAllValues(); + assertThat(deleteValues).hasSize(2); + + EntityRelation deleteRelationFromFirstIteration = deleteValues.get(0); + assertThat(deleteRelationFromFirstIteration.getFrom()).isEqualTo(ZONE_2_ID); + assertThat(deleteRelationFromFirstIteration.getTo()).isEqualTo(ctx.getEntityId()); + + EntityRelation deleteRelationFromSecondIteration = deleteValues.get(1); + assertThat(deleteRelationFromSecondIteration.getFrom()).isEqualTo(ZONE_1_ID); + assertThat(deleteRelationFromSecondIteration.getTo()).isEqualTo(ctx.getEntityId()); } @Test @@ -360,10 +367,17 @@ public class GeofencingCalculatedFieldStateTest { assertThat(relationFromSecondIteration.getType()).isEqualTo("CurrentZone"); ArgumentCaptor deleteCaptor = ArgumentCaptor.forClass(EntityRelation.class); - verify(relationService).deleteRelationAsync(eq(ctx.getTenantId()), deleteCaptor.capture()); - EntityRelation leftRelation = deleteCaptor.getValue(); - assertThat(leftRelation.getFrom()).isEqualTo(ZONE_1_ID); - assertThat(leftRelation.getTo()).isEqualTo(ctx.getEntityId()); + verify(relationService, times(2)).deleteRelationAsync(eq(ctx.getTenantId()), deleteCaptor.capture()); + List deleteValues = deleteCaptor.getAllValues(); + assertThat(deleteValues).hasSize(2); + + EntityRelation deleteRelationFromFirstIteration = deleteValues.get(0); + assertThat(deleteRelationFromFirstIteration.getFrom()).isEqualTo(ZONE_2_ID); + assertThat(deleteRelationFromFirstIteration.getTo()).isEqualTo(ctx.getEntityId()); + + EntityRelation deleteRelationFromSecondIteration = deleteValues.get(1); + assertThat(deleteRelationFromSecondIteration.getFrom()).isEqualTo(ZONE_1_ID); + assertThat(deleteRelationFromSecondIteration.getTo()).isEqualTo(ctx.getEntityId()); } @Test @@ -432,10 +446,17 @@ public class GeofencingCalculatedFieldStateTest { assertThat(relationFromSecondIteration.getType()).isEqualTo("CurrentZone"); ArgumentCaptor deleteCaptor = ArgumentCaptor.forClass(EntityRelation.class); - verify(relationService).deleteRelationAsync(eq(ctx.getTenantId()), deleteCaptor.capture()); - EntityRelation leftRelation = deleteCaptor.getValue(); - assertThat(leftRelation.getFrom()).isEqualTo(ZONE_1_ID); - assertThat(leftRelation.getTo()).isEqualTo(ctx.getEntityId()); + verify(relationService, times(2)).deleteRelationAsync(eq(ctx.getTenantId()), deleteCaptor.capture()); + List deleteValues = deleteCaptor.getAllValues(); + assertThat(deleteValues).hasSize(2); + + EntityRelation deleteRelationFromFirstIteration = deleteValues.get(0); + assertThat(deleteRelationFromFirstIteration.getFrom()).isEqualTo(ZONE_2_ID); + assertThat(deleteRelationFromFirstIteration.getTo()).isEqualTo(ctx.getEntityId()); + + EntityRelation deleteRelationFromSecondIteration = deleteValues.get(1); + assertThat(deleteRelationFromSecondIteration.getFrom()).isEqualTo(ZONE_1_ID); + assertThat(deleteRelationFromSecondIteration.getTo()).isEqualTo(ctx.getEntityId()); } private CalculatedField getCalculatedField() { diff --git a/application/src/test/java/org/thingsboard/server/service/cf/ctx/state/PropagationCalculatedFieldStateTest.java b/application/src/test/java/org/thingsboard/server/service/cf/ctx/state/PropagationCalculatedFieldStateTest.java index 6ef945e4c6..e5bd6720e0 100644 --- a/application/src/test/java/org/thingsboard/server/service/cf/ctx/state/PropagationCalculatedFieldStateTest.java +++ b/application/src/test/java/org/thingsboard/server/service/cf/ctx/state/PropagationCalculatedFieldStateTest.java @@ -19,6 +19,8 @@ import com.fasterxml.jackson.databind.node.ObjectNode; import io.micrometer.core.instrument.simple.SimpleMeterRegistry; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.MethodSource; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.context.SpringBootTest; import org.springframework.test.context.bean.override.mockito.MockitoBean; @@ -52,10 +54,12 @@ import org.thingsboard.server.service.cf.ctx.state.propagation.PropagationCalcul import java.util.ArrayList; import java.util.Collections; +import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.UUID; import java.util.concurrent.ExecutionException; +import java.util.stream.Stream; import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.ArgumentMatchers.any; @@ -126,21 +130,28 @@ public class PropagationCalculatedFieldStateTest { assertThat(state.isReady()).isFalse(); } - @Test - void testIsReadyWhenPropagationArgIsNull() { - initCtxAndState(false); - state.update(Map.of(TEMPERATURE_ARGUMENT_NAME, singleValueArgEntry), ctx); - assertThat(state.isReady()).isFalse(); - assertThat(state.getReadinessStatus().errorMsg()).contains(PROPAGATION_CONFIG_ARGUMENT); + private static Stream provideInvalidPropagationArgs() { + return Stream.of( + null, + new PropagationArgumentEntry(Collections.emptyList()) + ); } - @Test - void testIsReadyWhenPropagationArgIsEmpty() { + @ParameterizedTest + @MethodSource("provideInvalidPropagationArgs") + void testIsReadyWhenPropagationArgIsNullOrEmpty(ArgumentEntry propagationEntry) { initCtxAndState(false); - state.update(Map.of(TEMPERATURE_ARGUMENT_NAME, singleValueArgEntry, - PROPAGATION_CONFIG_ARGUMENT, new PropagationArgumentEntry(Collections.emptyList())), ctx); + + Map args = new HashMap<>(); + args.put(TEMPERATURE_ARGUMENT_NAME, singleValueArgEntry); // Valid user arg + + if (propagationEntry != null) { + args.put(PROPAGATION_CONFIG_ARGUMENT, propagationEntry); + } + state.update(args, ctx); assertThat(state.isReady()).isFalse(); - assertThat(state.getReadinessStatus().errorMsg()).contains(PROPAGATION_CONFIG_ARGUMENT); + assertThat(state.getReadinessStatus().errorMsg()) + .isEqualTo("No entities found via 'Propagation path to related entities'. Verify the relation type and direction configured."); } @Test diff --git a/application/src/test/java/org/thingsboard/server/utils/CalculatedFieldUtilsTest.java b/application/src/test/java/org/thingsboard/server/utils/CalculatedFieldUtilsTest.java index 64b2fb032e..db24e51123 100644 --- a/application/src/test/java/org/thingsboard/server/utils/CalculatedFieldUtilsTest.java +++ b/application/src/test/java/org/thingsboard/server/utils/CalculatedFieldUtilsTest.java @@ -107,7 +107,7 @@ class CalculatedFieldUtilsTest { assertThat(fromProto) .usingRecursiveComparison() - .ignoringFields("ctx", "requiredArguments", "readinessStatus") + .ignoringFields("ctx", "requiredArguments", "readinessStatus", "latestTimestamp") .isEqualTo(state); ArgumentEntry fromProtoArgument = fromProto.getArguments().get("geofencingArgumentTest"); From 792f5471201acf83103aa02f5bb06cf7670139b9 Mon Sep 17 00:00:00 2001 From: IrynaMatveieva Date: Tue, 25 Nov 2025 15:55:38 +0200 Subject: [PATCH 20/54] debug events improvements --- .../server/actors/ActorSystemContext.java | 14 +-- ...CalculatedFieldEntityMessageProcessor.java | 14 +-- .../CalculatedFieldException.java | 8 +- .../ctx/state/BaseCalculatedFieldState.java | 8 ++ .../cf/ctx/state/CalculatedFieldCtx.java | 4 +- .../cf/ctx/state/CalculatedFieldState.java | 3 + ...EntityAggregationCalculatedFieldState.java | 89 +++++++++++++++++++ .../server/common/data/DataConstants.java | 2 + 8 files changed, 120 insertions(+), 22 deletions(-) diff --git a/application/src/main/java/org/thingsboard/server/actors/ActorSystemContext.java b/application/src/main/java/org/thingsboard/server/actors/ActorSystemContext.java index f38a1972c0..bb8c3fca44 100644 --- a/application/src/main/java/org/thingsboard/server/actors/ActorSystemContext.java +++ b/application/src/main/java/org/thingsboard/server/actors/ActorSystemContext.java @@ -15,6 +15,7 @@ */ package org.thingsboard.server.actors; +import com.fasterxml.jackson.databind.JsonNode; import com.google.common.util.concurrent.FutureCallback; import com.google.common.util.concurrent.Futures; import com.google.common.util.concurrent.ListenableFuture; @@ -54,7 +55,6 @@ import org.thingsboard.server.common.data.id.CalculatedFieldId; import org.thingsboard.server.common.data.id.EntityId; import org.thingsboard.server.common.data.id.TenantId; import org.thingsboard.server.common.data.limit.LimitedApi; -import org.thingsboard.server.common.data.msg.TbMsgType; import org.thingsboard.server.common.data.plugin.ComponentLifecycleEvent; import org.thingsboard.server.common.msg.TbActorMsg; import org.thingsboard.server.common.msg.TbMsg; @@ -119,7 +119,6 @@ import org.thingsboard.server.service.cf.CalculatedFieldProcessingService; import org.thingsboard.server.service.cf.CalculatedFieldQueueService; import org.thingsboard.server.service.cf.CalculatedFieldStateService; import org.thingsboard.server.service.cf.OwnerService; -import org.thingsboard.server.service.cf.ctx.state.ArgumentEntry; import org.thingsboard.server.service.component.ComponentDiscoveryService; import org.thingsboard.server.service.edge.rpc.EdgeRpcService; import org.thingsboard.server.service.entitiy.entityview.TbEntityViewService; @@ -144,14 +143,12 @@ import org.thingsboard.server.utils.DebugModeRateLimitsConfig; import java.io.PrintWriter; import java.io.StringWriter; -import java.util.Map; import java.util.UUID; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ConcurrentMap; import java.util.concurrent.ScheduledExecutorService; import java.util.concurrent.ScheduledFuture; import java.util.concurrent.TimeUnit; -import java.util.stream.Collectors; @Slf4j @Component @@ -842,7 +839,7 @@ public class ActorSystemContext { Futures.addCallback(future, RULE_CHAIN_DEBUG_EVENT_ERROR_CALLBACK, MoreExecutors.directExecutor()); } - public void persistCalculatedFieldDebugEvent(TenantId tenantId, CalculatedFieldId calculatedFieldId, EntityId entityId, Map arguments, UUID tbMsgId, TbMsgType tbMsgType, String result, String errorMessage) { + public void persistCalculatedFieldDebugEvent(TenantId tenantId, CalculatedFieldId calculatedFieldId, EntityId entityId, JsonNode arguments, UUID tbMsgId, String tbMsgType, String result, String errorMessage) { if (checkLimits(tenantId)) { try { CalculatedFieldDebugEvent.CalculatedFieldDebugEventBuilder eventBuilder = CalculatedFieldDebugEvent.builder() @@ -855,13 +852,10 @@ public class ActorSystemContext { eventBuilder.msgId(tbMsgId); } if (tbMsgType != null) { - eventBuilder.msgType(tbMsgType.name()); + eventBuilder.msgType(tbMsgType); } if (arguments != null) { - eventBuilder.arguments(JacksonUtil.toString( - arguments.entrySet().stream() - .collect(Collectors.toMap(Map.Entry::getKey, e -> e.getValue().jsonValue())) - )); + eventBuilder.arguments(JacksonUtil.toString(arguments)); } if (result != null) { eventBuilder.result(result); diff --git a/application/src/main/java/org/thingsboard/server/actors/calculatedField/CalculatedFieldEntityMessageProcessor.java b/application/src/main/java/org/thingsboard/server/actors/calculatedField/CalculatedFieldEntityMessageProcessor.java index 6cc1c50325..6b45552bac 100644 --- a/application/src/main/java/org/thingsboard/server/actors/calculatedField/CalculatedFieldEntityMessageProcessor.java +++ b/application/src/main/java/org/thingsboard/server/actors/calculatedField/CalculatedFieldEntityMessageProcessor.java @@ -70,6 +70,7 @@ import java.util.UUID; import java.util.concurrent.TimeUnit; import java.util.stream.Collectors; +import static org.thingsboard.server.common.data.DataConstants.CF_REEVALUATION_MSG; import static org.thingsboard.server.utils.CalculatedFieldArgumentUtils.createStateByType; /** @@ -351,7 +352,7 @@ public class CalculatedFieldEntityMessageProcessor extends AbstractContextAwareM } if (state.isSizeOk()) { log.debug("[{}][{}] Reevaluating CF state", entityId, cfId); - processStateIfReady(state, null, ctx, Collections.singletonList(cfId), null, null, msg.getCallback()); + processStateIfReady(state, null, ctx, Collections.singletonList(cfId), null, CF_REEVALUATION_MSG, msg.getCallback()); } else { throw new RuntimeException(ctx.getSizeExceedsLimitMessage()); } @@ -432,7 +433,8 @@ public class CalculatedFieldEntityMessageProcessor extends AbstractContextAwareM if (!updatedArgs.isEmpty() || justRestored) { cfIdList = new ArrayList<>(cfIdList); cfIdList.add(ctx.getCfId()); - processStateIfReady(state, updatedArgs, ctx, cfIdList, tbMsgId, tbMsgType, callback); + String msgType = tbMsgType == null ? null : tbMsgType.name(); + processStateIfReady(state, updatedArgs, ctx, cfIdList, tbMsgId, msgType, callback); } else { callback.onSuccess(); } @@ -474,7 +476,7 @@ public class CalculatedFieldEntityMessageProcessor extends AbstractContextAwareM } private void processStateIfReady(CalculatedFieldState state, Map updatedArgs, CalculatedFieldCtx ctx, - List cfIdList, UUID tbMsgId, TbMsgType tbMsgType, TbCallback callback) throws CalculatedFieldException { + List cfIdList, UUID tbMsgId, String tbMsgType, TbCallback callback) throws CalculatedFieldException { callback = new MultipleTbCallback(CALLBACKS_PER_CF, callback); log.trace("[{}][{}] Processing state if ready. Current args: {}, updated args: {}", entityId, ctx.getCfId(), state.getArguments(), updatedArgs); CalculatedFieldEntityCtxId ctxId = new CalculatedFieldEntityCtxId(tenantId, ctx.getCfId(), entityId); @@ -492,19 +494,19 @@ public class CalculatedFieldEntityMessageProcessor extends AbstractContextAwareM callback.onSuccess(); } if (DebugModeUtil.isDebugAllAvailable(ctx.getCalculatedField())) { - systemContext.persistCalculatedFieldDebugEvent(tenantId, ctx.getCfId(), entityId, state.getArguments(), tbMsgId, tbMsgType, calculationResult.stringValue(), null); + systemContext.persistCalculatedFieldDebugEvent(tenantId, ctx.getCfId(), entityId, state.getArgumentsJson(), tbMsgId, tbMsgType, calculationResult.stringValue(), null); } } } else { if (DebugModeUtil.isDebugFailuresAvailable(ctx.getCalculatedField())) { String errorMsg = ctx.isInitialized() ? state.getReadinessStatus().errorMsg() : "Calculated field state is not initialized!"; - systemContext.persistCalculatedFieldDebugEvent(tenantId, ctx.getCfId(), entityId, state.getArguments(), tbMsgId, tbMsgType, null, errorMsg); + systemContext.persistCalculatedFieldDebugEvent(tenantId, ctx.getCfId(), entityId, state.getArgumentsJson(), tbMsgId, tbMsgType, null, errorMsg); } callback.onSuccess(); } } catch (Exception e) { log.debug("[{}][{}] Failed to process CF state", entityId, ctx.getCfId(), e); - throw CalculatedFieldException.builder().ctx(ctx).eventEntity(entityId).msgId(tbMsgId).msgType(tbMsgType).arguments(state.getArguments()).cause(e).build(); + throw CalculatedFieldException.builder().ctx(ctx).eventEntity(entityId).msgId(tbMsgId).msgType(tbMsgType).arguments(state.getArgumentsJson()).cause(e).build(); } finally { if (!stateSizeChecked) { state.checkStateSize(ctxId, ctx.getMaxStateSize()); diff --git a/application/src/main/java/org/thingsboard/server/actors/calculatedField/CalculatedFieldException.java b/application/src/main/java/org/thingsboard/server/actors/calculatedField/CalculatedFieldException.java index 70c8dfbfd2..0242a33145 100644 --- a/application/src/main/java/org/thingsboard/server/actors/calculatedField/CalculatedFieldException.java +++ b/application/src/main/java/org/thingsboard/server/actors/calculatedField/CalculatedFieldException.java @@ -15,14 +15,12 @@ */ package org.thingsboard.server.actors.calculatedField; +import com.fasterxml.jackson.databind.JsonNode; import lombok.Builder; import lombok.Getter; import org.thingsboard.server.common.data.id.EntityId; -import org.thingsboard.server.common.data.msg.TbMsgType; -import org.thingsboard.server.service.cf.ctx.state.ArgumentEntry; import org.thingsboard.server.service.cf.ctx.state.CalculatedFieldCtx; -import java.util.Map; import java.util.UUID; @Getter @@ -32,8 +30,8 @@ public class CalculatedFieldException extends Exception { private final CalculatedFieldCtx ctx; private final EntityId eventEntity; private final UUID msgId; - private final TbMsgType msgType; - private Map arguments; + private final String msgType; + private JsonNode arguments; private String errorMessage; private Exception cause; diff --git a/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/BaseCalculatedFieldState.java b/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/BaseCalculatedFieldState.java index 741c94c796..6df4240410 100644 --- a/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/BaseCalculatedFieldState.java +++ b/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/BaseCalculatedFieldState.java @@ -15,6 +15,7 @@ */ package org.thingsboard.server.service.cf.ctx.state; +import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.node.ObjectNode; import lombok.Getter; import lombok.Setter; @@ -33,6 +34,7 @@ import java.util.Collections; import java.util.HashMap; import java.util.List; import java.util.Map; +import java.util.stream.Collectors; @Getter public abstract class BaseCalculatedFieldState implements CalculatedFieldState, Closeable { @@ -185,4 +187,10 @@ public abstract class BaseCalculatedFieldState implements CalculatedFieldState, return ReadinessStatus.from(emptyArguments); } + @Override + public JsonNode getArgumentsJson() { + return JacksonUtil.valueToTree(arguments.entrySet().stream() + .collect(Collectors.toMap(Map.Entry::getKey, e -> e.getValue().jsonValue()))); + } + } diff --git a/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/CalculatedFieldCtx.java b/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/CalculatedFieldCtx.java index cadd34c420..62163fecc2 100644 --- a/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/CalculatedFieldCtx.java +++ b/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/CalculatedFieldCtx.java @@ -646,7 +646,9 @@ public class CalculatedFieldCtx implements Closeable { } if (calculatedField.getConfiguration() instanceof RelatedEntitiesAggregationCalculatedFieldConfiguration thisConfig && other.getCalculatedField().getConfiguration() instanceof RelatedEntitiesAggregationCalculatedFieldConfiguration otherConfig - && (thisConfig.getDeduplicationIntervalInSec() != otherConfig.getDeduplicationIntervalInSec() || !thisConfig.getMetrics().equals(otherConfig.getMetrics()))) { + && (thisConfig.getDeduplicationIntervalInSec() != otherConfig.getDeduplicationIntervalInSec() + || !thisConfig.getMetrics().equals(otherConfig.getMetrics()) + || thisConfig.isUseLatestTs() != otherConfig.isUseLatestTs())) { return true; } if (calculatedField.getConfiguration() instanceof EntityAggregationCalculatedFieldConfiguration thisConfig diff --git a/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/CalculatedFieldState.java b/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/CalculatedFieldState.java index e3914cc125..8a3c618254 100644 --- a/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/CalculatedFieldState.java +++ b/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/CalculatedFieldState.java @@ -19,6 +19,7 @@ import com.fasterxml.jackson.annotation.JsonIgnore; import com.fasterxml.jackson.annotation.JsonSubTypes; import com.fasterxml.jackson.annotation.JsonSubTypes.Type; import com.fasterxml.jackson.annotation.JsonTypeInfo; +import com.fasterxml.jackson.databind.JsonNode; import com.google.common.util.concurrent.ListenableFuture; import org.thingsboard.server.actors.TbActorRef; import org.thingsboard.server.common.data.cf.CalculatedFieldType; @@ -59,6 +60,8 @@ public interface CalculatedFieldState extends Closeable { Map getArguments(); + JsonNode getArgumentsJson(); + long getLatestTimestamp(); void setCtx(CalculatedFieldCtx ctx, TbActorRef actorCtx); diff --git a/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/aggregation/single/EntityAggregationCalculatedFieldState.java b/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/aggregation/single/EntityAggregationCalculatedFieldState.java index 6ead645322..0fadc59b8c 100644 --- a/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/aggregation/single/EntityAggregationCalculatedFieldState.java +++ b/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/aggregation/single/EntityAggregationCalculatedFieldState.java @@ -15,12 +15,15 @@ */ package org.thingsboard.server.service.cf.ctx.state.aggregation.single; +import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.node.ArrayNode; import com.fasterxml.jackson.databind.node.ObjectNode; import com.google.common.util.concurrent.Futures; import com.google.common.util.concurrent.ListenableFuture; +import org.thingsboard.common.util.DebugModeUtil; import org.thingsboard.common.util.JacksonUtil; import org.thingsboard.script.api.tbel.TbUtils; +import org.thingsboard.script.api.tbel.TbelCfArg; import org.thingsboard.server.actors.TbActorRef; import org.thingsboard.server.common.data.cf.CalculatedFieldType; import org.thingsboard.server.common.data.cf.configuration.Output; @@ -36,6 +39,7 @@ import org.thingsboard.server.service.cf.TelemetryCalculatedFieldResult; import org.thingsboard.server.service.cf.ctx.state.ArgumentEntry; import org.thingsboard.server.service.cf.ctx.state.BaseCalculatedFieldState; import org.thingsboard.server.service.cf.ctx.state.CalculatedFieldCtx; +import org.thingsboard.server.service.cf.ctx.state.SingleValueArgumentEntry; import java.time.Instant; import java.time.ZoneId; @@ -58,6 +62,8 @@ public class EntityAggregationCalculatedFieldState extends BaseCalculatedFieldSt private long checkInterval; private Map metrics; + private final EntityAggregationDebugArgumentsTracker debugTracker = new EntityAggregationDebugArgumentsTracker(new HashMap<>()); + private CalculatedFieldProcessingService cfProcessingService; public EntityAggregationCalculatedFieldState(EntityId entityId) { @@ -91,9 +97,14 @@ public class EntityAggregationCalculatedFieldState extends BaseCalculatedFieldSt @Override public ListenableFuture performCalculation(Map updatedArgs, CalculatedFieldCtx ctx) throws Exception { + debugTracker.reset(); createIntervalIfNotExist(); long now = System.currentTimeMillis(); + if (DebugModeUtil.isDebugFailuresAvailable(ctx.getCalculatedField())) { + debugTracker.recordUpdatedArgs(updatedArgs, arguments); + } + Map> results = new HashMap<>(); List expiredIntervals = new ArrayList<>(); getIntervals().forEach((intervalEntry, argIntervalStatuses) -> { @@ -114,6 +125,12 @@ public class EntityAggregationCalculatedFieldState extends BaseCalculatedFieldSt .build()); } + @Override + public Map update(Map argumentValues, CalculatedFieldCtx ctx) { + createIntervalIfNotExist(); + return super.update(argumentValues, ctx); + } + private void removeExpiredIntervals(List expiredIntervals) { expiredIntervals.forEach(expiredInterval -> { arguments.values().stream() @@ -183,6 +200,9 @@ public class EntityAggregationCalculatedFieldState extends BaseCalculatedFieldSt expiredIntervals.add(intervalEntry); } else if (now - startTs >= intervalEntry.getIntervalDuration()) { handleActiveInterval(intervalEntry, args, results); + if (watermarkDuration == 0) { + expiredIntervals.add(intervalEntry); + } } } @@ -262,14 +282,83 @@ public class EntityAggregationCalculatedFieldState extends BaseCalculatedFieldSt resultNode.put("ts", interval.getEndTs() - 1); resultNode.set("values", metricsNode); result.add(resultNode); + + if (DebugModeUtil.isDebugFailuresAvailable(ctx.getCalculatedField())) { + debugTracker.addInterval(interval); + } } }); return result; } + @Override + public JsonNode getArgumentsJson() { + EntityAggregationDebugArguments debugArguments = debugTracker.toDebugArguments(); + return debugArguments == null ? null : JacksonUtil.valueToTree(debugArguments); + } + @Override public boolean isReady() { return true; } + record EntityAggregationDebugArgumentsTracker(Map> processedIntervals) { + + public void reset() { + processedIntervals.clear(); + } + + public void addInterval(AggIntervalEntry interval) { + processedIntervals.computeIfAbsent(interval, k -> new HashMap<>()); + } + + public void recordUpdatedArgs(Map updatedArgs, Map arguments) { + if (updatedArgs != null && !updatedArgs.isEmpty()) { + updatedArgs.forEach((argName, argEntry) -> { + ArgumentEntry argumentEntry = arguments.get(argName); + if (argumentEntry instanceof EntityAggregationArgumentEntry entityAggEntry && argEntry instanceof SingleValueArgumentEntry singleEntry) { + entityAggEntry.getAggIntervals().forEach((aggIntervalEntry, aggIntervalEntryStatus) -> { + boolean match = singleEntry.isForceResetPrevious() || aggIntervalEntry.belongsToInterval(singleEntry.getTs()); + if (match) { + recordArg(aggIntervalEntry, argName, singleEntry.toTbelCfArg()); + } + }); + } + }); + } + } + + public void recordArg(AggIntervalEntry interval, String argName, TbelCfArg value) { + processedIntervals.computeIfAbsent(interval, k -> new HashMap<>()).put(argName, value); + } + + public EntityAggregationDebugArguments toDebugArguments() { + if (processedIntervals.isEmpty()) { + return null; + } + return EntityAggregationDebugArguments.toDebugArguments(processedIntervals); + } + + } + + record EntityAggregationDebugArguments(List processedIntervals) { + + public static EntityAggregationDebugArguments toDebugArguments(Map> processedIntervals) { + List result = new ArrayList<>(); + processedIntervals.forEach((interval, args) -> { + result.add(new IntervalDebugArgument(interval.getStartTs(), interval.getEndTs(), args)); + }); + return new EntityAggregationDebugArguments(result); + } + + } + + record IntervalDebugArgument(Long intervalStartTs, Long intervalEndTs, JsonNode updatedArguments) { + + public IntervalDebugArgument(Long intervalStartTs, Long intervalEndTs, Map updatedArguments) { + this(intervalStartTs, intervalEndTs, updatedArguments == null || updatedArguments.isEmpty() ? null : JacksonUtil.valueToTree(updatedArguments)); + } + + } + } diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/DataConstants.java b/common/data/src/main/java/org/thingsboard/server/common/data/DataConstants.java index 8a72b26a28..7830461109 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/DataConstants.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/DataConstants.java @@ -105,6 +105,8 @@ public class DataConstants { public static final String RPC_FAILED = "RPC_FAILED"; public static final String RPC_DELETED = "RPC_DELETED"; + public static final String CF_REEVALUATION_MSG = "CF_REEVALUATION_MSG"; + public static final String DEFAULT_SECRET_KEY = ""; public static final String SECRET_KEY_FIELD_NAME = "secretKey"; public static final String DURATION_MS_FIELD_NAME = "durationMs"; From 0e8b99c067a5a8d2c833ab2717a6c77be00ddc86 Mon Sep 17 00:00:00 2001 From: Maksym Tsymbarov Date: Tue, 25 Nov 2025 16:00:37 +0200 Subject: [PATCH 21/54] moved sorting of datasources --- .../system/widget_types/timeseries_table.json | 2 +- ...eries-table-widget-settings.component.html | 6 +- ...eseries-table-widget-settings.component.ts | 13 ++-- .../lib/timeseries-table-widget.component.ts | 64 +++---------------- .../assets/locale/locale.constant-en_US.json | 6 +- 5 files changed, 21 insertions(+), 70 deletions(-) diff --git a/application/src/main/data/json/system/widget_types/timeseries_table.json b/application/src/main/data/json/system/widget_types/timeseries_table.json index 0283899134..543d189f70 100644 --- a/application/src/main/data/json/system/widget_types/timeseries_table.json +++ b/application/src/main/data/json/system/widget_types/timeseries_table.json @@ -17,7 +17,7 @@ "latestDataKeySettingsDirective": "tb-timeseries-table-latest-key-settings", "hasBasicMode": true, "basicModeDirective": "tb-timeseries-table-basic-config", - "defaultConfig": "{\"datasources\":[{\"type\":\"function\",\"name\":\"function\",\"entityAliasId\":null,\"filterId\":null,\"dataKeys\":[{\"name\":\"f(x)\",\"type\":\"function\",\"label\":\"Temperature °C\",\"color\":\"#2196f3\",\"settings\":{\"useCellStyleFunction\":true,\"cellStyleFunction\":\"if (value) {\\n var percent = (value + 60)/120 * 100;\\n var color = tinycolor.mix('blue', 'red', percent);\\n color.setAlpha(.5);\\n return {\\n paddingLeft: '20px',\\n color: '#ffffff',\\n background: color.toRgbString(),\\n fontSize: '18px'\\n };\\n} else {\\n return {};\\n}\",\"useCellContentFunction\":false},\"_hash\":0.8587686344902596,\"funcBody\":\"var value = prevValue + Math.random() * 40 - 20;\\nvar multiplier = Math.pow(10, 1 || 0);\\nvar value = Math.round(value * multiplier) / multiplier;\\nif (value < -60) {\\n\\tvalue = -60;\\n} else if (value > 60) {\\n\\tvalue = 60;\\n}\\nreturn value;\",\"units\":null,\"decimals\":null,\"usePostProcessing\":null,\"postFuncBody\":null},{\"name\":\"f(x)\",\"type\":\"function\",\"label\":\"Humidity, %\",\"color\":\"#ffc107\",\"settings\":{\"useCellStyleFunction\":true,\"cellStyleFunction\":\"if (value) {\\n var percent = value;\\n var backgroundColor = tinycolor('blue');\\n backgroundColor.setAlpha(value/100);\\n var color = 'blue';\\n if (value > 50) {\\n color = 'white';\\n }\\n \\n return {\\n paddingLeft: '20px',\\n color: color,\\n background: backgroundColor.toRgbString(),\\n fontSize: '18px'\\n };\\n} else {\\n return {};\\n}\",\"useCellContentFunction\":false},\"_hash\":0.12775350966079668,\"funcBody\":\"var value = prevValue + Math.random() * 20 - 10;\\nvar multiplier = Math.pow(10, 1 || 0);\\nvar value = Math.round(value * multiplier) / multiplier;\\nif (value < 5) {\\n\\tvalue = 5;\\n} else if (value > 100) {\\n\\tvalue = 100;\\n}\\nreturn value;\"}],\"latestDataKeys\":[]}],\"showTitle\":true,\"backgroundColor\":\"rgb(255, 255, 255)\",\"color\":\"rgba(0, 0, 0, 0.87)\",\"padding\":\"8px\",\"settings\":{\"enableSearch\":true,\"enableSelectColumnDisplay\":true,\"enableStickyHeader\":true,\"enableStickyAction\":true,\"showCellActionsMenu\":true,\"reserveSpaceForHiddenAction\":\"true\",\"showTimestamp\":true,\"dateFormat\":{\"format\":\"yyyy-MM-dd HH:mm:ss\"},\"displayPagination\":true,\"useEntityLabel\":false,\"defaultPageSize\":10,\"pageStepCount\":3,\"pageStepIncrement\":10,\"hideEmptyLines\":false,\"disableStickyHeader\":false,\"useRowStyleFunction\":false,\"rowStyleFunction\":\"\",\"sortOrder\":{\"property\":\"createdTime\",\"direction\":\"ASC\"}},\"title\":\"Time series table\",\"dropShadow\":true,\"enableFullscreen\":true,\"titleStyle\":{\"fontSize\":\"16px\",\"fontWeight\":400,\"padding\":\"5px 10px 5px 10px\"},\"useDashboardTimewindow\":true,\"showLegend\":false,\"widgetStyle\":{},\"actions\":{},\"showTitleIcon\":false,\"iconColor\":\"rgba(0, 0, 0, 0.87)\",\"iconSize\":\"24px\",\"configMode\":\"basic\",\"titleFont\":null,\"titleColor\":null,\"titleIcon\":null}" + "defaultConfig": "{\"datasources\":[{\"type\":\"function\",\"name\":\"function\",\"entityAliasId\":null,\"filterId\":null,\"dataKeys\":[{\"name\":\"f(x)\",\"type\":\"function\",\"label\":\"Temperature °C\",\"color\":\"#2196f3\",\"settings\":{\"useCellStyleFunction\":true,\"cellStyleFunction\":\"if (value) {\\n var percent = (value + 60)/120 * 100;\\n var color = tinycolor.mix('blue', 'red', percent);\\n color.setAlpha(.5);\\n return {\\n paddingLeft: '20px',\\n color: '#ffffff',\\n background: color.toRgbString(),\\n fontSize: '18px'\\n };\\n} else {\\n return {};\\n}\",\"useCellContentFunction\":false},\"_hash\":0.8587686344902596,\"funcBody\":\"var value = prevValue + Math.random() * 40 - 20;\\nvar multiplier = Math.pow(10, 1 || 0);\\nvar value = Math.round(value * multiplier) / multiplier;\\nif (value < -60) {\\n\\tvalue = -60;\\n} else if (value > 60) {\\n\\tvalue = 60;\\n}\\nreturn value;\",\"units\":null,\"decimals\":null,\"usePostProcessing\":null,\"postFuncBody\":null},{\"name\":\"f(x)\",\"type\":\"function\",\"label\":\"Humidity, %\",\"color\":\"#ffc107\",\"settings\":{\"useCellStyleFunction\":true,\"cellStyleFunction\":\"if (value) {\\n var percent = value;\\n var backgroundColor = tinycolor('blue');\\n backgroundColor.setAlpha(value/100);\\n var color = 'blue';\\n if (value > 50) {\\n color = 'white';\\n }\\n \\n return {\\n paddingLeft: '20px',\\n color: color,\\n background: backgroundColor.toRgbString(),\\n fontSize: '18px'\\n };\\n} else {\\n return {};\\n}\",\"useCellContentFunction\":false},\"_hash\":0.12775350966079668,\"funcBody\":\"var value = prevValue + Math.random() * 20 - 10;\\nvar multiplier = Math.pow(10, 1 || 0);\\nvar value = Math.round(value * multiplier) / multiplier;\\nif (value < 5) {\\n\\tvalue = 5;\\n} else if (value > 100) {\\n\\tvalue = 100;\\n}\\nreturn value;\"}],\"latestDataKeys\":null}],\"timewindow\":{\"realtime\":{\"interval\":1000,\"timewindowMs\":60000},\"aggregation\":{\"type\":\"NONE\",\"limit\":200}},\"showTitle\":true,\"backgroundColor\":\"rgb(255, 255, 255)\",\"color\":\"rgba(0, 0, 0, 0.87)\",\"padding\":\"8px\",\"settings\":{\"enableSearch\":true,\"enableSelectColumnDisplay\":true,\"enableStickyHeader\":true,\"enableStickyAction\":true,\"showCellActionsMenu\":true,\"reserveSpaceForHiddenAction\":\"true\",\"showTimestamp\":true,\"dateFormat\":{\"format\":\"yyyy-MM-dd HH:mm:ss\"},\"displayPagination\":true,\"useEntityLabel\":false,\"defaultPageSize\":10,\"pageStepCount\":3,\"pageStepIncrement\":10,\"hideEmptyLines\":false,\"disableStickyHeader\":false,\"useRowStyleFunction\":false,\"rowStyleFunction\":\"\",\"sortOrder\":{\"property\":\"createdTime\",\"direction\":\"DESC\"}},\"title\":\"Timeseries table\",\"dropShadow\":true,\"enableFullscreen\":true,\"titleStyle\":{\"fontSize\":\"16px\",\"fontWeight\":400,\"padding\":\"5px 10px 5px 10px\"},\"useDashboardTimewindow\":false,\"showLegend\":false,\"widgetStyle\":{},\"actions\":{},\"showTitleIcon\":false,\"iconColor\":\"rgba(0, 0, 0, 0.87)\",\"iconSize\":\"24px\",\"displayTimewindow\":true,\"configMode\":\"advanced\"}" }, "resources": [ { diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/cards/timeseries-table-widget-settings.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/settings/cards/timeseries-table-widget-settings.component.html index ae07674162..bd223238a8 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/cards/timeseries-table-widget-settings.component.html +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/cards/timeseries-table-widget-settings.component.html @@ -113,7 +113,7 @@
widgets.table.table-tabs
- {{ 'widgets.table.use-entity-label-header-title' | translate }} + {{ 'widgets.table.use-entity-label-tab-name' | translate }}
widgets.table.sort-by
@@ -126,8 +126,8 @@ - {{ 'widgets.table.sort-asc' | translate }} - {{ 'widgets.table.sort-desc' | translate }} + {{ 'common.sort-asc' | translate }} + {{ 'common.sort-desc' | translate }}
diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/cards/timeseries-table-widget-settings.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/settings/cards/timeseries-table-widget-settings.component.ts index 95a9eb8014..d501d581c9 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/cards/timeseries-table-widget-settings.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/cards/timeseries-table-widget-settings.component.ts @@ -46,7 +46,6 @@ export class TimeseriesTableWidgetSettingsComponent extends WidgetSettingsCompon } protected defaultSettings(): WidgetSettings { - console.log("default") return { enableSearch: true, enableSelectColumnDisplay: true, @@ -67,7 +66,7 @@ export class TimeseriesTableWidgetSettingsComponent extends WidgetSettingsCompon rowStyleFunction: '', sortOrder: { property: this.entityFields.name.keyName, - direction: Direction.ASC + direction: Direction.DESC } }; } @@ -75,11 +74,10 @@ export class TimeseriesTableWidgetSettingsComponent extends WidgetSettingsCompon protected prepareInputSettings(settings: WidgetSettings): WidgetSettings { settings.pageStepIncrement = settings.pageStepIncrement ?? settings.defaultPageSize; settings.sortOrder = { - property: settings.sortOrder?.property || this.entityFields.name.keyName, - direction: settings.sortOrder?.direction || Direction.ASC + property: settings.sortOrder?.property || this.entityFields.createdTime.keyName, + direction: settings.sortOrder?.direction || Direction.DESC }; this.pageStepSizeValues = buildPageStepSizeValues(settings.pageStepCount, settings.pageStepIncrement); - console.log("input",settings) return settings; } @@ -128,10 +126,9 @@ export class TimeseriesTableWidgetSettingsComponent extends WidgetSettingsCompon protected prepareOutputSettings(settings: WidgetSettings): WidgetSettings { settings.sortOrder = { - property: settings.sortOrder?.property || this.entityFields.name.keyName, - direction: settings.sortOrder?.direction || Direction.ASC + property: settings.sortOrder?.property || this.entityFields.createdTime.keyName, + direction: settings.sortOrder?.direction || Direction.DESC }; - console.log("output",settings) return settings; } diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/timeseries-table-widget.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/timeseries-table-widget.component.ts index d6c73d7f82..5b18fac9c0 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/timeseries-table-widget.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/timeseries-table-widget.component.ts @@ -409,13 +409,10 @@ export class TimeseriesTableWidgetComponent extends PageComponent implements OnI return translated; } - private sortDatasources(source: Datasource[], entityLabelCache: Map): Datasource[] { + private sortDatasources(source: TimeseriesTableSource[], entityLabelCache: Map) { const property = this.settings?.sortOrder?.property; const direction = this.settings?.sortOrder?.direction; const isAsc = direction === Direction.ASC; - let sortedSource = [...source]; - - source.forEach(ds => this.getTabLabel(ds, entityLabelCache)); if (property === entityFields.name.keyName) { const collator = new Intl.Collator(undefined, { @@ -424,63 +421,19 @@ export class TimeseriesTableWidgetComponent extends PageComponent implements OnI ignorePunctuation: false }); - sortedSource.sort((a, b) => { - const valueA = entityLabelCache.get(a.entityId) || ''; - const valueB = entityLabelCache.get(b.entityId) || ''; + source.sort((a, b) => { + const valueA = entityLabelCache.get(a.datasource.entityId) || ''; + const valueB = entityLabelCache.get(a.datasource.entityId) || ''; return isAsc ? collator.compare(valueA, valueB) : collator.compare(valueB, valueA); }); } else if (property === entityFields.createdTime.keyName) { - if (!isAsc) { - sortedSource.reverse(); + if (isAsc) { + source.reverse(); } } - this.reorderDataArrays(source, sortedSource); - return sortedSource; - } - - private reorderDataArrays(originalOrder: Datasource[], newOrder: Datasource[]): void { - const indexMap = new Map(); - originalOrder.forEach((ds, oldIndex) => { - const newIndex = newOrder.findIndex(newDs => newDs.entityId === ds.entityId); - indexMap.set(oldIndex, newIndex); - }); - - const newData: Array = []; - originalOrder.forEach((ds, oldIndex) => { - const dataKeys = ds.dataKeys; - const startIdx = oldIndex * dataKeys.length; - const endIdx = startIdx + dataKeys.length; - const datasourceData = this.data.slice(startIdx, endIdx); - - const newIndex = indexMap.get(oldIndex); - const newStartIdx = newIndex * dataKeys.length; - - datasourceData.forEach((data, i) => { - newData[newStartIdx + i] = data; - }); - }); - this.data = newData; - - if (this.latestData && this.latestData.length > 0) { - const newLatestData: Array = []; - originalOrder.forEach((ds, oldIndex) => { - const latestDataKeys = ds.latestDataKeys || []; - const startIdx = oldIndex * latestDataKeys.length; - const endIdx = startIdx + latestDataKeys.length; - const datasourceLatestData = this.latestData.slice(startIdx, endIdx); - - const newIndex = indexMap.get(oldIndex); - const newStartIdx = newIndex * latestDataKeys.length; - - datasourceLatestData.forEach((data, i) => { - newLatestData[newStartIdx + i] = data; - }); - }); - this.latestData = newLatestData; - } } private updateDatasources() { @@ -491,8 +444,8 @@ export class TimeseriesTableWidgetComponent extends PageComponent implements OnI const entityLabelCache = new Map(); const pageSize = this.displayPagination ? this.defaultPageSize : Number.POSITIVE_INFINITY; if (this.datasources) { - const sortedDatasources = this.sortDatasources(this.datasources, entityLabelCache); - for (const datasource of sortedDatasources) { + this.datasources.forEach(ds => this.getTabLabel(ds, entityLabelCache)); + for (const datasource of this.datasources) { const sortOrder: SortOrder = sortOrderFromString(this.defaultSortOrder); const source = {} as TimeseriesTableSource; source.header = this.prepareHeader(datasource); @@ -529,6 +482,7 @@ export class TimeseriesTableWidgetComponent extends PageComponent implements OnI } } if (this.sources.length) { + this.sortDatasources(this.sources, entityLabelCache); this.sources.forEach((source, index) => { this.prepareDisplayedColumn(index); source.displayedColumns = this.displayedColumns[index].filter(value => value.display).map(value => value.def); 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 9850ec73d7..5a0be16baf 100644 --- a/ui-ngx/src/assets/locale/locale.constant-en_US.json +++ b/ui-ngx/src/assets/locale/locale.constant-en_US.json @@ -1639,6 +1639,8 @@ "documentation": "Documentation", "time-left": "{{time}} left", "output": "Output", + "sort-asc": "Ascending", + "sort-desc": "Descending", "suffix": { "s": "s", "ms": "ms" @@ -9482,7 +9484,7 @@ "page-step-increment": "Step increment", "page-step-count-format-message": "Should be an integer value, in the range from 1 to 100.", "page-step-increment-format-message": "Should be an integer value, greater or equal to 1.", - "use-entity-label-header-title": "Use entity label in header title", + "use-entity-label-tab-name": "Use entity label in tab name", "hide-empty-lines": "Hide empty lines", "row-style": "Row style", "use-row-style-function": "Use row style function", @@ -9538,8 +9540,6 @@ "show-cell-actions-menu-mobile": "Show cell actions dropdown menu in mobile mode", "disable-sorting": "Disable sorting", "sort-by": "Sort tabs by", - "sort-asc": "Ascending", - "sort-desc": "Descending", "sort-timestamp-option": "Created time" }, "latest-chart": { From 8fd3986ee9c846caf7035ed017ee652af8e147f4 Mon Sep 17 00:00:00 2001 From: Maksym Tsymbarov Date: Tue, 25 Nov 2025 16:08:30 +0200 Subject: [PATCH 22/54] Update timeseries_table.json --- .../main/data/json/system/widget_types/timeseries_table.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/application/src/main/data/json/system/widget_types/timeseries_table.json b/application/src/main/data/json/system/widget_types/timeseries_table.json index 543d189f70..24443cfecf 100644 --- a/application/src/main/data/json/system/widget_types/timeseries_table.json +++ b/application/src/main/data/json/system/widget_types/timeseries_table.json @@ -17,7 +17,7 @@ "latestDataKeySettingsDirective": "tb-timeseries-table-latest-key-settings", "hasBasicMode": true, "basicModeDirective": "tb-timeseries-table-basic-config", - "defaultConfig": "{\"datasources\":[{\"type\":\"function\",\"name\":\"function\",\"entityAliasId\":null,\"filterId\":null,\"dataKeys\":[{\"name\":\"f(x)\",\"type\":\"function\",\"label\":\"Temperature °C\",\"color\":\"#2196f3\",\"settings\":{\"useCellStyleFunction\":true,\"cellStyleFunction\":\"if (value) {\\n var percent = (value + 60)/120 * 100;\\n var color = tinycolor.mix('blue', 'red', percent);\\n color.setAlpha(.5);\\n return {\\n paddingLeft: '20px',\\n color: '#ffffff',\\n background: color.toRgbString(),\\n fontSize: '18px'\\n };\\n} else {\\n return {};\\n}\",\"useCellContentFunction\":false},\"_hash\":0.8587686344902596,\"funcBody\":\"var value = prevValue + Math.random() * 40 - 20;\\nvar multiplier = Math.pow(10, 1 || 0);\\nvar value = Math.round(value * multiplier) / multiplier;\\nif (value < -60) {\\n\\tvalue = -60;\\n} else if (value > 60) {\\n\\tvalue = 60;\\n}\\nreturn value;\",\"units\":null,\"decimals\":null,\"usePostProcessing\":null,\"postFuncBody\":null},{\"name\":\"f(x)\",\"type\":\"function\",\"label\":\"Humidity, %\",\"color\":\"#ffc107\",\"settings\":{\"useCellStyleFunction\":true,\"cellStyleFunction\":\"if (value) {\\n var percent = value;\\n var backgroundColor = tinycolor('blue');\\n backgroundColor.setAlpha(value/100);\\n var color = 'blue';\\n if (value > 50) {\\n color = 'white';\\n }\\n \\n return {\\n paddingLeft: '20px',\\n color: color,\\n background: backgroundColor.toRgbString(),\\n fontSize: '18px'\\n };\\n} else {\\n return {};\\n}\",\"useCellContentFunction\":false},\"_hash\":0.12775350966079668,\"funcBody\":\"var value = prevValue + Math.random() * 20 - 10;\\nvar multiplier = Math.pow(10, 1 || 0);\\nvar value = Math.round(value * multiplier) / multiplier;\\nif (value < 5) {\\n\\tvalue = 5;\\n} else if (value > 100) {\\n\\tvalue = 100;\\n}\\nreturn value;\"}],\"latestDataKeys\":null}],\"timewindow\":{\"realtime\":{\"interval\":1000,\"timewindowMs\":60000},\"aggregation\":{\"type\":\"NONE\",\"limit\":200}},\"showTitle\":true,\"backgroundColor\":\"rgb(255, 255, 255)\",\"color\":\"rgba(0, 0, 0, 0.87)\",\"padding\":\"8px\",\"settings\":{\"enableSearch\":true,\"enableSelectColumnDisplay\":true,\"enableStickyHeader\":true,\"enableStickyAction\":true,\"showCellActionsMenu\":true,\"reserveSpaceForHiddenAction\":\"true\",\"showTimestamp\":true,\"dateFormat\":{\"format\":\"yyyy-MM-dd HH:mm:ss\"},\"displayPagination\":true,\"useEntityLabel\":false,\"defaultPageSize\":10,\"pageStepCount\":3,\"pageStepIncrement\":10,\"hideEmptyLines\":false,\"disableStickyHeader\":false,\"useRowStyleFunction\":false,\"rowStyleFunction\":\"\",\"sortOrder\":{\"property\":\"createdTime\",\"direction\":\"DESC\"}},\"title\":\"Timeseries table\",\"dropShadow\":true,\"enableFullscreen\":true,\"titleStyle\":{\"fontSize\":\"16px\",\"fontWeight\":400,\"padding\":\"5px 10px 5px 10px\"},\"useDashboardTimewindow\":false,\"showLegend\":false,\"widgetStyle\":{},\"actions\":{},\"showTitleIcon\":false,\"iconColor\":\"rgba(0, 0, 0, 0.87)\",\"iconSize\":\"24px\",\"displayTimewindow\":true,\"configMode\":\"advanced\"}" + "defaultConfig": "{\"datasources\":[{\"type\":\"function\",\"name\":\"function\",\"entityAliasId\":null,\"filterId\":null,\"dataKeys\":[{\"name\":\"f(x)\",\"type\":\"function\",\"label\":\"Temperature °C\",\"color\":\"#2196f3\",\"settings\":{\"useCellStyleFunction\":true,\"cellStyleFunction\":\"if (value) {\\n var percent = (value + 60)/120 * 100;\\n var color = tinycolor.mix('blue', 'red', percent);\\n color.setAlpha(.5);\\n return {\\n paddingLeft: '20px',\\n color: '#ffffff',\\n background: color.toRgbString(),\\n fontSize: '18px'\\n };\\n} else {\\n return {};\\n}\",\"useCellContentFunction\":false},\"_hash\":0.8587686344902596,\"funcBody\":\"var value = prevValue + Math.random() * 40 - 20;\\nvar multiplier = Math.pow(10, 1 || 0);\\nvar value = Math.round(value * multiplier) / multiplier;\\nif (value < -60) {\\n\\tvalue = -60;\\n} else if (value > 60) {\\n\\tvalue = 60;\\n}\\nreturn value;\",\"units\":null,\"decimals\":null,\"usePostProcessing\":null,\"postFuncBody\":null},{\"name\":\"f(x)\",\"type\":\"function\",\"label\":\"Humidity, %\",\"color\":\"#ffc107\",\"settings\":{\"useCellStyleFunction\":true,\"cellStyleFunction\":\"if (value) {\\n var percent = value;\\n var backgroundColor = tinycolor('blue');\\n backgroundColor.setAlpha(value/100);\\n var color = 'blue';\\n if (value > 50) {\\n color = 'white';\\n }\\n \\n return {\\n paddingLeft: '20px',\\n color: color,\\n background: backgroundColor.toRgbString(),\\n fontSize: '18px'\\n };\\n} else {\\n return {};\\n}\",\"useCellContentFunction\":false},\"_hash\":0.12775350966079668,\"funcBody\":\"var value = prevValue + Math.random() * 20 - 10;\\nvar multiplier = Math.pow(10, 1 || 0);\\nvar value = Math.round(value * multiplier) / multiplier;\\nif (value < 5) {\\n\\tvalue = 5;\\n} else if (value > 100) {\\n\\tvalue = 100;\\n}\\nreturn value;\"}],\"latestDataKeys\":[]}],\"showTitle\":true,\"backgroundColor\":\"rgb(255, 255, 255)\",\"color\":\"rgba(0, 0, 0, 0.87)\",\"padding\":\"8px\",\"settings\":{\"enableSearch\":true,\"enableSelectColumnDisplay\":true,\"enableStickyHeader\":true,\"enableStickyAction\":true,\"showCellActionsMenu\":true,\"reserveSpaceForHiddenAction\":\"true\",\"showTimestamp\":true,\"dateFormat\":{\"format\":\"yyyy-MM-dd HH:mm:ss\"},\"displayPagination\":true,\"useEntityLabel\":false,\"defaultPageSize\":10,\"pageStepCount\":3,\"pageStepIncrement\":10,\"hideEmptyLines\":false,\"disableStickyHeader\":false,\"useRowStyleFunction\":false,\"rowStyleFunction\":\"\",\"sortOrder\":{\"property\":\"createdTime\",\"direction\":\"DESC\"}},\"title\":\"Time series table\",\"dropShadow\":true,\"enableFullscreen\":true,\"titleStyle\":{\"fontSize\":\"16px\",\"fontWeight\":400,\"padding\":\"5px 10px 5px 10px\"},\"useDashboardTimewindow\":true,\"showLegend\":false,\"widgetStyle\":{},\"actions\":{},\"showTitleIcon\":false,\"iconColor\":\"rgba(0, 0, 0, 0.87)\",\"iconSize\":\"24px\",\"configMode\":\"basic\",\"titleFont\":null,\"titleColor\":null,\"titleIcon\":null}" }, "resources": [ { From c4f7b385db756aa6c1c7e8b5b26f84e99599e114 Mon Sep 17 00:00:00 2001 From: Maksym Tsymbarov Date: Tue, 25 Nov 2025 16:17:10 +0200 Subject: [PATCH 23/54] fixed typo in getting translation for sort value --- .../components/widget/lib/timeseries-table-widget.component.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/timeseries-table-widget.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/timeseries-table-widget.component.ts index 5b18fac9c0..47322aaed1 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/timeseries-table-widget.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/timeseries-table-widget.component.ts @@ -423,7 +423,7 @@ export class TimeseriesTableWidgetComponent extends PageComponent implements OnI source.sort((a, b) => { const valueA = entityLabelCache.get(a.datasource.entityId) || ''; - const valueB = entityLabelCache.get(a.datasource.entityId) || ''; + const valueB = entityLabelCache.get(b.datasource.entityId) || ''; return isAsc ? collator.compare(valueA, valueB) From c74f0b42f5ba01fa320eb505d167b305bef7c10e Mon Sep 17 00:00:00 2001 From: Vladyslav_Prykhodko Date: Tue, 25 Nov 2025 16:17:27 +0200 Subject: [PATCH 24/54] UI: Fixed incorrect preview for microsoft team notification --- .../sent-notification-dialog.component.html | 4 +- ...action-button-configuration.component.html | 46 +++++++++---------- 2 files changed, 24 insertions(+), 26 deletions(-) diff --git a/ui-ngx/src/app/modules/home/pages/notification/sent/sent-notification-dialog.component.html b/ui-ngx/src/app/modules/home/pages/notification/sent/sent-notification-dialog.component.html index 35095a16e3..39c94d1ffb 100644 --- a/ui-ngx/src/app/modules/home/pages/notification/sent/sent-notification-dialog.component.html +++ b/ui-ngx/src/app/modules/home/pages/notification/sent/sent-notification-dialog.component.html @@ -233,7 +233,9 @@
{{ preview.processedTemplates.MICROSOFT_TEAMS.subject }}
{{ preview.processedTemplates.MICROSOFT_TEAMS.body }} - + @if (preview.processedTemplates.MICROSOFT_TEAMS.button?.enabled) { + + }
diff --git a/ui-ngx/src/app/modules/home/pages/notification/template/configuration/notification-action-button-configuration.component.html b/ui-ngx/src/app/modules/home/pages/notification/template/configuration/notification-action-button-configuration.component.html index 3eb330bb50..cba2c54a7b 100644 --- a/ui-ngx/src/app/modules/home/pages/notification/template/configuration/notification-action-button-configuration.component.html +++ b/ui-ngx/src/app/modules/home/pages/notification/template/configuration/notification-action-button-configuration.component.html @@ -44,8 +44,8 @@
-
- +
+ notification.action-type - - notification.link - - - {{ 'notification.link-required' | translate }} - - - {{ 'notification.link-max-length' | translate : - {length: actionButtonConfigForm.get('link').getError('maxlength').requiredLength} - }} - - - - + @if(actionButtonConfigForm.get('linkType').value === actionButtonLinkType.LINK) { + + notification.link + + + {{ 'notification.link-required' | translate }} + + + {{ 'notification.link-max-length' | translate : + {length: actionButtonConfigForm.get('link').getError('maxlength').requiredLength} + }} + + + } @else { + - - + }
From c0f8b87397916a377854bac310711d390d2e7a2d Mon Sep 17 00:00:00 2001 From: IrynaMatveieva Date: Tue, 25 Nov 2025 16:20:01 +0200 Subject: [PATCH 25/54] ignore null arguments --- .../single/EntityAggregationCalculatedFieldState.java | 2 ++ 1 file changed, 2 insertions(+) diff --git a/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/aggregation/single/EntityAggregationCalculatedFieldState.java b/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/aggregation/single/EntityAggregationCalculatedFieldState.java index 0fadc59b8c..dd67ec8123 100644 --- a/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/aggregation/single/EntityAggregationCalculatedFieldState.java +++ b/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/aggregation/single/EntityAggregationCalculatedFieldState.java @@ -15,6 +15,7 @@ */ package org.thingsboard.server.service.cf.ctx.state.aggregation.single; +import com.fasterxml.jackson.annotation.JsonInclude; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.node.ArrayNode; import com.fasterxml.jackson.databind.node.ObjectNode; @@ -353,6 +354,7 @@ public class EntityAggregationCalculatedFieldState extends BaseCalculatedFieldSt } + @JsonInclude(JsonInclude.Include.NON_NULL) record IntervalDebugArgument(Long intervalStartTs, Long intervalEndTs, JsonNode updatedArguments) { public IntervalDebugArgument(Long intervalStartTs, Long intervalEndTs, Map updatedArguments) { From 97a0e44f0a3181e4a6a19b9922a298cd3024dece Mon Sep 17 00:00:00 2001 From: dshvaika Date: Tue, 25 Nov 2025 16:33:35 +0200 Subject: [PATCH 26/54] Added validation for missing perimeter attribute key --- .../service/cf/AbstractCalculatedFieldProcessingService.java | 4 ++-- .../service/cf/ctx/state/geofencing/GeofencingZoneState.java | 5 ++++- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/application/src/main/java/org/thingsboard/server/service/cf/AbstractCalculatedFieldProcessingService.java b/application/src/main/java/org/thingsboard/server/service/cf/AbstractCalculatedFieldProcessingService.java index d4d7fd8bf8..b8f1822bab 100644 --- a/application/src/main/java/org/thingsboard/server/service/cf/AbstractCalculatedFieldProcessingService.java +++ b/application/src/main/java/org/thingsboard/server/service/cf/AbstractCalculatedFieldProcessingService.java @@ -174,9 +174,9 @@ public abstract class AbstractCalculatedFieldProcessingService { return future.get(); } catch (ExecutionException e) { Throwable cause = e.getCause(); - throw new RuntimeException("Failed to fetch " + key + ": " + cause.getMessage(), cause); + throw new RuntimeException("Failed to fetch '" + key + "' argument: " + cause.getMessage(), cause); } catch (InterruptedException e) { - throw new RuntimeException("Failed to fetch" + key, e); + throw new RuntimeException("Failed to fetch '" + key + "' argument!", e); } } diff --git a/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/geofencing/GeofencingZoneState.java b/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/geofencing/GeofencingZoneState.java index c849f5d169..ca4108570c 100644 --- a/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/geofencing/GeofencingZoneState.java +++ b/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/geofencing/GeofencingZoneState.java @@ -46,10 +46,13 @@ public class GeofencingZoneState { public GeofencingZoneState(EntityId zoneId, KvEntry entry) { this.zoneId = zoneId; if (!(entry instanceof AttributeKvEntry attributeKvEntry)) { - throw new IllegalArgumentException("Unsupported KvEntry type for geofencing zone state: " + entry.getClass().getSimpleName()); + throw new IllegalArgumentException("Invalid perimeter data source for zone with id: " + zoneId + ". Perimeter definition must be stored as attribute!"); } this.ts = attributeKvEntry.getLastUpdateTs(); this.version = attributeKvEntry.getVersion(); + if (entry.getValueAsString() == null) { + throw new IllegalArgumentException("Perimeter attribute key '" + entry.getKey() + "' not found for Zone with id: " + zoneId); + } this.perimeterDefinition = JacksonUtil.fromString(entry.getValueAsString(), PerimeterDefinition.class); } From 53598ac49c1c1a4a3a0eb3cabf676a68307f2ebc Mon Sep 17 00:00:00 2001 From: Maksym Tsymbarov Date: Tue, 25 Nov 2025 16:43:37 +0200 Subject: [PATCH 27/54] updated api usage dashboard --- ...eseries-table-widget-settings.component.ts | 2 +- .../lib/timeseries-table-widget.component.ts | 22 ++++++------------- ui-ngx/src/assets/dashboard/api_usage.json | 5 ++++- 3 files changed, 12 insertions(+), 17 deletions(-) diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/cards/timeseries-table-widget-settings.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/settings/cards/timeseries-table-widget-settings.component.ts index d501d581c9..3911506be7 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/cards/timeseries-table-widget-settings.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/cards/timeseries-table-widget-settings.component.ts @@ -65,7 +65,7 @@ export class TimeseriesTableWidgetSettingsComponent extends WidgetSettingsCompon useRowStyleFunction: false, rowStyleFunction: '', sortOrder: { - property: this.entityFields.name.keyName, + property: this.entityFields.createdTime.keyName, direction: Direction.DESC } }; diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/timeseries-table-widget.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/timeseries-table-widget.component.ts index 47322aaed1..282fab9a3e 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/timeseries-table-widget.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/timeseries-table-widget.component.ts @@ -395,21 +395,15 @@ export class TimeseriesTableWidgetComponent extends PageComponent implements OnI this.updateDatasources(); } - private getTabLabel(source: Datasource, entityLabelCache:Map):string { - if (entityLabelCache.has(source.entityId)) { - return entityLabelCache.get(source.entityId); - } - + private getTabLabel(source: Datasource):string { const value = this.useEntityLabel ? (source.entityLabel || source.entityName) : source.entityName; - const translated = this.utils.customTranslation(value); - entityLabelCache.set(source.entityId, translated); - return translated; + return this.utils.customTranslation(value); } - private sortDatasources(source: TimeseriesTableSource[], entityLabelCache: Map) { + private sortDatasources(source: TimeseriesTableSource[]) { const property = this.settings?.sortOrder?.property; const direction = this.settings?.sortOrder?.direction; const isAsc = direction === Direction.ASC; @@ -422,8 +416,8 @@ export class TimeseriesTableWidgetComponent extends PageComponent implements OnI }); source.sort((a, b) => { - const valueA = entityLabelCache.get(a.datasource.entityId) || ''; - const valueB = entityLabelCache.get(b.datasource.entityId) || ''; + const valueA = a.displayName || ''; + const valueB = b.displayName || ''; return isAsc ? collator.compare(valueA, valueB) @@ -441,10 +435,8 @@ export class TimeseriesTableWidgetComponent extends PageComponent implements OnI this.sourceIndex = 0; let keyOffset = 0; let latestKeyOffset = 0; - const entityLabelCache = new Map(); const pageSize = this.displayPagination ? this.defaultPageSize : Number.POSITIVE_INFINITY; if (this.datasources) { - this.datasources.forEach(ds => this.getTabLabel(ds, entityLabelCache)); for (const datasource of this.datasources) { const sortOrder: SortOrder = sortOrderFromString(this.defaultSortOrder); const source = {} as TimeseriesTableSource; @@ -462,7 +454,7 @@ export class TimeseriesTableWidgetComponent extends PageComponent implements OnI source.pageLink = new PageLink(pageSize, 0, null, sortOrder); source.rowDataTemplate = {}; source.rowDataTemplate.Timestamp = null; - source.displayName = entityLabelCache.get(datasource.entityId); + source.displayName = this.getTabLabel(datasource); if (this.showTimestamp) { source.displayedColumns.push('0'); } @@ -482,7 +474,7 @@ export class TimeseriesTableWidgetComponent extends PageComponent implements OnI } } if (this.sources.length) { - this.sortDatasources(this.sources, entityLabelCache); + this.sortDatasources(this.sources); this.sources.forEach((source, index) => { this.prepareDisplayedColumn(index); source.displayedColumns = this.displayedColumns[index].filter(value => value.display).map(value => value.def); diff --git a/ui-ngx/src/assets/dashboard/api_usage.json b/ui-ngx/src/assets/dashboard/api_usage.json index 994b431ade..e45e9a59fa 100644 --- a/ui-ngx/src/assets/dashboard/api_usage.json +++ b/ui-ngx/src/assets/dashboard/api_usage.json @@ -99,7 +99,10 @@ "showTimestamp": true, "displayPagination": true, "defaultPageSize": 10, - "tabSortKey": "NAME_ASC" + "sortOrder": { + "property": "createdTime", + "direction": "DESC" + } }, "title": "{i18n:api-usage.exceptions}", "dropShadow": true, From 7fb7e3f24c1a08c750ce7c4c82932286d0d4feb6 Mon Sep 17 00:00:00 2001 From: Volodymyr Babak Date: Tue, 25 Nov 2025 16:53:39 +0200 Subject: [PATCH 28/54] Refactoring - revert replaceUserCredentials. Added removeUserCredentials and use it instead --- .../rpc/processor/user/BaseUserProcessor.java | 35 +++--------- .../server/dao/user/UserService.java | 3 +- .../server/dao/user/UserServiceImpl.java | 56 +++++++------------ 3 files changed, 30 insertions(+), 64 deletions(-) diff --git a/application/src/main/java/org/thingsboard/server/service/edge/rpc/processor/user/BaseUserProcessor.java b/application/src/main/java/org/thingsboard/server/service/edge/rpc/processor/user/BaseUserProcessor.java index 0da0e4aade..fe640505b1 100644 --- a/application/src/main/java/org/thingsboard/server/service/edge/rpc/processor/user/BaseUserProcessor.java +++ b/application/src/main/java/org/thingsboard/server/service/edge/rpc/processor/user/BaseUserProcessor.java @@ -23,7 +23,6 @@ import org.thingsboard.server.common.data.StringUtils; import org.thingsboard.server.common.data.User; import org.thingsboard.server.common.data.id.CustomerId; import org.thingsboard.server.common.data.id.TenantId; -import org.thingsboard.server.common.data.id.UserCredentialsId; import org.thingsboard.server.common.data.id.UserId; import org.thingsboard.server.common.data.security.UserCredentials; import org.thingsboard.server.dao.service.DataValidator; @@ -90,40 +89,24 @@ public abstract class BaseUserProcessor extends BaseEdgeProcessor { } protected void updateUserCredentials(TenantId tenantId, UserCredentialsUpdateMsg updateMsg) { - UserCredentials userCredentialsFromUpdateMsg = JacksonUtil.fromString(updateMsg.getEntity(), UserCredentials.class, true); - if (userCredentialsFromUpdateMsg == null) { + UserCredentials userCredentials = JacksonUtil.fromString(updateMsg.getEntity(), UserCredentials.class, true); + if (userCredentials == null) { throw new IllegalArgumentException(String.format("[%s] Failed to parse UserCredentials from updateMsg: %s", tenantId, updateMsg)); } - - User user = edgeCtx.getUserService().findUserById(tenantId, userCredentialsFromUpdateMsg.getUserId()); + User user = edgeCtx.getUserService().findUserById(tenantId, userCredentials.getUserId()); if (user == null) { log.warn("[{}] Can't find user by id [{}] skipping credentials update. UserCredentialsUpdateMsg [{}]", - tenantId, userCredentialsFromUpdateMsg.getUserId(), updateMsg); + tenantId, userCredentials.getUserId(), updateMsg); return; } - log.debug("[{}] Updating user credentials for user [{}]. New credentials Id [{}], enabled [{}]", - tenantId, user.getName(), userCredentialsFromUpdateMsg.getId(), userCredentialsFromUpdateMsg.isEnabled()); - + tenantId, user.getName(), userCredentials.getId(), userCredentials.isEnabled()); try { - UserCredentials existing = edgeCtx.getUserService().findUserCredentialsByUserId(tenantId, user.getId()); - boolean created = existing == null; - UserCredentialsId oldCredentialsId = created ? null : existing.getId(); - - UserCredentials updated = created ? new UserCredentials() : existing; - updated.setId(userCredentialsFromUpdateMsg.getId()); - updated.setUserId(user.getId()); - updated.setEnabled(userCredentialsFromUpdateMsg.isEnabled()); - updated.setActivateToken(userCredentialsFromUpdateMsg.getActivateToken()); - updated.setAdditionalInfo(userCredentialsFromUpdateMsg.getAdditionalInfo()); - updated.setPassword(userCredentialsFromUpdateMsg.getPassword()); - updated.setResetToken(userCredentialsFromUpdateMsg.getResetToken()); - - if (created) { - edgeCtx.getUserService().saveUserCredentials(tenantId, updated, false); - } else { - edgeCtx.getUserService().replaceUserCredentials(tenantId, updated, oldCredentialsId, false); + UserCredentials userCredentialsByUserId = edgeCtx.getUserService().findUserCredentialsByUserId(tenantId, user.getId()); + if (userCredentialsByUserId != null && !userCredentialsByUserId.getId().equals(userCredentials.getId())) { + edgeCtx.getUserService().deleteUserCredentials(tenantId, userCredentialsByUserId); } + edgeCtx.getUserService().saveUserCredentials(tenantId, userCredentials, false); } catch (Exception e) { log.error("[{}] Can't update user credentials for user [{}], userCredentialsUpdateMsg [{}]", tenantId, user.getName(), updateMsg, e); diff --git a/common/dao-api/src/main/java/org/thingsboard/server/dao/user/UserService.java b/common/dao-api/src/main/java/org/thingsboard/server/dao/user/UserService.java index c3f7b72ed3..1062982885 100644 --- a/common/dao-api/src/main/java/org/thingsboard/server/dao/user/UserService.java +++ b/common/dao-api/src/main/java/org/thingsboard/server/dao/user/UserService.java @@ -74,8 +74,7 @@ public interface UserService extends EntityDaoService { UserCredentials replaceUserCredentials(TenantId tenantId, UserCredentials userCredentials); - UserCredentials replaceUserCredentials(TenantId tenantId, UserCredentials userCredentials, - UserCredentialsId oldUserCredentialsId, boolean doValidate); + void deleteUserCredentials(TenantId tenantId, UserCredentials userCredentials); void deleteUser(TenantId tenantId, User user); diff --git a/dao/src/main/java/org/thingsboard/server/dao/user/UserServiceImpl.java b/dao/src/main/java/org/thingsboard/server/dao/user/UserServiceImpl.java index bdcac80d4a..e10d87e760 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/user/UserServiceImpl.java +++ b/dao/src/main/java/org/thingsboard/server/dao/user/UserServiceImpl.java @@ -79,7 +79,6 @@ import java.util.List; import java.util.Map; import java.util.Objects; import java.util.Optional; -import java.util.UUID; import java.util.concurrent.TimeUnit; import java.util.stream.Collectors; @@ -99,6 +98,7 @@ public class UserServiceImpl extends AbstractCachedEntityService tenantId); + userCredentialsDao.removeById(tenantId, userCredentials.getUuidId()); + userCredentials.setId(null); + if (userCredentials.getPassword() != null) { + updatePasswordHistory(userCredentials); + } + UserCredentials result = userCredentialsDao.save(tenantId, userCredentials); + eventPublisher.publishEvent(ActionEntityEvent.builder() + .tenantId(tenantId) + .entityId(userCredentials.getUserId()) + .actionType(ActionType.CREDENTIALS_UPDATED).build()); + return result; } @Override - public UserCredentials replaceUserCredentials(TenantId tenantId, UserCredentials userCredentials, - UserCredentialsId oldUserCredentialsId, boolean doValidate) { - return replaceUserCredentialsInternal(tenantId, userCredentials, oldUserCredentialsId.getId(), doValidate); - } - - private UserCredentials replaceUserCredentialsInternal(TenantId tenantId, UserCredentials userCredentials, - UUID oldCredentialsUuid, boolean doValidate) { - log.trace("[{}] Replacing user credentials for user [{}], old credentials ID [{}]", - tenantId, userCredentials.getUserId(), oldCredentialsUuid); - - if (doValidate) { - userCredentialsValidator.validate(userCredentials, data -> tenantId); - } - - try { - userCredentialsDao.removeById(tenantId, oldCredentialsUuid); - userCredentials.setId(null); - if (userCredentials.getPassword() != null) { - updatePasswordHistory(userCredentials); - } - - UserCredentials savedCredentials = userCredentialsDao.save(tenantId, userCredentials); - - eventPublisher.publishEvent(ActionEntityEvent.builder() - .tenantId(tenantId) - .entityId(userCredentials.getUserId()) - .actionType(ActionType.CREDENTIALS_UPDATED) - .build()); - - return savedCredentials; - } catch (Exception e) { - log.error("[{}] Failed to replace user credentials for user [{}]", tenantId, userCredentials.getUserId(), e); - throw new RuntimeException("Failed to replace user credentials", e); - } + public void deleteUserCredentials(TenantId tenantId, UserCredentials userCredentials) { + Objects.requireNonNull(userCredentials, "UserCredentials is null"); + UserCredentialsId userCredentialsId = userCredentials.getId(); + log.trace("[{}] Executing deleteUserCredentials [{}]", tenantId, userCredentialsId); + validateId(userCredentialsId, id -> INCORRECT_USER_CREDENTIALS_ID + id); + userCredentialsDao.removeById(tenantId, userCredentialsId.getId()); } @Override From dcf46a1cec14a150d63eddde046570cc71f7d9c7 Mon Sep 17 00:00:00 2001 From: Vladyslav_Prykhodko Date: Tue, 25 Nov 2025 16:54:20 +0200 Subject: [PATCH 29/54] UI: Updated cf output strategy hint --- .../output/calculated-field-output.component.html | 10 +++++----- ui-ngx/src/assets/locale/locale.constant-en_US.json | 10 +++++++--- 2 files changed, 12 insertions(+), 8 deletions(-) diff --git a/ui-ngx/src/app/modules/home/components/calculated-fields/components/output/calculated-field-output.component.html b/ui-ngx/src/app/modules/home/components/calculated-fields/components/output/calculated-field-output.component.html index 7e23acceae..747faac88f 100644 --- a/ui-ngx/src/app/modules/home/components/calculated-fields/components/output/calculated-field-output.component.html +++ b/ui-ngx/src/app/modules/home/components/calculated-fields/components/output/calculated-field-output.component.html @@ -98,14 +98,14 @@ @if (outputForm.get('type').value === OutputType.Timeseries) {
-
+
calculated-fields.output-strategy.save-time-series
-
+
calculated-fields.output-strategy.save-latest-values
@@ -113,7 +113,7 @@ } @else {
-
+
calculated-fields.output-strategy.save-database
@@ -121,14 +121,14 @@ }
-
+
calculated-fields.output-strategy.send-web-sockets
-
+
calculated-fields.output-strategy.save-calculated-fields
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 09dfb9cb2c..d208ef3fd4 100644 --- a/ui-ngx/src/assets/locale/locale.constant-en_US.json +++ b/ui-ngx/src/assets/locale/locale.constant-en_US.json @@ -1241,7 +1241,6 @@ "strategy": "Strategy", "process-right-away": "Process right away", "process-rule-chains": "Process via Rule Chains", - "processing-options": "Processing options", "save-time-series": "Save to time series", "save-database": "Save to database", "save-latest-values": "Save to latest values", @@ -1252,11 +1251,16 @@ "ttl-required": "TTL is required", "ttl-min": "Only 0 minimum TTL is allowed", "hint": { - "strategy": "Strategy", + "strategy": "Controls whether the result is processed immediately or sent to a rule chain for additional processing", "processing-options": "Processing options", "update-attributes-only-on-value-change": "Updates the attributes on every incoming message disregarding if their value has changed. Increases API usage and reduces performance.", "update-attributes-only-on-value-change-enabled": "Updates the attributes only if their value has changed. If the value is not changed, no update to the attribute timestamp nor attribute change notification will be sent.", - "ttl": "Uncheck to use the default system settings" + "save-time-series": "Saves time series data to the ts_kv table in the database.", + "save-database": "Saves attribute data to the database.", + "save-latest-values": "Updates time series data in the ts_kv_latest table in the database if the new value is more recent.", + "send-web-sockets": "Notifies WebSocket subscriptions about updates to the attribute data.", + "save-calculated-fields": "Notifies calculated fields about updates to the attribute data.", + "ttl": "Defines the retention period for time series data. If disabled, the Tenant Profile TTL is used." } }, "aggregate-interval-type": "Aggregate interval type", From e5519f3d8ea260e8949c37db4b562ea95a5373d5 Mon Sep 17 00:00:00 2001 From: dshvaika Date: Tue, 25 Nov 2025 16:54:46 +0200 Subject: [PATCH 30/54] fixed error message --- .../server/service/cf/ctx/state/CalculatedFieldState.java | 2 +- .../cf/ctx/state/PropagationCalculatedFieldStateTest.java | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/CalculatedFieldState.java b/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/CalculatedFieldState.java index ec5681202f..998d7aa4a6 100644 --- a/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/CalculatedFieldState.java +++ b/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/CalculatedFieldState.java @@ -105,7 +105,7 @@ public interface CalculatedFieldState extends Closeable { private static final String MISSING_REQUIRED_ARGUMENTS_ERROR = "Required arguments are missing: "; private static final String MISSING_PROPAGATION_TARGETS_ERROR = "No entities found via 'Propagation path to related entities'. " + - "Verify the relation type and direction configured."; + "Verify the configured relation type and direction."; private static final String MISSING_PROPAGATION_TARGETS_AND_ARGUMENTS_ERROR = MISSING_PROPAGATION_TARGETS_ERROR + " Missing arguments to propagate: "; private static final ReadinessStatus READY = new ReadinessStatus(true, null); diff --git a/application/src/test/java/org/thingsboard/server/service/cf/ctx/state/PropagationCalculatedFieldStateTest.java b/application/src/test/java/org/thingsboard/server/service/cf/ctx/state/PropagationCalculatedFieldStateTest.java index e5bd6720e0..202b88b2eb 100644 --- a/application/src/test/java/org/thingsboard/server/service/cf/ctx/state/PropagationCalculatedFieldStateTest.java +++ b/application/src/test/java/org/thingsboard/server/service/cf/ctx/state/PropagationCalculatedFieldStateTest.java @@ -151,7 +151,7 @@ public class PropagationCalculatedFieldStateTest { state.update(args, ctx); assertThat(state.isReady()).isFalse(); assertThat(state.getReadinessStatus().errorMsg()) - .isEqualTo("No entities found via 'Propagation path to related entities'. Verify the relation type and direction configured."); + .isEqualTo("No entities found via 'Propagation path to related entities'. Verify the configured relation type and direction."); } @Test From c5a8a874a97e20c67db49ea12b948cfb4d8c508e Mon Sep 17 00:00:00 2001 From: Volodymyr Babak Date: Tue, 25 Nov 2025 17:00:29 +0200 Subject: [PATCH 31/54] refacotring to be in sync with PE --- .../edge/rpc/processor/user/BaseUserProcessor.java | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/application/src/main/java/org/thingsboard/server/service/edge/rpc/processor/user/BaseUserProcessor.java b/application/src/main/java/org/thingsboard/server/service/edge/rpc/processor/user/BaseUserProcessor.java index fe640505b1..a742d83ada 100644 --- a/application/src/main/java/org/thingsboard/server/service/edge/rpc/processor/user/BaseUserProcessor.java +++ b/application/src/main/java/org/thingsboard/server/service/edge/rpc/processor/user/BaseUserProcessor.java @@ -47,8 +47,12 @@ public abstract class BaseUserProcessor extends BaseEdgeProcessor { } User userById = edgeCtx.getUserService().findUserById(tenantId, userId); - isCreated = userById == null; - user.setId(isCreated ? null : userId); + if (userById == null) { + isCreated = true; + user.setId(null); + } else { + user.setId(userId); + } String userEmail = user.getEmail(); User existing = edgeCtx.getUserService().findUserByTenantIdAndEmail(tenantId, user.getEmail()); From fb1e4632495d006eb64c510ccd25dc8034729acf Mon Sep 17 00:00:00 2001 From: ArtemDzhereleiko Date: Tue, 25 Nov 2025 17:20:57 +0200 Subject: [PATCH 32/54] UI: Bug fixes --- .../alarm-rule-dialog.component.html | 9 ++- .../alarm-rule-filter-config.component.ts | 5 +- .../alarm-rule-table-header.component.ts | 1 - .../alarm-rules/alarm-rules-table-config.ts | 31 +++++--- ...alarm-rule-condition-dialog.component.html | 19 ++++- ...f-alarm-rule-condition-dialog.component.ts | 71 ++++++++++++++++- .../cf-alarm-rule-condition.component.html | 2 +- .../cf-alarm-rule-condition.component.ts | 21 +++-- .../alarm-rules/cf-alarm-rule.component.html | 2 +- .../alarm-rules/cf-alarm-rule.component.scss | 1 + .../alarm-rules/cf-alarm-rule.component.ts | 9 ++- .../create-cf-alarm-rules.component.html | 3 +- .../create-cf-alarm-rules.component.ts | 4 +- .../alarm-rule-filter-dialog.component.html | 5 +- .../alarm-rule-filter-list.component.html | 1 - ...-rule-filter-predicate-list.component.html | 2 - .../calculated-fields-table-config.ts | 16 +++- ...ulated-field-argument-panel.component.html | 25 ++---- ...lculated-field-argument-panel.component.ts | 45 ++++++++--- ...lated-field-arguments-table.component.html | 6 +- ...culated-field-arguments-table.component.ts | 3 + .../propagate-arguments-table.component.ts | 1 + .../calculated-field-dialog.component.html | 1 + ...eofencing-zone-groups-panel.component.html | 3 +- ...-geofencing-zone-groups-panel.component.ts | 13 ++-- ...culated-field-metrics-panel.component.html | 48 ++++++++++-- ...alculated-field-metrics-panel.component.ts | 76 +++++++++++++++++-- ...alculated-field-metrics-table.component.ts | 5 +- ...ities-aggregation-component.component.html | 1 + ...ntities-aggregation-component.component.ts | 4 +- .../pages/asset/asset-tabs.component.html | 4 +- .../home/pages/asset/asset-tabs.component.ts | 8 ++ .../pages/device/device-tabs.component.html | 4 +- .../pages/device/device-tabs.component.ts | 8 ++ .../entity-key-autocomplete.component.html | 2 +- .../entity-key-autocomplete.component.ts | 7 ++ .../entity/entity-subtype-list.component.ts | 2 +- .../app/shared/models/alarm-rule.models.ts | 4 + .../shared/models/calculated-field.models.ts | 7 ++ .../assets/locale/locale.constant-en_US.json | 38 ++++++---- 40 files changed, 404 insertions(+), 113 deletions(-) diff --git a/ui-ngx/src/app/modules/home/components/alarm-rules/alarm-rule-dialog.component.html b/ui-ngx/src/app/modules/home/components/alarm-rules/alarm-rule-dialog.component.html index d4a93d461f..eefa6ff209 100644 --- a/ui-ngx/src/app/modules/home/components/alarm-rules/alarm-rule-dialog.component.html +++ b/ui-ngx/src/app/modules/home/components/alarm-rules/alarm-rule-dialog.component.html @@ -58,6 +58,7 @@
{{ 'alarm-rule.create-conditions' | translate }}
- +
@@ -97,7 +100,7 @@
- +
diff --git a/ui-ngx/src/app/modules/home/components/alarm-rules/alarm-rule-filter-config.component.ts b/ui-ngx/src/app/modules/home/components/alarm-rules/alarm-rule-filter-config.component.ts index c961cebe33..a19fc688a8 100644 --- a/ui-ngx/src/app/modules/home/components/alarm-rules/alarm-rule-filter-config.component.ts +++ b/ui-ngx/src/app/modules/home/components/alarm-rules/alarm-rule-filter-config.component.ts @@ -90,7 +90,7 @@ export class AlarmRuleFilterConfigComponent implements OnInit, ControlValueAcces panelMode = false; - buttonDisplayValue = this.translate.instant('alarm-rule.alarm-rule-filter'); + buttonDisplayValue = this.translate.instant('alarm-rule.alarm-rule-filter-title'); alarmRuleFilterConfigForm: FormGroup; @@ -281,6 +281,9 @@ export class AlarmRuleFilterConfigComponent implements OnInit, ControlValueAcces if (this.alarmRuleFilterConfig?.name?.length) { filterTextParts.push(this.alarmRuleFilterConfig.name.map((type) => this.customTranslate(type)).join(', ')); } + if (this.alarmRuleFilterConfig?.entityType) { + filterTextParts.push(this.translate.instant( entityTypeTranslations.get(this.alarmRuleFilterConfig.entityType).type)); + } if (!filterTextParts.length) { this.buttonDisplayValue = this.translate.instant('alarm-rule.alarm-rule-filter-title'); } else { diff --git a/ui-ngx/src/app/modules/home/components/alarm-rules/alarm-rule-table-header.component.ts b/ui-ngx/src/app/modules/home/components/alarm-rules/alarm-rule-table-header.component.ts index cf5462d1a2..8f2501d8f1 100644 --- a/ui-ngx/src/app/modules/home/components/alarm-rules/alarm-rule-table-header.component.ts +++ b/ui-ngx/src/app/modules/home/components/alarm-rules/alarm-rule-table-header.component.ts @@ -18,7 +18,6 @@ import { Component } from '@angular/core'; import { Store } from '@ngrx/store'; import { AppState } from '@core/core.state'; import { EntityTableHeaderComponent } from '../../components/entity/entity-table-header.component'; -import { AlarmFilterConfig } from '@shared/models/query/query.models'; import { CalculatedFieldAlarmRule, CalculatedFieldsQuery } from "@shared/models/calculated-field.models"; import { AlarmRulesTableConfig } from "@home/components/alarm-rules/alarm-rules-table-config"; diff --git a/ui-ngx/src/app/modules/home/components/alarm-rules/alarm-rules-table-config.ts b/ui-ngx/src/app/modules/home/components/alarm-rules/alarm-rules-table-config.ts index 1dbb7fdc83..c581db2b2b 100644 --- a/ui-ngx/src/app/modules/home/components/alarm-rules/alarm-rules-table-config.ts +++ b/ui-ngx/src/app/modules/home/components/alarm-rules/alarm-rules-table-config.ts @@ -26,7 +26,7 @@ import { TranslateService } from '@ngx-translate/core'; import { Direction } from '@shared/models/page/sort-order'; import { MatDialog } from '@angular/material/dialog'; import { PageLink } from '@shared/models/page/page-link'; -import { Observable, of } from 'rxjs'; +import { EMPTY, Observable, of } from 'rxjs'; import { PageData } from '@shared/models/page/page-data'; import { EntityId } from '@shared/models/id/entity-id'; import { Store } from '@ngrx/store'; @@ -59,6 +59,7 @@ import { AlarmSeverity, alarmSeverityTranslations } from "@shared/models/alarm.m import { UtilsService } from "@core/services/utils.service"; import { deepClone, getEntityDetailsPageURL } from "@core/utils"; import { AlarmRuleTableHeaderComponent } from "@home/components/alarm-rules/alarm-rule-table-header.component"; +import { ActionNotificationShow } from "@core/notification/notification.actions"; export class AlarmRulesTableConfig extends EntityTableConfig { @@ -223,7 +224,10 @@ export class AlarmRulesTableConfig extends EntityTableConfig { private copyCalculatedField(calculatedField: CalculatedField, isDirty = false): void { const copyCalculatedAlarmRule = deepClone(calculatedField); - copyCalculatedAlarmRule.entityId = null; + if (this.pageMode) { + copyCalculatedAlarmRule.entityId = null; + } + delete copyCalculatedAlarmRule.id; this.getCalculatedAlarmDialog(copyCalculatedAlarmRule, 'action.apply', isDirty) .subscribe((res) => { if (res) { @@ -277,6 +281,19 @@ export class AlarmRulesTableConfig extends EntityTableConfig { this.importExportService.openCalculatedFieldImportDialog() .pipe( filter(Boolean), + switchMap(calculatedField => { + if (calculatedField.type !== CalculatedFieldType.ALARM) { + this.store.dispatch(new ActionNotificationShow({ + message: this.translate.instant('alarm-rule.import-invalid-alarm-rule-type'), + type: 'error', + verticalPosition: 'top', + horizontalPosition: 'left', + duration: 5000 + })); + return EMPTY; + } + return of(calculatedField); + }), switchMap(calculatedField => this.getCalculatedAlarmDialog(this.updateImportedCalculatedField(calculatedField), 'action.add', true)), filter(Boolean), switchMap(calculatedField => this.calculatedFieldsService.saveCalculatedField(calculatedField)), @@ -287,15 +304,7 @@ export class AlarmRulesTableConfig extends EntityTableConfig { } private updateImportedCalculatedField(calculatedField: CalculatedField): CalculatedField { - if (calculatedField.type === CalculatedFieldType.GEOFENCING) { - calculatedField.configuration.zoneGroups = Object.keys(calculatedField.configuration.zoneGroups).reduce((acc, key) => { - const arg = calculatedField.configuration.zoneGroups[key]; - acc[key] = arg.refEntityId?.entityType === ArgumentEntityType.Tenant - ? { ...arg, refEntityId: { id: this.tenantId, entityType: ArgumentEntityType.Tenant } } - : arg; - return acc; - }, {}); - } else { + if (calculatedField.type === CalculatedFieldType.ALARM) { calculatedField.configuration.arguments = Object.keys(calculatedField.configuration.arguments).reduce((acc, key) => { const arg = calculatedField.configuration.arguments[key]; acc[key] = arg.refEntityId?.entityType === ArgumentEntityType.Tenant diff --git a/ui-ngx/src/app/modules/home/components/alarm-rules/cf-alarm-rule-condition-dialog.component.html b/ui-ngx/src/app/modules/home/components/alarm-rules/cf-alarm-rule-condition-dialog.component.html index feafcaa0a4..34f165420a 100644 --- a/ui-ngx/src/app/modules/home/components/alarm-rules/cf-alarm-rule-condition-dialog.component.html +++ b/ui-ngx/src/app/modules/home/components/alarm-rules/cf-alarm-rule-condition-dialog.component.html @@ -67,9 +67,26 @@ [helpPopupStyle]="{ width: '1200px' }" helpId="alarm-rule/expression_fn">
{{ 'alarm-rule.expression-type.script' | translate }} + class="tb-primary-background tbel-script-lang-chip">{{ 'alarm-rule.tbel' | translate }}
+ +
+ +
}
diff --git a/ui-ngx/src/app/modules/home/components/alarm-rules/cf-alarm-rule-condition-dialog.component.ts b/ui-ngx/src/app/modules/home/components/alarm-rules/cf-alarm-rule-condition-dialog.component.ts index 48b290b32c..dc8b23c707 100644 --- a/ui-ngx/src/app/modules/home/components/alarm-rules/cf-alarm-rule-condition-dialog.component.ts +++ b/ui-ngx/src/app/modules/home/components/alarm-rules/cf-alarm-rule-condition-dialog.component.ts @@ -14,8 +14,8 @@ /// limitations under the License. /// -import { Component, Inject } from '@angular/core'; -import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog'; +import { Component, DestroyRef, Inject } from '@angular/core'; +import { MAT_DIALOG_DATA, MatDialog, MatDialogRef } from '@angular/material/dialog'; import { Store } from '@ngrx/store'; import { AppState } from '@core/core.state'; import { FormBuilder, Validators } from '@angular/forms'; @@ -28,21 +28,34 @@ import { AlarmRuleCondition, AlarmRuleConditionType, AlarmRuleConditionTypeTranslationMap, + alarmRuleDefaultScript, AlarmRuleExpressionType } from "@shared/models/alarm-rule.models"; import { + ArgumentType, + CalculatedField, CalculatedFieldArgument, + CalculatedFieldEventArguments, getCalculatedFieldArgumentsEditorCompleter, getCalculatedFieldArgumentsHighlights } from "@shared/models/calculated-field.models"; import { TbEditorCompleter } from "@shared/models/ace/completion.models"; import { AceHighlightRules } from "@shared/models/ace/ace.models"; import { ComplexOperation, complexOperationTranslationMap } from "@shared/models/query/query.models"; +import { Observable } from "rxjs"; +import { filter, switchMap, tap } from "rxjs/operators"; +import { isObject } from "@core/utils"; +import { + CalculatedFieldScriptTestDialogComponent, + CalculatedFieldTestScriptDialogData +} from "@home/components/calculated-fields/components/test-dialog/calculated-field-script-test-dialog.component"; +import { CalculatedFieldsService } from "@core/http/calculated-fields.service"; export interface CfAlarmRuleConditionDialogData { readonly: boolean; condition: AlarmRuleCondition; arguments?: Record; + value?: CalculatedField; } @Component({ @@ -106,7 +119,10 @@ export class CfAlarmRuleConditionDialogComponent extends DialogComponent, - private fb: FormBuilder) { + private fb: FormBuilder, + private calculatedFieldsService: CalculatedFieldsService, + private destroyRef: DestroyRef, + private dialog: MatDialog) { super(store, router, dialogRef); this.functionArgs = ['ctx', ...Object.keys(this.data.arguments)]; @@ -117,7 +133,7 @@ export class CfAlarmRuleConditionDialogComponent extends DialogComponent { + this.conditionFormGroup.get('expression.expression').setValue(expression); + this.conditionFormGroup.get('expression.expression').markAsDirty(); + }) + } + + testScript(): Observable { + if (this.data.value?.id?.id) { + return this.calculatedFieldsService.getLatestCalculatedFieldDebugEvent(this.data.value?.id?.id, {ignoreLoading: true}) + .pipe( + switchMap(event => { + const args = event?.arguments ? JSON.parse(event.arguments) : null; + return this.getTestScriptDialog(this.arguments, this.conditionFormGroup.get('expression.expression').value, args); + }), + takeUntilDestroyed(this.destroyRef) + ) + } + return this.getTestScriptDialog(this.arguments, this.conditionFormGroup.get('expression.expression').value, null); + } + + getTestScriptDialog(argumentsList: Record, expression: string, argumentsObj?: CalculatedFieldEventArguments): Observable { + const resultArguments = Object.keys(argumentsList).reduce((acc, key) => { + const type = argumentsList[key].refEntityKey.type; + acc[key] = isObject(argumentsObj) && argumentsObj.hasOwnProperty(key) + ? {...argumentsObj[key], type} + : type === ArgumentType.Rolling ? {values: [], type} : {value: '', type, ts: new Date().getTime()}; + return acc; + }, {}); + return this.dialog.open(CalculatedFieldScriptTestDialogComponent, + { + disableClose: true, + panelClass: ['tb-dialog', 'tb-fullscreen-dialog', 'tb-fullscreen-dialog-gt-xs'], + data: { + arguments: resultArguments, + expression: expression, + argumentsEditorCompleter: getCalculatedFieldArgumentsEditorCompleter(argumentsList), + argumentsHighlightRules: getCalculatedFieldArgumentsHighlights(argumentsList) + } + }).afterClosed() + .pipe( + filter(Boolean), + tap(expression => expression) + ); + } + } diff --git a/ui-ngx/src/app/modules/home/components/alarm-rules/cf-alarm-rule-condition.component.html b/ui-ngx/src/app/modules/home/components/alarm-rules/cf-alarm-rule-condition.component.html index e17d8c8261..0b722b4ec1 100644 --- a/ui-ngx/src/app/modules/home/components/alarm-rules/cf-alarm-rule-condition.component.html +++ b/ui-ngx/src/app/modules/home/components/alarm-rules/cf-alarm-rule-condition.component.html @@ -30,7 +30,7 @@ [nowrap]="true" [specText]="specText" required - addFilterPrompt="{{'alarm-rule.enter-alarm-rule-condition-prompt' | translate}}"> + addFilterPrompt="{{ (isClearCondition ? 'alarm-rule.enter-alarm-rule-clear-condition-prompt' :'alarm-rule.enter-alarm-rule-condition-prompt') | translate }}"> {{ conditionSet() ? 'edit' : 'add' }}
diff --git a/ui-ngx/src/app/modules/home/components/alarm-rules/cf-alarm-rule-condition.component.ts b/ui-ngx/src/app/modules/home/components/alarm-rules/cf-alarm-rule-condition.component.ts index 2e8d116ad2..0ae0c4a067 100644 --- a/ui-ngx/src/app/modules/home/components/alarm-rules/cf-alarm-rule-condition.component.ts +++ b/ui-ngx/src/app/modules/home/components/alarm-rules/cf-alarm-rule-condition.component.ts @@ -21,8 +21,7 @@ import { NG_VALIDATORS, NG_VALUE_ACCESSOR, UntypedFormControl, - Validator, - Validators + Validator } from '@angular/forms'; import { MatDialog } from '@angular/material/dialog'; import { deepClone, isDefinedAndNotNull } from '@core/utils'; @@ -44,7 +43,7 @@ import { AlarmRuleSchedule, AlarmRuleScheduleType } from "@shared/models/alarm-rule.models"; -import { CalculatedFieldArgument } from "@shared/models/calculated-field.models"; +import { CalculatedField, CalculatedFieldArgument } from "@shared/models/calculated-field.models"; import { AlarmRuleScheduleDialogData, CfAlarmScheduleDialogComponent @@ -81,6 +80,13 @@ export class CfAlarmRuleConditionComponent implements ControlValueAccessor, Vali @Input() arguments: Record; + @Input() + @coerceBoolean() + isClearCondition = false; + + @Input() + value: CalculatedField; + alarmRuleConditionFormGroup = this.fb.group({ type: ['SIMPLE'], expression: [{type: AlarmRuleExpressionType.SIMPLE}], @@ -118,10 +124,8 @@ export class CfAlarmRuleConditionComponent implements ControlValueAccessor, Vali } writeValue(value: AlarmRuleCondition): void { - if (value) { - this.modelValue = value; - this.updateConditionInfo(); - } + this.modelValue = value; + this.updateConditionInfo(); } public conditionSet() { @@ -147,7 +151,8 @@ export class CfAlarmRuleConditionComponent implements ControlValueAccessor, Vali data: { readonly: this.disabled, condition: this.disabled ? this.modelValue : deepClone(this.modelValue), - arguments: this.arguments + arguments: this.arguments, + value: this.value, } }).afterClosed().subscribe((result) => { if (result) { diff --git a/ui-ngx/src/app/modules/home/components/alarm-rules/cf-alarm-rule.component.html b/ui-ngx/src/app/modules/home/components/alarm-rules/cf-alarm-rule.component.html index 14a88b2bf1..661573fc51 100644 --- a/ui-ngx/src/app/modules/home/components/alarm-rules/cf-alarm-rule.component.html +++ b/ui-ngx/src/app/modules/home/components/alarm-rules/cf-alarm-rule.component.html @@ -16,7 +16,7 @@ -->
- + @if (!disabled || alarmRuleFormGroup.get('alarmDetails').value) {
diff --git a/ui-ngx/src/app/modules/home/components/alarm-rules/cf-alarm-rule.component.scss b/ui-ngx/src/app/modules/home/components/alarm-rules/cf-alarm-rule.component.scss index 21b8a67c24..cea9c68955 100644 --- a/ui-ngx/src/app/modules/home/components/alarm-rules/cf-alarm-rule.component.scss +++ b/ui-ngx/src/app/modules/home/components/alarm-rules/cf-alarm-rule.component.scss @@ -14,6 +14,7 @@ * limitations under the License. */ :host { + width: 100%; .tb-alarm-rule-details, .tb-alarm-rule-dashboard { padding: 4px; &.title { diff --git a/ui-ngx/src/app/modules/home/components/alarm-rules/cf-alarm-rule.component.ts b/ui-ngx/src/app/modules/home/components/alarm-rules/cf-alarm-rule.component.ts index f5e57310a2..d0947c651f 100644 --- a/ui-ngx/src/app/modules/home/components/alarm-rules/cf-alarm-rule.component.ts +++ b/ui-ngx/src/app/modules/home/components/alarm-rules/cf-alarm-rule.component.ts @@ -28,7 +28,7 @@ import { isDefinedAndNotNull } from '@core/utils'; import { DashboardId } from '@shared/models/id/dashboard-id'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { AlarmRule, AlarmRuleCondition } from "@shared/models/alarm-rule.models"; -import { CalculatedFieldArgument } from "@shared/models/calculated-field.models"; +import { CalculatedField, CalculatedFieldArgument } from "@shared/models/calculated-field.models"; import { AlarmRuleDetailsDialogComponent, AlarmRuleDetailsDialogData @@ -65,6 +65,13 @@ export class CfAlarmRuleComponent implements ControlValueAccessor, OnInit, Valid @Input() arguments: Record; + @Input() + @coerceBoolean() + isClearCondition = false; + + @Input() + value: CalculatedField; + private modelValue: AlarmRule; alarmRuleFormGroup = this.fb.group({ diff --git a/ui-ngx/src/app/modules/home/components/alarm-rules/create-cf-alarm-rules.component.html b/ui-ngx/src/app/modules/home/components/alarm-rules/create-cf-alarm-rules.component.html index b0e6164d30..6f6784b8d0 100644 --- a/ui-ngx/src/app/modules/home/components/alarm-rules/create-cf-alarm-rules.component.html +++ b/ui-ngx/src/app/modules/home/components/alarm-rules/create-cf-alarm-rules.component.html @@ -34,7 +34,7 @@
- +
diff --git a/ui-ngx/src/app/modules/home/components/alarm-rules/create-cf-alarm-rules.component.ts b/ui-ngx/src/app/modules/home/components/alarm-rules/create-cf-alarm-rules.component.ts index 94eeb755a9..7fde250074 100644 --- a/ui-ngx/src/app/modules/home/components/alarm-rules/create-cf-alarm-rules.component.ts +++ b/ui-ngx/src/app/modules/home/components/alarm-rules/create-cf-alarm-rules.component.ts @@ -29,7 +29,7 @@ import { } from '@angular/forms'; import { AlarmSeverity, alarmSeverityTranslations } from '@shared/models/alarm.models'; import { AlarmRule } from "@shared/models/alarm-rule.models"; -import { CalculatedFieldArgument } from "@shared/models/calculated-field.models"; +import { CalculatedField, CalculatedFieldArgument } from "@shared/models/calculated-field.models"; import { AlarmSeverityNotificationColors } from "@shared/models/notification.models"; import { takeUntilDestroyed } from "@angular/core/rxjs-interop"; import { coerceBoolean } from "@shared/decorators/coercion"; @@ -60,6 +60,8 @@ export class CreateCfAlarmRulesComponent implements ControlValueAccessor, Valida @Input() arguments: Record; + @Input() + value: CalculatedField; alarmSeverities = Object.keys(AlarmSeverity); alarmSeverityEnum = AlarmSeverity; diff --git a/ui-ngx/src/app/modules/home/components/alarm-rules/filter/alarm-rule-filter-dialog.component.html b/ui-ngx/src/app/modules/home/components/alarm-rules/filter/alarm-rule-filter-dialog.component.html index abc1dd463d..8abb4ea45a 100644 --- a/ui-ngx/src/app/modules/home/components/alarm-rules/filter/alarm-rule-filter-dialog.component.html +++ b/ui-ngx/src/app/modules/home/components/alarm-rules/filter/alarm-rule-filter-dialog.component.html @@ -67,7 +67,10 @@
-
{{ 'alarm-rule.filter' | translate }}
+
+ {{ 'alarm-rule.filter' | translate }} +
{{ complexOperationTranslationMap.get(ComplexOperation.AND) | translate }} diff --git a/ui-ngx/src/app/modules/home/components/alarm-rules/filter/alarm-rule-filter-list.component.html b/ui-ngx/src/app/modules/home/components/alarm-rules/filter/alarm-rule-filter-list.component.html index 3f588a3d70..3295bdc85e 100644 --- a/ui-ngx/src/app/modules/home/components/alarm-rules/filter/alarm-rule-filter-list.component.html +++ b/ui-ngx/src/app/modules/home/components/alarm-rules/filter/alarm-rule-filter-list.component.html @@ -72,7 +72,6 @@ diff --git a/ui-ngx/src/app/modules/home/components/alarm-rules/filter/alarm-rule-filter-predicate-list.component.html b/ui-ngx/src/app/modules/home/components/alarm-rules/filter/alarm-rule-filter-predicate-list.component.html index b7450262f6..4882d37f0e 100644 --- a/ui-ngx/src/app/modules/home/components/alarm-rules/filter/alarm-rule-filter-predicate-list.component.html +++ b/ui-ngx/src/app/modules/home/components/alarm-rules/filter/alarm-rule-filter-predicate-list.component.html @@ -70,7 +70,6 @@ [class.!hidden]="disabled" (click)="addPredicate(false)" type="button" - matTooltip="{{ 'filter.add-filter' | translate }}" matTooltipPosition="above"> {{ 'action.add' | translate }} @@ -78,7 +77,6 @@ [class.!hidden]="disabled" (click)="addPredicate(true)" type="button" - matTooltip="{{ 'filter.add-complex-filter' | translate }}" matTooltipPosition="above"> {{ 'filter.add-complex' | translate }} diff --git a/ui-ngx/src/app/modules/home/components/calculated-fields/calculated-fields-table-config.ts b/ui-ngx/src/app/modules/home/components/calculated-fields/calculated-fields-table-config.ts index 0ac64b428f..6ac87e21ce 100644 --- a/ui-ngx/src/app/modules/home/components/calculated-fields/calculated-fields-table-config.ts +++ b/ui-ngx/src/app/modules/home/components/calculated-fields/calculated-fields-table-config.ts @@ -24,7 +24,7 @@ import { TranslateService } from '@ngx-translate/core'; import { Direction } from '@shared/models/page/sort-order'; import { MatDialog } from '@angular/material/dialog'; import { PageLink } from '@shared/models/page/page-link'; -import { Observable, of } from 'rxjs'; +import { EMPTY, Observable, of } from 'rxjs'; import { PageData } from '@shared/models/page/page-data'; import { EntityId } from '@shared/models/id/entity-id'; import { Store } from '@ngrx/store'; @@ -60,6 +60,7 @@ import { isObject } from '@core/utils'; import { EntityDebugSettingsService } from '@home/components/entity/debug/entity-debug-settings.service'; import { DatePipe } from '@angular/common'; import { UtilsService } from "@core/services/utils.service"; +import { ActionNotificationShow } from "@core/notification/notification.actions"; export class CalculatedFieldsTableConfig extends EntityTableConfig { @@ -256,6 +257,19 @@ export class CalculatedFieldsTableConfig extends EntityTableConfig { + if (calculatedField.type === CalculatedFieldType.ALARM) { + this.store.dispatch(new ActionNotificationShow({ + message: this.translate.instant('calculated-fields.hint.import-invalid-calculated-field-type'), + type: 'error', + verticalPosition: 'top', + horizontalPosition: 'left', + duration: 5000 + })); + return EMPTY; + } + return of(calculatedField); + }), switchMap(calculatedField => this.getCalculatedFieldDialog(this.updateImportedCalculatedField(calculatedField), 'action.add', true)), filter(Boolean), switchMap(calculatedField => this.calculatedFieldsService.saveCalculatedField(calculatedField)), diff --git a/ui-ngx/src/app/modules/home/components/calculated-fields/components/calculated-field-arguments/calculated-field-argument-panel.component.html b/ui-ngx/src/app/modules/home/components/calculated-fields/components/calculated-field-arguments/calculated-field-argument-panel.component.html index 9277bae767..30533dc714 100644 --- a/ui-ngx/src/app/modules/home/components/calculated-fields/components/calculated-field-arguments/calculated-field-argument-panel.component.html +++ b/ui-ngx/src/app/modules/home/components/calculated-fields/components/calculated-field-arguments/calculated-field-argument-panel.component.html @@ -24,15 +24,8 @@
}
- @if (!isOutputKey) { - + @if (!watchKeyChange) { + } @if (!hiddenEntityTypes) { @@ -105,7 +98,7 @@ } - +
} @else { @@ -132,6 +125,7 @@ - @if (isOutputKey) { - + @if (watchKeyChange) { + } @if (refEntityKeyFormGroup.get('type').value !== ArgumentType.Rolling) { @if (!hiddenDefaultValue) { diff --git a/ui-ngx/src/app/modules/home/components/calculated-fields/components/calculated-field-arguments/calculated-field-argument-panel.component.ts b/ui-ngx/src/app/modules/home/components/calculated-fields/components/calculated-field-arguments/calculated-field-argument-panel.component.ts index ad97e7b06e..ac74815704 100644 --- a/ui-ngx/src/app/modules/home/components/calculated-fields/components/calculated-field-arguments/calculated-field-argument-panel.component.ts +++ b/ui-ngx/src/app/modules/home/components/calculated-fields/components/calculated-field-arguments/calculated-field-argument-panel.component.ts @@ -72,6 +72,7 @@ export class CalculatedFieldArgumentPanelComponent implements OnInit, AfterViewI @Input() isScript: boolean; @Input() usedArgumentNames: string[]; @Input() isOutputKey = false; + @Input() watchKeyChange = false; @Input() hiddenEntityTypes = false; @Input() hiddenEntityKeyTypes = false; @Input() hiddenDefaultValue = false; @@ -107,6 +108,17 @@ export class CalculatedFieldArgumentPanelComponent implements OnInit, AfterViewI entityFilter: EntityFilter; entityNameSubject = new BehaviorSubject(null); + enableAutocomplete = false; + + argumentNameContext = { + label: 'calculated-fields.argument-name', + required: 'calculated-fields.hint.argument-name-required', + duplicate: 'calculated-fields.hint.argument-name-duplicate', + pattern: 'calculated-fields.hint.argument-name-pattern', + maxlength: 'calculated-fields.hint.argument-name-max-length', + forbidden: 'calculated-fields.hint.argument-name-forbidden' + } + readonly ArgumentEntityTypeTranslations = ArgumentEntityTypeTranslations; readonly ArgumentType = ArgumentType; readonly DataKeyType = DataKeyType; @@ -155,7 +167,20 @@ export class CalculatedFieldArgumentPanelComponent implements OnInit, AfterViewI this.toggleByEntityKeyType(this.argument.refEntityKey?.type); this.setInitialEntityKeyType(); this.setInitialEntityType(); - this.setWatchKeyChange(); + if (this.watchKeyChange) { + this.setWatchKeyChange(); + } + + if (this.isOutputKey) { + this.argumentNameContext = { + label: 'calculated-fields.output-key', + required: 'calculated-fields.hint.output-key-required', + duplicate: 'calculated-fields.hint.output-key-duplicate', + pattern: 'calculated-fields.hint.output-key-pattern', + maxlength: 'calculated-fields.hint.output-key-max-length', + forbidden: 'calculated-fields.hint.output-key-forbidden' + } + } if (this.defaultValueRequired) { this.argumentFormGroup.get('defaultValue').addValidators(Validators.required); @@ -202,6 +227,7 @@ export class CalculatedFieldArgumentPanelComponent implements OnInit, AfterViewI private updatedArgumentType(): void { let argumentType = ArgumentEntityType.Current; if (this.argument.refDynamicSourceConfiguration?.type === ArgumentEntityType.Owner) { + this.enableAutocomplete = (this.entityId.entityType === EntityType.DEVICE_PROFILE || this.entityId.entityType === EntityType.ASSET_PROFILE); argumentType = ArgumentEntityType.Owner; } else if (this.argument.refEntityId?.entityType) { argumentType = this.argument.refEntityId.entityType; @@ -270,6 +296,7 @@ export class CalculatedFieldArgumentPanelComponent implements OnInit, AfterViewI .pipe(distinctUntilChanged(), takeUntilDestroyed()) .subscribe(type => { this.argumentFormGroup.get('refEntityId').setValue(null); + this.enableAutocomplete = (this.entityId.entityType === EntityType.DEVICE_PROFILE || this.entityId.entityType === EntityType.ASSET_PROFILE) && type === ArgumentEntityType.Owner; this.updatedRefEntityIdState(type); if (!this.enableAttributeScopeSelection) { this.refEntityKeyFormGroup.get('scope').setValue(AttributeScope.SERVER_SCOPE); @@ -299,15 +326,13 @@ export class CalculatedFieldArgumentPanelComponent implements OnInit, AfterViewI } private setWatchKeyChange(): void { - if (this.isOutputKey) { - this.refEntityKeyFormGroup.get('key').valueChanges.pipe( - takeUntilDestroyed(this.destroyRef) - ).subscribe((key) => { - if (this.argumentFormGroup.get('argumentName').pristine) { - this.argumentFormGroup.get('argumentName').setValue(key); - } - }); - } + this.refEntityKeyFormGroup.get('key').valueChanges.pipe( + takeUntilDestroyed(this.destroyRef) + ).subscribe((key) => { + if (this.argumentFormGroup.get('argumentName').pristine) { + this.argumentFormGroup.get('argumentName').setValue(key); + } + }); } private updatedRefEntityIdState(type: ArgumentEntityType, emitEvent = true): void { diff --git a/ui-ngx/src/app/modules/home/components/calculated-fields/components/calculated-field-arguments/calculated-field-arguments-table.component.html b/ui-ngx/src/app/modules/home/components/calculated-fields/components/calculated-field-arguments/calculated-field-arguments-table.component.html index 15d2f4b09b..6b8f4fda79 100644 --- a/ui-ngx/src/app/modules/home/components/calculated-fields/components/calculated-field-arguments/calculated-field-arguments-table.component.html +++ b/ui-ngx/src/app/modules/home/components/calculated-fields/components/calculated-field-arguments/calculated-field-arguments-table.component.html @@ -120,10 +120,10 @@
+ class="tb-prompt flex flex-1 items-end justify-center min-h-14"> {{ 'calculated-fields.no-arguments' | translate }}
- @if (errorText) { + @if (errorText || (dataSource.isEmpty() | async)) { }
@@ -134,7 +134,7 @@ color="primary" #button (click)="manageArgument($event, button)" - [disabled]="maxArgumentsPerCF > 0 && argumentsFormArray.length >= maxArgumentsPerCF" + [disabled]="maxArgumentsPerCF > 0 && argumentsFormArray.length >= maxArgumentsPerCF || disabledAddButton" > {{ 'calculated-fields.add-argument' | translate }} diff --git a/ui-ngx/src/app/modules/home/components/calculated-fields/components/calculated-field-arguments/calculated-field-arguments-table.component.ts b/ui-ngx/src/app/modules/home/components/calculated-fields/components/calculated-field-arguments/calculated-field-arguments-table.component.ts index f3d8117d3e..bca335211e 100644 --- a/ui-ngx/src/app/modules/home/components/calculated-fields/components/calculated-field-arguments/calculated-field-arguments-table.component.ts +++ b/ui-ngx/src/app/modules/home/components/calculated-fields/components/calculated-field-arguments/calculated-field-arguments-table.component.ts @@ -87,6 +87,8 @@ export class CalculatedFieldArgumentsTableComponent implements ControlValueAcces @Input() entityName: string; @Input() ownerId: EntityId; @Input() isScript: boolean; + @Input() disabledAddButton = false; + @Input() watchKeyChange = false; @ViewChild(MatSort, { static: true }) sort: MatSort; @@ -181,6 +183,7 @@ export class CalculatedFieldArgumentsTableComponent implements ControlValueAcces tenantId: this.tenantId, entityName: this.entityName, ownerId: this.ownerId, + watchKeyChange: this.watchKeyChange, usedArgumentNames: this.argumentsFormArray.value.map(({ argumentName }) => argumentName).filter(name => name !== argument.argumentName), }; this.popoverComponent = this.popoverService.displayPopover({ diff --git a/ui-ngx/src/app/modules/home/components/calculated-fields/components/calculated-field-arguments/propagate-arguments-table.component.ts b/ui-ngx/src/app/modules/home/components/calculated-fields/components/calculated-field-arguments/propagate-arguments-table.component.ts index 94d94c2d76..2e4dd9a04d 100644 --- a/ui-ngx/src/app/modules/home/components/calculated-fields/components/calculated-field-arguments/propagate-arguments-table.component.ts +++ b/ui-ngx/src/app/modules/home/components/calculated-fields/components/calculated-field-arguments/propagate-arguments-table.component.ts @@ -94,6 +94,7 @@ export class PropagateArgumentsTableComponent extends CalculatedFieldArgumentsTa this.panelAdditionalCtx = { argumentEntityTypes: [ArgumentEntityType.Current], isOutputKey: true, + watchKeyChange: true, forbiddenNames: [...FORBIDDEN_NAMES, 'propagationCtx'], }; } diff --git a/ui-ngx/src/app/modules/home/components/calculated-fields/components/dialog/calculated-field-dialog.component.html b/ui-ngx/src/app/modules/home/components/calculated-fields/components/dialog/calculated-field-dialog.component.html index 7184a5f098..49f4664f13 100644 --- a/ui-ngx/src/app/modules/home/components/calculated-fields/components/dialog/calculated-field-dialog.component.html +++ b/ui-ngx/src/app/modules/home/components/calculated-fields/components/dialog/calculated-field-dialog.component.html @@ -88,6 +88,7 @@ [entityId]="data.entityId" [entityName]="data.entityName" [tenantId]="data.tenantId" + [calculatedFieldId]="data.value?.id?.id" > } @case (CalculatedFieldType.ENTITY_AGGREGATION) { diff --git a/ui-ngx/src/app/modules/home/components/calculated-fields/components/geofencing-configuration/calculated-field-geofencing-zone-groups-panel.component.html b/ui-ngx/src/app/modules/home/components/calculated-fields/components/geofencing-configuration/calculated-field-geofencing-zone-groups-panel.component.html index 1f5444f8b3..9f4e331cc1 100644 --- a/ui-ngx/src/app/modules/home/components/calculated-fields/components/geofencing-configuration/calculated-field-geofencing-zone-groups-panel.component.html +++ b/ui-ngx/src/app/modules/home/components/calculated-fields/components/geofencing-configuration/calculated-field-geofencing-zone-groups-panel.component.html @@ -176,7 +176,7 @@
- @if (entityFilter.singleEntity?.id || entityType === ArgumentEntityType.RelationQuery || entityType === ArgumentEntityType.Current) { + @if (entityFilter.singleEntity?.id || entityType === ArgumentEntityType.RelationQuery || entityType === ArgumentEntityType.Current || entityType === ArgumentEntityType.Owner) {
{{ 'calculated-fields.perimeter-attribute-key' | translate }} @@ -204,6 +204,7 @@ } @else { } diff --git a/ui-ngx/src/app/modules/home/components/calculated-fields/components/geofencing-configuration/calculated-field-geofencing-zone-groups-panel.component.ts b/ui-ngx/src/app/modules/home/components/calculated-fields/components/geofencing-configuration/calculated-field-geofencing-zone-groups-panel.component.ts index 5f3e00a0cf..0bbb72efb5 100644 --- a/ui-ngx/src/app/modules/home/components/calculated-fields/components/geofencing-configuration/calculated-field-geofencing-zone-groups-panel.component.ts +++ b/ui-ngx/src/app/modules/home/components/calculated-fields/components/geofencing-configuration/calculated-field-geofencing-zone-groups-panel.component.ts @@ -97,6 +97,8 @@ export class CalculatedFieldGeofencingZoneGroupsPanelComponent implements OnInit entityFilter: EntityFilter; entityNameSubject = new BehaviorSubject(null); + enableAutocomplete = false; + readonly ArgumentEntityType = ArgumentEntityType; readonly argumentEntityTypes = Object.values(ArgumentEntityType) as ArgumentEntityType[]; readonly ArgumentEntityTypeTranslations = ArgumentEntityTypeTranslations; @@ -151,6 +153,8 @@ export class CalculatedFieldGeofencingZoneGroupsPanelComponent implements OnInit } else { this.addKey(); } + this.enableAutocomplete = (this.entityId.entityType === EntityType.DEVICE_PROFILE || this.entityId.entityType === EntityType.ASSET_PROFILE) && + this.refEntityIdFormGroup.get('entityType').value === ArgumentEntityType.Owner; this.validateDirectionAndRelationType(this.zone?.createRelationsWithMatchedZones); this.validateRefDynamicSourceConfiguration(this.zone?.refEntityId?.entityType || this.zone?.refDynamicSourceConfiguration?.type); @@ -265,18 +269,17 @@ export class CalculatedFieldGeofencingZoneGroupsPanelComponent implements OnInit ) .pipe(debounceTime(50), takeUntilDestroyed()) .subscribe(() => this.updateEntityFilter(this.entityType)); - - this.refEntityIdFormGroup.get('id').valueChanges.pipe(distinctUntilChanged(), takeUntilDestroyed()).subscribe(() => this.geofencingFormGroup.get('perimeterKeyName').reset('')); } private observeEntityTypeChanges(): void { this.refEntityIdFormGroup.get('entityType').valueChanges .pipe(distinctUntilChanged(), takeUntilDestroyed()) .subscribe(type => { + this.enableAutocomplete = (this.entityId.entityType === EntityType.DEVICE_PROFILE || this.entityId.entityType === EntityType.ASSET_PROFILE) && type === ArgumentEntityType.Owner; this.geofencingFormGroup.get('refEntityId').get('id').setValue(null); - const isEntityWithId = type !== ArgumentEntityType.Tenant && type !== ArgumentEntityType.Current && type !== ArgumentEntityType.RelationQuery; - this.geofencingFormGroup.get('refEntityId') - .get('id')[isEntityWithId ? 'enable' : 'disable'](); + this.geofencingFormGroup.get('perimeterKeyName').reset(''); + const isEntityWithId = !!type && ![ArgumentEntityType.Tenant, ArgumentEntityType.Current, ArgumentEntityType.Owner].includes(type); + this.geofencingFormGroup.get('refEntityId').get('id')[isEntityWithId ? 'enable' : 'disable'](); if (!isEntityWithId) { this.entityNameSubject.next(null); } diff --git a/ui-ngx/src/app/modules/home/components/calculated-fields/components/metrics/calculated-field-metrics-panel.component.html b/ui-ngx/src/app/modules/home/components/calculated-fields/components/metrics/calculated-field-metrics-panel.component.html index eda8a779ad..0a2358d544 100644 --- a/ui-ngx/src/app/modules/home/components/calculated-fields/components/metrics/calculated-field-metrics-panel.component.html +++ b/ui-ngx/src/app/modules/home/components/calculated-fields/components/metrics/calculated-field-metrics-panel.component.html @@ -27,7 +27,7 @@ warning @@ -35,7 +35,7 @@ warning @@ -43,7 +43,7 @@ warning @@ -51,7 +51,7 @@ warning @@ -59,7 +59,7 @@ warning @@ -105,7 +105,24 @@
{{ 'api-usage.tbel' | translate }}
+ +
+ +
@@ -128,7 +145,7 @@
{{ 'calculated-fields.argument-name' | translate }}
- @for (argument of arguments; track argument) { + @for (argument of argumentsList; track argument) { {{ argument }} } @@ -146,7 +163,7 @@ } @else { {{ 'api-usage.tbel' | translate }}
+ +
+ +
}
@if (simpleMode) { diff --git a/ui-ngx/src/app/modules/home/components/calculated-fields/components/metrics/calculated-field-metrics-panel.component.ts b/ui-ngx/src/app/modules/home/components/calculated-fields/components/metrics/calculated-field-metrics-panel.component.ts index 4a843208c5..1c1c0da7d7 100644 --- a/ui-ngx/src/app/modules/home/components/calculated-fields/components/metrics/calculated-field-metrics-panel.component.ts +++ b/ui-ngx/src/app/modules/home/components/calculated-fields/components/metrics/calculated-field-metrics-panel.component.ts @@ -14,7 +14,7 @@ /// limitations under the License. /// -import { Component, Input, OnInit, output } from '@angular/core'; +import { Component, DestroyRef, Input, OnInit, output } from '@angular/core'; import { TbPopoverComponent } from '@shared/components/popover.component'; import { FormBuilder, Validators } from '@angular/forms'; import { charsWithNumRegex } from '@shared/models/regex.constants'; @@ -23,9 +23,14 @@ import { AggFunctionTranslations, AggInputType, AggInputTypeTranslations, + ArgumentType, CalculatedFieldAggMetricValue, + CalculatedFieldArgument, + CalculatedFieldEventArguments, FORBIDDEN_NAMES, forbiddenNamesValidator, + getCalculatedFieldArgumentsEditorCompleter, + getCalculatedFieldArgumentsHighlights, uniqueNameValidator } from '@shared/models/calculated-field.models'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; @@ -33,6 +38,15 @@ import { EntityFilter } from '@shared/models/query/query.models'; import { ScriptLanguage } from '@shared/models/rule-node.models'; import { TbEditorCompleter } from '@shared/models/ace/completion.models'; import { AceHighlightRules } from '@shared/models/ace/ace.models'; +import { MatDialog } from "@angular/material/dialog"; +import { Observable } from "rxjs"; +import { isObject } from "@core/utils"; +import { + CalculatedFieldScriptTestDialogComponent, + CalculatedFieldTestScriptDialogData +} from "@home/components/calculated-fields/components/test-dialog/calculated-field-script-test-dialog.component"; +import { filter, switchMap, tap } from "rxjs/operators"; +import { CalculatedFieldsService } from "@core/http/calculated-fields.service"; interface CalculatedFieldAggMetricValuePanel extends CalculatedFieldAggMetricValue { allowFilter: boolean; @@ -48,13 +62,15 @@ export class CalculatedFieldMetricsPanelComponent implements OnInit { @Input() buttonTitle: string; @Input() metric: CalculatedFieldAggMetricValue; @Input() usedNames: string[]; - @Input() arguments: Array; + @Input() arguments: Record; @Input() simpleMode: boolean; @Input() editorCompleter: TbEditorCompleter; @Input() highlightRules: AceHighlightRules; + @Input() calculatedFieldId: string; metricDataApplied = output(); filterExpanded = false; + argumentsList: Array functionArgs: Array metricForm = this.fb.group({ @@ -81,7 +97,10 @@ export class CalculatedFieldMetricsPanelComponent implements OnInit { constructor( private fb: FormBuilder, - private popover: TbPopoverComponent + private popover: TbPopoverComponent, + private dialog: MatDialog, + private calculatedFieldsService: CalculatedFieldsService, + private destroyRef: DestroyRef ) { this.observeFilterAllowChange(); this.observeInputTypeChange(); @@ -100,7 +119,8 @@ export class CalculatedFieldMetricsPanelComponent implements OnInit { this.validateInputTypeFilter(data.input?.type ?? AggInputType.key); this.validateInputKey(); - this.functionArgs = ['ctx', ...this.arguments]; + this.argumentsList = Object.keys(this.arguments); + this.functionArgs = ['ctx', ...this.argumentsList]; } saveMetric(): void { @@ -158,9 +178,55 @@ export class CalculatedFieldMetricsPanelComponent implements OnInit { } private validateInputKey() { - if (this.metric.input?.type === AggInputType.key && !this.arguments.includes(this.metric.input.key)) { + if (this.metric.input?.type === AggInputType.key && !Object.keys(this.arguments).includes(this.metric.input.key)) { this.metricForm.get('input.key').setValue(null); this.metricForm.get('input.key').markAsTouched(); } } + + onTestScript(scriptFunc: 'filter' | 'input.function') { + this.testScript(scriptFunc).subscribe(expression => { + this.metricForm.get(scriptFunc).setValue(expression); + this.metricForm.get(scriptFunc).markAsDirty(); + }); + } + + testScript(scriptFunc: 'filter' | 'input.function'): Observable { + if (this.calculatedFieldId) { + return this.calculatedFieldsService.getLatestCalculatedFieldDebugEvent(this.calculatedFieldId, {ignoreLoading: true}) + .pipe( + switchMap(event => { + const args = event?.arguments ? JSON.parse(event.arguments) : null; + return this.getTestScriptDialog(this.arguments, this.metricForm.get(scriptFunc).value, args); + }), + takeUntilDestroyed(this.destroyRef) + ) + } + return this.getTestScriptDialog(this.arguments, this.metricForm.get(scriptFunc).value, null); + } + + getTestScriptDialog(argumentsList: Record, expression: string, argumentsObj?: CalculatedFieldEventArguments): Observable { + const resultArguments = Object.keys(argumentsList).reduce((acc, key) => { + const type = argumentsList[key].refEntityKey.type; + acc[key] = isObject(argumentsObj) && argumentsObj.hasOwnProperty(key) + ? {...argumentsObj[key], type} + : type === ArgumentType.Rolling ? {values: [], type} : {value: '', type, ts: new Date().getTime()}; + return acc; + }, {}); + return this.dialog.open(CalculatedFieldScriptTestDialogComponent, + { + disableClose: true, + panelClass: ['tb-dialog', 'tb-fullscreen-dialog', 'tb-fullscreen-dialog-gt-xs'], + data: { + arguments: resultArguments, + expression: expression, + argumentsEditorCompleter: getCalculatedFieldArgumentsEditorCompleter(argumentsList), + argumentsHighlightRules: getCalculatedFieldArgumentsHighlights(argumentsList) + } + }).afterClosed() + .pipe( + filter(Boolean), + tap(expression => expression) + ); + } } diff --git a/ui-ngx/src/app/modules/home/components/calculated-fields/components/metrics/calculated-field-metrics-table.component.ts b/ui-ngx/src/app/modules/home/components/calculated-fields/components/metrics/calculated-field-metrics-table.component.ts index 22adf9a801..1a8821a15c 100644 --- a/ui-ngx/src/app/modules/home/components/calculated-fields/components/metrics/calculated-field-metrics-table.component.ts +++ b/ui-ngx/src/app/modules/home/components/calculated-fields/components/metrics/calculated-field-metrics-table.component.ts @@ -40,6 +40,7 @@ import { AggInputTypeTranslations, CalculatedFieldAggMetric, CalculatedFieldAggMetricValue, + CalculatedFieldArgument, } from '@shared/models/calculated-field.models'; import { MatButton } from '@angular/material/button'; import { TbPopoverService } from '@shared/components/popover.service'; @@ -76,10 +77,11 @@ import { AceHighlightRules } from '@shared/models/ace/ace.models'; }) export class CalculatedFieldMetricsTableComponent implements OnInit, ControlValueAccessor, Validator, AfterViewInit { - @Input() arguments: Array; + @Input() arguments: Record; @Input() editorCompleter: TbEditorCompleter; @Input() highlightRules: AceHighlightRules; @Input({transform: booleanAttribute}) simpleMode: boolean = false; + @Input() calculatedFieldId: string; @ViewChild(MatSort, { static: true }) sort: MatSort; @@ -166,6 +168,7 @@ export class CalculatedFieldMetricsTableComponent implements OnInit, ControlValu editorCompleter: this.editorCompleter, highlightRules: this.highlightRules, simpleMode: this.simpleMode, + calculatedFieldId: this.calculatedFieldId }; this.popoverComponent = this.popoverService.displayPopover({ trigger, diff --git a/ui-ngx/src/app/modules/home/components/calculated-fields/components/related-entities-aggregation-configuration/related-entities-aggregation-component.component.html b/ui-ngx/src/app/modules/home/components/calculated-fields/components/related-entities-aggregation-configuration/related-entities-aggregation-component.component.html index 8a0361ee58..d6b02b3bbe 100644 --- a/ui-ngx/src/app/modules/home/components/calculated-fields/components/related-entities-aggregation-configuration/related-entities-aggregation-component.component.html +++ b/ui-ngx/src/app/modules/home/components/calculated-fields/components/related-entities-aggregation-configuration/related-entities-aggregation-component.component.html @@ -55,6 +55,7 @@ {{ 'calculated-fields.metrics.metrics' | translate }}
Object.keys(argumentsObj)) + map(argumentsObj => argumentsObj) ); argumentsEditorCompleter$ = this.relatedAggregationConfiguration.get('arguments').valueChanges.pipe( diff --git a/ui-ngx/src/app/modules/home/pages/asset/asset-tabs.component.html b/ui-ngx/src/app/modules/home/pages/asset/asset-tabs.component.html index 9cb82c07fb..d84ad03aca 100644 --- a/ui-ngx/src/app/modules/home/pages/asset/asset-tabs.component.html +++ b/ui-ngx/src/app/modules/home/pages/asset/asset-tabs.component.html @@ -33,11 +33,11 @@ @if (authUser.authority === authorities.TENANT_ADMIN) { - + - + } diff --git a/ui-ngx/src/app/modules/home/pages/asset/asset-tabs.component.ts b/ui-ngx/src/app/modules/home/pages/asset/asset-tabs.component.ts index b5eecccb1a..ffde1502c8 100644 --- a/ui-ngx/src/app/modules/home/pages/asset/asset-tabs.component.ts +++ b/ui-ngx/src/app/modules/home/pages/asset/asset-tabs.component.ts @@ -19,6 +19,7 @@ import { Store } from '@ngrx/store'; import { AppState } from '@core/core.state'; import { EntityTabsComponent } from '../../components/entity/entity-tabs.component'; import { AssetInfo } from '@app/shared/models/asset.models'; +import { EntityId } from "@shared/models/id/entity-id"; @Component({ selector: 'tb-asset-tabs', @@ -27,6 +28,8 @@ import { AssetInfo } from '@app/shared/models/asset.models'; }) export class AssetTabsComponent extends EntityTabsComponent { + ownerId: EntityId; + constructor(protected store: Store) { super(store); } @@ -35,4 +38,9 @@ export class AssetTabsComponent extends EntityTabsComponent { super.ngOnInit(); } + protected setEntity(entity: AssetInfo) { + this.ownerId = entity.customerId.id !== this.nullUid ? entity.customerId : entity.tenantId; + super.setEntity(entity); + } + } diff --git a/ui-ngx/src/app/modules/home/pages/device/device-tabs.component.html b/ui-ngx/src/app/modules/home/pages/device/device-tabs.component.html index 119da03175..cfd2d0d29c 100644 --- a/ui-ngx/src/app/modules/home/pages/device/device-tabs.component.html +++ b/ui-ngx/src/app/modules/home/pages/device/device-tabs.component.html @@ -33,10 +33,10 @@ @if (authUser.authority === authorities.TENANT_ADMIN) { - + - + } diff --git a/ui-ngx/src/app/modules/home/pages/device/device-tabs.component.ts b/ui-ngx/src/app/modules/home/pages/device/device-tabs.component.ts index 7bd86922bf..95f2fbfccf 100644 --- a/ui-ngx/src/app/modules/home/pages/device/device-tabs.component.ts +++ b/ui-ngx/src/app/modules/home/pages/device/device-tabs.component.ts @@ -19,6 +19,7 @@ import { Store } from '@ngrx/store'; import { AppState } from '@core/core.state'; import { DeviceInfo } from '@shared/models/device.models'; import { EntityTabsComponent } from '../../components/entity/entity-tabs.component'; +import { EntityId } from "@shared/models/id/entity-id"; @Component({ selector: 'tb-device-tabs', @@ -27,6 +28,8 @@ import { EntityTabsComponent } from '../../components/entity/entity-tabs.compone }) export class DeviceTabsComponent extends EntityTabsComponent { + ownerId: EntityId; + constructor(protected store: Store) { super(store); } @@ -35,4 +38,9 @@ export class DeviceTabsComponent extends EntityTabsComponent { super.ngOnInit(); } + protected setEntity(entity: DeviceInfo) { + this.ownerId = entity.customerId.id !== this.nullUid ? entity.customerId : entity.tenantId; + super.setEntity(entity); + } + } diff --git a/ui-ngx/src/app/shared/components/entity/entity-key-autocomplete.component.html b/ui-ngx/src/app/shared/components/entity/entity-key-autocomplete.component.html index c08355462f..06448d3d69 100644 --- a/ui-ngx/src/app/shared/components/entity/entity-key-autocomplete.component.html +++ b/ui-ngx/src/app/shared/components/entity/entity-key-autocomplete.component.html @@ -43,7 +43,7 @@ @for (key of filteredKeys$ | async; track key) { } @empty { - @if (!this.keyControl.value) { + @if (!this.keyControl.value && enableAutocomplete) { {{ 'entity.no-keys-found' | translate }} } } diff --git a/ui-ngx/src/app/shared/components/entity/entity-key-autocomplete.component.ts b/ui-ngx/src/app/shared/components/entity/entity-key-autocomplete.component.ts index 8727c17f77..623f1d9ead 100644 --- a/ui-ngx/src/app/shared/components/entity/entity-key-autocomplete.component.ts +++ b/ui-ngx/src/app/shared/components/entity/entity-key-autocomplete.component.ts @@ -66,6 +66,7 @@ export class EntityKeyAutocompleteComponent implements ControlValueAccessor, Val @Input() placeholder = this.translate.instant('action.set'); @Input() requiredText = this.translate.instant('common.hint.key-required'); + @Input() enableAutocomplete = true; entityFilter = input.required(); dataKeyType = input.required(); @@ -81,12 +82,18 @@ export class EntityKeyAutocompleteComponent implements ControlValueAccessor, Val keys$ = this.keyInputSubject.asObservable() .pipe( switchMap(() => { + if (!this.enableAutocomplete) { + return of([] as string[]); + } return this.cachedResult ? of(this.cachedResult) : this.entityService.findEntityKeysByQuery({ pageLink: { page: 0, pageSize: 100 }, entityFilter: this.entityFilter(), }, this.dataKeyType() === DataKeyType.attribute, this.dataKeyType() === DataKeyType.timeseries, this.keyScopeType(), {ignoreLoading: true}); }), map(result => { + if (Array.isArray(result)) { + return result; + } this.cachedResult = result; switch (this.dataKeyType()) { case DataKeyType.attribute: diff --git a/ui-ngx/src/app/shared/components/entity/entity-subtype-list.component.ts b/ui-ngx/src/app/shared/components/entity/entity-subtype-list.component.ts index bb44f0ea53..dc89f05fde 100644 --- a/ui-ngx/src/app/shared/components/entity/entity-subtype-list.component.ts +++ b/ui-ngx/src/app/shared/components/entity/entity-subtype-list.component.ts @@ -204,7 +204,7 @@ export class EntitySubTypeListComponent implements ControlValueAccessor, OnInit, case EntityType.CALCULATED_FIELD: this.placeholder = this.required ? this.translate.instant('alarm.enter-alarm-rule-type') : this.translate.instant('alarm-rule.any-type'); - this.secondaryPlaceholder = '+' + this.translate.instant('alarm-rule.alarm-rule'); + this.secondaryPlaceholder = '+' + this.translate.instant('alarm-rule.alarm-type'); this.noSubtypesMathingText = 'alarm-rule.no-alarm-rule-types-matching'; this.subtypeListEmptyText = 'alarm-rule.alarm-rule-type-list-empty'; break; diff --git a/ui-ngx/src/app/shared/models/alarm-rule.models.ts b/ui-ngx/src/app/shared/models/alarm-rule.models.ts index c9b549d78a..35b6690644 100644 --- a/ui-ngx/src/app/shared/models/alarm-rule.models.ts +++ b/ui-ngx/src/app/shared/models/alarm-rule.models.ts @@ -157,3 +157,7 @@ export interface AlarmRuleFilterConfig { entityType?: EntityType; entities?: Array; } + +export const alarmRuleDefaultScript = + '// Sample expression for an alarm rule: triggers when temperature is above 20 degree\n' + + 'return temperature > 20;' diff --git a/ui-ngx/src/app/shared/models/calculated-field.models.ts b/ui-ngx/src/app/shared/models/calculated-field.models.ts index 8140f67e8f..5727bfe063 100644 --- a/ui-ngx/src/app/shared/models/calculated-field.models.ts +++ b/ui-ngx/src/app/shared/models/calculated-field.models.ts @@ -65,11 +65,18 @@ export interface CalculatedFieldAlarmRule extends BaseCalculatedField { configuration: CalculatedFieldAlarmRuleConfiguration; } +export interface CalculatedFieldRelatedEntityAggregation extends BaseCalculatedField { + type: CalculatedFieldType.RELATED_ENTITIES_AGGREGATION; + configuration: CalculatedFieldRelatedAggregationConfiguration; +} + + export type CalculatedField = | CalculatedFieldSimple | CalculatedFieldScript | CalculatedFieldGeofencing | CalculatedFieldPropagation + | CalculatedFieldRelatedEntityAggregation | CalculatedFieldAlarmRule; export enum CalculatedFieldType { 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 538b3a8352..3fc9741952 100644 --- a/ui-ngx/src/assets/locale/locale.constant-en_US.json +++ b/ui-ngx/src/assets/locale/locale.constant-en_US.json @@ -805,7 +805,7 @@ "idCopiedMessage": "Asset Id has been copied to clipboard", "select-asset": "Select asset", "no-assets-matching": "No assets matching '{{entity}}' were found.", - "asset-required": "Asset is required", + "asset-required": "Asset is required.", "name-starts-with": "Asset name expression", "help-text": "Use '%' according to need: '%asset_name_contains%', '%asset_name_ends', 'asset_starts_with'.", "import": "Import assets", @@ -1214,12 +1214,17 @@ "copy-output-key": "Copy output key", "aggregation-path-related-entities": "Aggregation path to related entities", "deduplication-interval": "Deduplication interval", - "deduplication-interval-min": "Deduplication interval should be at least {{ sec }} second.", + "deduplication-interval-min": "Deduplication interval should be at least {{ sec }} seconds.", "deduplication-interval-required": "Deduplication interval is required.", "metrics": { "metrics": "Metrics", "metrics-empty": "At least one metric must be configured.", "metric-name": "Metric name", + "metric-name-required": "Metric name is required.", + "metric-name-pattern": "Metric name is invalid.", + "metric-name-duplicate": "Metric name with such name already exists.", + "metric-name-max-length": "Metric name should be less than 256 characters.", + "metric-name-forbidden": "Metric name is reserved and cannot be used.", "copy-metric-name": "Copy metric name", "aggregation": "Aggregation", "aggregation-type": { @@ -1305,7 +1310,7 @@ "arguments-propagate-arguments-with-rolling": "'Time series rolling' type is incompatible with 'Arguments only' propagation.", "arguments-propagate-argument-entity-type": "Entity type is incompatible with 'Arguments only' propagation.", "arguments-propagate-argument-must-current-entity": "At least one argument must be configured with the 'Current entity' source entity type.", - "arguments-empty": "Arguments should not be empty.", + "arguments-empty": "At least one argument should be specified.", "expression-required": "Expression is required.", "expression-invalid": "Expression is invalid", "expression-max-length": "Expression length should be less than 255 characters.", @@ -1345,13 +1350,14 @@ "max-geofencing-zone": "Maximum number of geofencing zones reached.", "zone-group-refresh-interval": "Defines how often zone groups configured via related entities are refreshed.", "zone-group-refresh-interval-required": "Zone groups refresh interval is required.", - "zone-group-refresh-interval-min": "Zone group refresh interval should be at least {{ min }} second.", + "zone-group-refresh-interval-min": "Zone group refresh interval should be at least {{ min }} seconds.", "propagation-path-related-entities": "Defines a direct, single-level path to a related entity based on the selected direction and relation type.", "data-propagate": "Defines the data to be propagated from the arguments configured below. 'Arguments only' uses the retrieved data directly, while 'Expression result' calculates a new value from that data.", "aggregation-path-related-entities": "Defines a single-level aggregation path via direct relations with parent or child entities based on direction and relation type. Only relations between device, asset, customer, and tenant entities are supported.", "arguments-aggregation": "Defines input parameters used for filtering and aggregation.", "setting-arguments-aggregation": "Data will be fetched from related entities configured in aggregation path.", - "metrics": "Defines metrics aggregated based on the configured arguments." + "metrics": "Defines metrics aggregated based on the configured arguments.", + "import-invalid-calculated-field-type": "Unable to import calculated field: Invalid calculated field type." } }, "alarm-rule": { @@ -1405,6 +1411,7 @@ "value-type": "Value type", "general": "General", "filter": "Filter", + "date-time-hint": "The argument must be in epoch milliseconds. Example: 1698839340000 equals 2023-11-01 12:49:00 UTC.", "operation": "Operation", "value-source": "Value source", "value": "Value", @@ -1441,6 +1448,7 @@ "schedule-time-from": "From", "schedule-time-to": "To", "schedule-days-of-week-required": "At least one day of week should be selected.", + "tbel": "TBEL", "expression-type": { "simple": "Simple", "script": "Script" @@ -1463,13 +1471,14 @@ "alarm-rule-mobile-dashboard-hint": "Used by mobile application as an alarm details dashboard", "alarm-rule-no-mobile-dashboard": "No dashboard selected", "alarm-rule-condition": "Alarm rule condition", - "enter-alarm-rule-condition-prompt": "Please add alarm rule condition", + "enter-alarm-rule-condition-prompt": "Add alarm rule creating condition", + "enter-alarm-rule-clear-condition-prompt": "Add alarm rule clearing condition", "edit-alarm-rule-condition": "Edit alarm rule condition", "condition-type": "Condition type", "select-alarm-severity": "Select alarm severity", - "add-create-alarm-rule-prompt": "Please add create alarm rule", - "add-create-alarm-rule": "Add create condition", - "add-clear-alarm-rule": "Add clear condition", + "add-create-alarm-rule-prompt": "At least one creation condition should be specified", + "add-create-alarm-rule": "Add creation condition", + "add-clear-alarm-rule": "Add clearing condition", "condition-duration": "Condition duration", "condition-duration-value": "Duration value", "condition-duration-time-unit": "Time unit", @@ -1481,9 +1490,9 @@ "condition-repeating-value-range": "Count of events should be in a range from 1 to 2147483647.", "condition-repeating-value-pattern": "Count of events should be integers.", "condition-repeating-value-required": "Count of events is required.", - "create-conditions": "Create conditions", - "clear-condition": "Clear condition", - "no-clear-alarm-rule": "No clear condition configured", + "create-conditions": "Creation conditions", + "clear-condition": "Clearing condition", + "no-clear-alarm-rule": "Clearing condition not configured", "advanced-settings": "Advanced settings", "propagate-alarm": "Propagate alarm to related entities", "alarm-rule-relation-types-list": "Relation types", @@ -1497,7 +1506,8 @@ "no-alarm-rule-types-matching": "No alarm rule types matching '{{entitySubtype}}' were found.", "alarm-rule-type-list-empty": "No alarm rule types selected.", "alarm-rule-type-list": "Alarm rule type list", - "alarm-rule-entity-list": "Entity list" + "alarm-rule-entity-list": "Entity list", + "import-invalid-alarm-rule-type": "Unable to import alarm rule: Invalid alarm rule type." }, "ai-models": { "ai-models": "AI models", @@ -1708,7 +1718,7 @@ "idCopiedMessage": "Customer Id has been copied to clipboard", "select-customer": "Select customer", "no-customers-matching": "No customers matching '{{entity}}' were found.", - "customer-required": "Customer is required", + "customer-required": "Customer is required.", "select-default-customer": "Select default customer", "default-customer": "Default customer", "default-customer-required": "Default customer is required in order to debug dashboard on Tenant level", From fa50bda8b7230325a0d604035618a7c0521bd021 Mon Sep 17 00:00:00 2001 From: dshvaika Date: Tue, 25 Nov 2025 17:23:40 +0200 Subject: [PATCH 33/54] Improvements to tenant profile upgrade script --- .../main/data/upgrade/basic/schema_update.sql | 69 ++++++------------- 1 file changed, 20 insertions(+), 49 deletions(-) diff --git a/application/src/main/data/upgrade/basic/schema_update.sql b/application/src/main/data/upgrade/basic/schema_update.sql index b8c49441fe..94b1a8b878 100644 --- a/application/src/main/data/upgrade/basic/schema_update.sql +++ b/application/src/main/data/upgrade/basic/schema_update.sql @@ -18,56 +18,27 @@ UPDATE tenant_profile SET profile_data = jsonb_set( - profile_data, - '{configuration}', - (profile_data -> 'configuration') - || jsonb_strip_nulls( - jsonb_build_object( - 'minAllowedScheduledUpdateIntervalInSecForCF', - CASE - WHEN (profile_data -> 'configuration') ? 'minAllowedScheduledUpdateIntervalInSecForCF' - THEN NULL - ELSE to_jsonb(60) - END, - 'maxRelationLevelPerCfArgument', - CASE - WHEN (profile_data -> 'configuration') ? 'maxRelationLevelPerCfArgument' - THEN NULL - ELSE to_jsonb(10) - END, - 'maxRelatedEntitiesToReturnPerCfArgument', - CASE - WHEN (profile_data -> 'configuration') ? 'maxRelatedEntitiesToReturnPerCfArgument' - THEN NULL - ELSE to_jsonb(100) - END, - 'minAllowedDeduplicationIntervalInSecForCF', - CASE - WHEN (profile_data -> 'configuration') ? 'minAllowedDeduplicationIntervalInSecForCF' - THEN NULL - ELSE to_jsonb(60) - END, - 'minAllowedAggregationIntervalInSecForCF', - CASE - WHEN (profile_data -> 'configuration') ? 'minAllowedAggregationIntervalInSecForCF' - THEN NULL - ELSE to_jsonb(60) - END - ) - ), - false - ) + profile_data, + '{configuration}', + jsonb_build_object( + 'minAllowedScheduledUpdateIntervalInSecForCF', 60, + 'maxRelationLevelPerCfArgument', 10, + 'maxRelatedEntitiesToReturnPerCfArgument', 100, + 'minAllowedDeduplicationIntervalInSecForCF', 60, + 'minAllowedAggregationIntervalInSecForCF', 60 + ) + || + jsonb_strip_nulls(profile_data -> 'configuration') +) WHERE NOT ( - (profile_data -> 'configuration') ? 'minAllowedScheduledUpdateIntervalInSecForCF' - AND - (profile_data -> 'configuration') ? 'maxRelationLevelPerCfArgument' - AND - (profile_data -> 'configuration') ? 'maxRelatedEntitiesToReturnPerCfArgument' - AND - (profile_data -> 'configuration') ? 'minAllowedDeduplicationIntervalInSecForCF' - AND - (profile_data -> 'configuration') ? 'minAllowedAggregationIntervalInSecForCF' - ); + jsonb_strip_nulls(profile_data -> 'configuration') ?& ARRAY[ + 'minAllowedScheduledUpdateIntervalInSecForCF', + 'maxRelationLevelPerCfArgument', + 'maxRelatedEntitiesToReturnPerCfArgument', + 'minAllowedDeduplicationIntervalInSecForCF', + 'minAllowedAggregationIntervalInSecForCF' + ] +); -- UPDATE TENANT PROFILE CONFIGURATION END From 352236e003d17e6c686caf8da6f0440c034ae5d9 Mon Sep 17 00:00:00 2001 From: ArtemDzhereleiko Date: Tue, 25 Nov 2025 18:03:38 +0200 Subject: [PATCH 34/54] UI: Fixed validation --- .../components/alarm-rules/create-cf-alarm-rules.component.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ui-ngx/src/app/modules/home/components/alarm-rules/create-cf-alarm-rules.component.ts b/ui-ngx/src/app/modules/home/components/alarm-rules/create-cf-alarm-rules.component.ts index 7fde250074..5f36f76dae 100644 --- a/ui-ngx/src/app/modules/home/components/alarm-rules/create-cf-alarm-rules.component.ts +++ b/ui-ngx/src/app/modules/home/components/alarm-rules/create-cf-alarm-rules.component.ts @@ -159,7 +159,7 @@ export class CreateCfAlarmRulesComponent implements ControlValueAccessor, Valida } public validate(c: UntypedFormControl) { - return (this.createAlarmRulesFormArray().length && this.createAlarmRulesFormGroup.valid) ? null : { + return this.createAlarmRulesFormArray().length ? null : { createAlarmRules: { valid: false, }, From a23afd9bd496d0077dc6c4dadfed46f234f8b318 Mon Sep 17 00:00:00 2001 From: dshvaika Date: Tue, 25 Nov 2025 18:08:20 +0200 Subject: [PATCH 35/54] Added positive validation for CFs relation parameters --- .../data/tenant/profile/DefaultTenantProfileConfiguration.java | 3 +++ 1 file changed, 3 insertions(+) diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/tenant/profile/DefaultTenantProfileConfiguration.java b/common/data/src/main/java/org/thingsboard/server/common/data/tenant/profile/DefaultTenantProfileConfiguration.java index 7587f563ab..9a0bb1a1dc 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/tenant/profile/DefaultTenantProfileConfiguration.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/tenant/profile/DefaultTenantProfileConfiguration.java @@ -17,6 +17,7 @@ package org.thingsboard.server.common.data.tenant.profile; import io.swagger.v3.oas.annotations.media.Schema; import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.Positive; import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Data; @@ -175,8 +176,10 @@ public class DefaultTenantProfileConfiguration implements TenantProfileConfigura @Schema(example = "60") private int minAllowedScheduledUpdateIntervalInSecForCF = 60; @Schema(example = "10") + @Positive private int maxRelationLevelPerCfArgument = 10; @Schema(example = "100") + @Positive private int maxRelatedEntitiesToReturnPerCfArgument = 100; @Builder.Default @Min(value = 1, message = "must be at least 1") From 7b1bee4380a55ed2ad634c9e7ef58f26a04dc169 Mon Sep 17 00:00:00 2001 From: Vladyslav Prykhodko Date: Tue, 25 Nov 2025 18:26:17 +0200 Subject: [PATCH 36/54] Update api_usage.json --- ui-ngx/src/assets/dashboard/api_usage.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/ui-ngx/src/assets/dashboard/api_usage.json b/ui-ngx/src/assets/dashboard/api_usage.json index e45e9a59fa..bf3fd220be 100644 --- a/ui-ngx/src/assets/dashboard/api_usage.json +++ b/ui-ngx/src/assets/dashboard/api_usage.json @@ -100,8 +100,8 @@ "displayPagination": true, "defaultPageSize": 10, "sortOrder": { - "property": "createdTime", - "direction": "DESC" + "property": "name", + "direction": "ASC" } }, "title": "{i18n:api-usage.exceptions}", From cfb596f6d69a5ddb6bb4d3e2aa2133ac5ec7db37 Mon Sep 17 00:00:00 2001 From: IrynaMatveieva Date: Wed, 26 Nov 2025 08:29:11 +0200 Subject: [PATCH 37/54] added check for cf exception to avoid empty event in debug --- .../calculatedField/CalculatedFieldEntityMessageProcessor.java | 3 +++ 1 file changed, 3 insertions(+) diff --git a/application/src/main/java/org/thingsboard/server/actors/calculatedField/CalculatedFieldEntityMessageProcessor.java b/application/src/main/java/org/thingsboard/server/actors/calculatedField/CalculatedFieldEntityMessageProcessor.java index 6b45552bac..27206dc614 100644 --- a/application/src/main/java/org/thingsboard/server/actors/calculatedField/CalculatedFieldEntityMessageProcessor.java +++ b/application/src/main/java/org/thingsboard/server/actors/calculatedField/CalculatedFieldEntityMessageProcessor.java @@ -312,6 +312,9 @@ public class CalculatedFieldEntityMessageProcessor extends AbstractContextAwareM } } catch (Exception e) { log.debug("[{}][{}] Failed to process linked CF telemetry msg: {}", entityId, ctx.getCfId(), msg, e); + if (e instanceof CalculatedFieldException cfe) { + throw cfe; + } throw CalculatedFieldException.builder().ctx(ctx).eventEntity(entityId).cause(e).build(); } } From d6315b33293e8dd357cf4dc747b1ac4b5a932b4e Mon Sep 17 00:00:00 2001 From: IrynaMatveieva Date: Wed, 26 Nov 2025 12:40:22 +0200 Subject: [PATCH 38/54] updated related entities debug arguments structure --- ...titiesAggregationCalculatedFieldState.java | 26 +++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/aggregation/RelatedEntitiesAggregationCalculatedFieldState.java b/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/aggregation/RelatedEntitiesAggregationCalculatedFieldState.java index 28d7457c1f..dfffcb3777 100644 --- a/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/aggregation/RelatedEntitiesAggregationCalculatedFieldState.java +++ b/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/aggregation/RelatedEntitiesAggregationCalculatedFieldState.java @@ -15,6 +15,7 @@ */ package org.thingsboard.server.service.cf.ctx.state.aggregation; +import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.node.ObjectNode; import com.google.common.util.concurrent.Futures; import com.google.common.util.concurrent.ListenableFuture; @@ -23,6 +24,7 @@ import lombok.Setter; import lombok.extern.slf4j.Slf4j; import org.thingsboard.common.util.JacksonUtil; import org.thingsboard.server.actors.TbActorRef; +import org.thingsboard.server.common.data.EntityInfo; import org.thingsboard.server.common.data.cf.CalculatedFieldType; import org.thingsboard.server.common.data.cf.configuration.Output; import org.thingsboard.server.common.data.cf.configuration.aggregation.AggFunctionInput; @@ -31,9 +33,11 @@ import org.thingsboard.server.common.data.cf.configuration.aggregation.AggKeyInp import org.thingsboard.server.common.data.cf.configuration.aggregation.AggMetric; import org.thingsboard.server.common.data.cf.configuration.aggregation.RelatedEntitiesAggregationCalculatedFieldConfiguration; import org.thingsboard.server.common.data.id.EntityId; +import org.thingsboard.server.dao.entity.EntityService; import org.thingsboard.server.service.cf.CalculatedFieldResult; import org.thingsboard.server.service.cf.TelemetryCalculatedFieldResult; import org.thingsboard.server.service.cf.ctx.state.ArgumentEntry; +import org.thingsboard.server.service.cf.ctx.state.ArgumentEntryType; import org.thingsboard.server.service.cf.ctx.state.BaseCalculatedFieldState; import org.thingsboard.server.service.cf.ctx.state.CalculatedFieldCtx; import org.thingsboard.server.service.cf.ctx.state.aggregation.function.AggEntry; @@ -44,6 +48,7 @@ import java.util.List; import java.util.Map; import java.util.Map.Entry; import java.util.concurrent.ScheduledFuture; +import java.util.stream.Collectors; import static java.util.concurrent.TimeUnit.SECONDS; @@ -62,6 +67,8 @@ public class RelatedEntitiesAggregationCalculatedFieldState extends BaseCalculat private ScheduledFuture reevaluationFuture; + private EntityService entityService; + public RelatedEntitiesAggregationCalculatedFieldState(EntityId entityId) { super(entityId); } @@ -72,6 +79,7 @@ public class RelatedEntitiesAggregationCalculatedFieldState extends BaseCalculat var configuration = (RelatedEntitiesAggregationCalculatedFieldConfiguration) ctx.getCalculatedField().getConfiguration(); metrics = configuration.getMetrics(); deduplicationIntervalMs = SECONDS.toMillis(configuration.getDeduplicationIntervalInSec()); + entityService = ctx.getSystemContext().getEntityService(); } @Override @@ -247,4 +255,22 @@ public class RelatedEntitiesAggregationCalculatedFieldState extends BaseCalculat } } + @Override + public JsonNode getArgumentsJson() { + List entitiesArguments = new ArrayList<>(); + prepareInputs().forEach((entityId, entityArguments) -> { + entityService.fetchEntityName(ctx.getTenantId(), entityId).ifPresent(entityName -> { + EntityInfo entityInfo = new EntityInfo(entityId, entityName); + JsonNode entityArgumentsJson = JacksonUtil.valueToTree(entityArguments.entrySet().stream() + .collect(Collectors.toMap(Entry::getKey, e -> e.getValue().jsonValue()))); + entitiesArguments.add(new EntityArgument(entityInfo, entityArgumentsJson)); + }); + }); + return JacksonUtil.valueToTree(new RelatedEntitiesArgument(ArgumentEntryType.RELATED_ENTITIES, entitiesArguments)); + } + + record RelatedEntitiesArgument(ArgumentEntryType type, List entitiesArguments) {} + + record EntityArgument(EntityInfo entity, JsonNode entityArguments) {} + } From 9ca2b165526fe60ce565a671221c36b0077f59fe Mon Sep 17 00:00:00 2001 From: dshvaika Date: Wed, 26 Nov 2025 12:46:50 +0200 Subject: [PATCH 39/54] Set Builder.Default for Positive-only fields in Tenant profile --- .../tenant/profile/DefaultTenantProfileConfiguration.java | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/tenant/profile/DefaultTenantProfileConfiguration.java b/common/data/src/main/java/org/thingsboard/server/common/data/tenant/profile/DefaultTenantProfileConfiguration.java index 9a0bb1a1dc..87fa4a85da 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/tenant/profile/DefaultTenantProfileConfiguration.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/tenant/profile/DefaultTenantProfileConfiguration.java @@ -16,7 +16,6 @@ package org.thingsboard.server.common.data.tenant.profile; import io.swagger.v3.oas.annotations.media.Schema; -import jakarta.validation.constraints.Min; import jakarta.validation.constraints.Positive; import lombok.AllArgsConstructor; import lombok.Builder; @@ -175,14 +174,16 @@ public class DefaultTenantProfileConfiguration implements TenantProfileConfigura private long maxArgumentsPerCF = 10; @Schema(example = "60") private int minAllowedScheduledUpdateIntervalInSecForCF = 60; + @Builder.Default @Schema(example = "10") @Positive private int maxRelationLevelPerCfArgument = 10; + @Builder.Default @Schema(example = "100") @Positive private int maxRelatedEntitiesToReturnPerCfArgument = 100; @Builder.Default - @Min(value = 1, message = "must be at least 1") + @Positive @Schema(example = "1000") private long maxDataPointsPerRollingArg = 1000; @Schema(example = "32") From 361c9c8593b8d21a3a582bd1d9f260c4e702530d Mon Sep 17 00:00:00 2001 From: ArtemDzhereleiko Date: Wed, 26 Nov 2025 13:25:49 +0200 Subject: [PATCH 40/54] UI: Fixed debug event function dialog --- .../alarm-rule-dialog.component.html | 7 +- .../alarm-rule-dialog.component.ts | 25 ++++++- .../alarm-rules/alarm-rules-table-config.ts | 50 +++++++++++++- ...f-alarm-rule-condition-dialog.component.ts | 63 ++--------------- .../cf-alarm-rule-condition.component.ts | 9 +-- .../alarm-rules/cf-alarm-rule.component.html | 2 +- .../alarm-rules/cf-alarm-rule.component.ts | 7 +- .../create-cf-alarm-rules.component.html | 5 +- .../create-cf-alarm-rules.component.ts | 7 +- .../alarm-rule-filter-list.component.html | 3 +- ...-rule-filter-predicate-list.component.html | 6 +- .../calculated-fields-table-config.ts | 5 +- ...lculated-field-argument-panel.component.ts | 29 +++----- .../propagate-arguments-table.component.ts | 9 ++- .../calculated-field-dialog.component.html | 2 +- .../calculated-field-dialog.component.ts | 6 +- ...ntity-aggregation-component.component.html | 1 + .../entity-aggregation-component.component.ts | 4 +- ...culated-field-metrics-panel.component.html | 10 +-- ...alculated-field-metrics-panel.component.ts | 69 ++----------------- ...alculated-field-metrics-table.component.ts | 8 +-- ...ities-aggregation-component.component.html | 2 +- ...ntities-aggregation-component.component.ts | 5 +- .../app/shared/models/alarm-rule.models.ts | 4 ++ .../shared/models/calculated-field.models.ts | 2 +- 25 files changed, 150 insertions(+), 190 deletions(-) diff --git a/ui-ngx/src/app/modules/home/components/alarm-rules/alarm-rule-dialog.component.html b/ui-ngx/src/app/modules/home/components/alarm-rules/alarm-rule-dialog.component.html index eefa6ff209..b734757144 100644 --- a/ui-ngx/src/app/modules/home/components/alarm-rules/alarm-rule-dialog.component.html +++ b/ui-ngx/src/app/modules/home/components/alarm-rules/alarm-rule-dialog.component.html @@ -91,7 +91,7 @@
{{ 'alarm-rule.create-conditions' | translate }}
- +
@@ -100,7 +100,7 @@
- +
diff --git a/ui-ngx/src/app/modules/home/components/alarm-rules/alarm-rule-dialog.component.ts b/ui-ngx/src/app/modules/home/components/alarm-rules/alarm-rule-dialog.component.ts index b3176414ab..2209cbafba 100644 --- a/ui-ngx/src/app/modules/home/components/alarm-rules/alarm-rule-dialog.component.ts +++ b/ui-ngx/src/app/modules/home/components/alarm-rules/alarm-rule-dialog.component.ts @@ -31,8 +31,15 @@ import { EntityId } from '@shared/models/id/entity-id'; import { AdditionalDebugActionConfig } from '@home/components/entity/debug/entity-debug-settings.model'; import { COMMA, ENTER, SEMICOLON } from "@angular/cdk/keycodes"; import { MatChipInputEvent } from "@angular/material/chips"; -import { AlarmRule, AlarmRuleConditionType, AlarmRuleExpressionType } from "@shared/models/alarm-rule.models"; +import { + AlarmRule, + AlarmRuleConditionType, + AlarmRuleExpressionType, + AlarmRuleTestScriptFn +} from "@shared/models/alarm-rule.models"; import { deepTrim } from "@core/utils"; +import { Observable } from "rxjs"; +import { switchMap } from "rxjs/operators"; export interface AlarmRuleDialogData { value?: CalculatedField; @@ -43,6 +50,7 @@ export interface AlarmRuleDialogData { ownerId: EntityId; additionalDebugActionConfig: AdditionalDebugActionConfig<(calculatedField: CalculatedField) => void>; isDirty?: boolean; + getTestScriptDialogFn: AlarmRuleTestScriptFn, } @Component({ @@ -182,4 +190,19 @@ export class AlarmRuleDialogComponent extends DialogComponent { + const calculatedFieldId = this.data.value?.id?.id; + if (calculatedFieldId) { + return this.calculatedFieldsService.getLatestCalculatedFieldDebugEvent(calculatedFieldId, {ignoreLoading: true}) + .pipe( + switchMap(event => { + const args = event?.arguments ? JSON.parse(event.arguments) : null; + return this.data.getTestScriptDialogFn(this.fromGroupValue, expression, args, false); + }), + takeUntilDestroyed(this.destroyRef) + ) + } + return this.data.getTestScriptDialogFn(this.fromGroupValue, expression, null, false); + } } diff --git a/ui-ngx/src/app/modules/home/components/alarm-rules/alarm-rules-table-config.ts b/ui-ngx/src/app/modules/home/components/alarm-rules/alarm-rules-table-config.ts index c581db2b2b..6fa6515663 100644 --- a/ui-ngx/src/app/modules/home/components/alarm-rules/alarm-rules-table-config.ts +++ b/ui-ngx/src/app/modules/home/components/alarm-rules/alarm-rules-table-config.ts @@ -36,13 +36,17 @@ import { DestroyRef, Renderer2 } from '@angular/core'; import { EntityDebugSettings } from '@shared/models/entity.models'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { CalculatedFieldsService } from '@core/http/calculated-fields.service'; -import { catchError, filter, switchMap } from 'rxjs/operators'; +import { catchError, filter, switchMap, tap } from 'rxjs/operators'; import { ArgumentEntityType, + ArgumentType, CalculatedField, CalculatedFieldAlarmRule, + CalculatedFieldEventArguments, CalculatedFieldsQuery, CalculatedFieldType, + getCalculatedFieldArgumentsEditorCompleter, + getCalculatedFieldArgumentsHighlights, } from '@shared/models/calculated-field.models'; import { ImportExportService } from '@shared/import-export/import-export.service'; import { EntityDebugSettingsService } from '@home/components/entity/debug/entity-debug-settings.service'; @@ -57,9 +61,13 @@ import { } from "@home/components/calculated-fields/components/debug-dialog/calculated-field-debug-dialog.component"; import { AlarmSeverity, alarmSeverityTranslations } from "@shared/models/alarm.models"; import { UtilsService } from "@core/services/utils.service"; -import { deepClone, getEntityDetailsPageURL } from "@core/utils"; +import { deepClone, getEntityDetailsPageURL, isObject } from "@core/utils"; import { AlarmRuleTableHeaderComponent } from "@home/components/alarm-rules/alarm-rule-table-header.component"; import { ActionNotificationShow } from "@core/notification/notification.actions"; +import { + CalculatedFieldScriptTestDialogComponent, + CalculatedFieldTestScriptDialogData +} from "@home/components/calculated-fields/components/test-dialog/calculated-field-script-test-dialog.component"; export class AlarmRulesTableConfig extends EntityTableConfig { @@ -249,6 +257,7 @@ export class AlarmRulesTableConfig extends EntityTableConfig { ownerId: this.ownerId ?? {entityType: EntityType.TENANT, id: this.tenantId}, additionalDebugActionConfig: this.additionalDebugActionConfig, isDirty, + getTestScriptDialogFn: this.getTestScriptDialog.bind(this), }, enterAnimationDuration: isDirty ? 0 : null, }) @@ -324,4 +333,41 @@ export class AlarmRulesTableConfig extends EntityTableConfig { takeUntilDestroyed(this.destroyRef), ).subscribe(() => this.updateData()); } + + private getTestScriptDialog(calculatedField: CalculatedField, expression: string, argumentsObj?: CalculatedFieldEventArguments, openCalculatedFieldEdit = true): Observable { + if (calculatedField.type === CalculatedFieldType.ALARM) { + const resultArguments = Object.keys(calculatedField.configuration.arguments).reduce((acc, key) => { + const type = calculatedField.configuration.arguments[key].refEntityKey.type; + acc[key] = isObject(argumentsObj) && argumentsObj.hasOwnProperty(key) + ? {...argumentsObj[key], type} + : type === ArgumentType.Rolling ? {values: [], type} : {value: '', type, ts: new Date().getTime()}; + return acc; + }, {}); + return this.dialog.open(CalculatedFieldScriptTestDialogComponent, + { + disableClose: true, + panelClass: ['tb-dialog', 'tb-fullscreen-dialog', 'tb-fullscreen-dialog-gt-xs'], + data: { + arguments: resultArguments, + expression, + argumentsEditorCompleter: getCalculatedFieldArgumentsEditorCompleter(calculatedField.configuration.arguments), + argumentsHighlightRules: getCalculatedFieldArgumentsHighlights(calculatedField.configuration.arguments), + openCalculatedFieldEdit + } + }).afterClosed() + .pipe( + filter(Boolean), + tap(expression => { + if (openCalculatedFieldEdit) { + this.editCalculatedField({ + entityId: this.entityId, ...calculatedField, + configuration: {...calculatedField.configuration, expression} as any + }, true) + } + }), + ); + } else { + return of(null); + } + } } diff --git a/ui-ngx/src/app/modules/home/components/alarm-rules/cf-alarm-rule-condition-dialog.component.ts b/ui-ngx/src/app/modules/home/components/alarm-rules/cf-alarm-rule-condition-dialog.component.ts index dc8b23c707..2b0f25c2fe 100644 --- a/ui-ngx/src/app/modules/home/components/alarm-rules/cf-alarm-rule-condition-dialog.component.ts +++ b/ui-ngx/src/app/modules/home/components/alarm-rules/cf-alarm-rule-condition-dialog.component.ts @@ -14,8 +14,8 @@ /// limitations under the License. /// -import { Component, DestroyRef, Inject } from '@angular/core'; -import { MAT_DIALOG_DATA, MatDialog, MatDialogRef } from '@angular/material/dialog'; +import { Component, Inject } from '@angular/core'; +import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog'; import { Store } from '@ngrx/store'; import { AppState } from '@core/core.state'; import { FormBuilder, Validators } from '@angular/forms'; @@ -32,10 +32,7 @@ import { AlarmRuleExpressionType } from "@shared/models/alarm-rule.models"; import { - ArgumentType, - CalculatedField, CalculatedFieldArgument, - CalculatedFieldEventArguments, getCalculatedFieldArgumentsEditorCompleter, getCalculatedFieldArgumentsHighlights } from "@shared/models/calculated-field.models"; @@ -43,19 +40,12 @@ import { TbEditorCompleter } from "@shared/models/ace/completion.models"; import { AceHighlightRules } from "@shared/models/ace/ace.models"; import { ComplexOperation, complexOperationTranslationMap } from "@shared/models/query/query.models"; import { Observable } from "rxjs"; -import { filter, switchMap, tap } from "rxjs/operators"; -import { isObject } from "@core/utils"; -import { - CalculatedFieldScriptTestDialogComponent, - CalculatedFieldTestScriptDialogData -} from "@home/components/calculated-fields/components/test-dialog/calculated-field-script-test-dialog.component"; -import { CalculatedFieldsService } from "@core/http/calculated-fields.service"; export interface CfAlarmRuleConditionDialogData { readonly: boolean; condition: AlarmRuleCondition; arguments?: Record; - value?: CalculatedField; + testScript: (expression: string) => Observable; } @Component({ @@ -119,10 +109,7 @@ export class CfAlarmRuleConditionDialogComponent extends DialogComponent, - private fb: FormBuilder, - private calculatedFieldsService: CalculatedFieldsService, - private destroyRef: DestroyRef, - private dialog: MatDialog) { + private fb: FormBuilder) { super(store, router, dialogRef); this.functionArgs = ['ctx', ...Object.keys(this.data.arguments)]; @@ -240,50 +227,10 @@ export class CfAlarmRuleConditionDialogComponent extends DialogComponent { this.conditionFormGroup.get('expression.expression').setValue(expression); this.conditionFormGroup.get('expression.expression').markAsDirty(); }) } - - testScript(): Observable { - if (this.data.value?.id?.id) { - return this.calculatedFieldsService.getLatestCalculatedFieldDebugEvent(this.data.value?.id?.id, {ignoreLoading: true}) - .pipe( - switchMap(event => { - const args = event?.arguments ? JSON.parse(event.arguments) : null; - return this.getTestScriptDialog(this.arguments, this.conditionFormGroup.get('expression.expression').value, args); - }), - takeUntilDestroyed(this.destroyRef) - ) - } - return this.getTestScriptDialog(this.arguments, this.conditionFormGroup.get('expression.expression').value, null); - } - - getTestScriptDialog(argumentsList: Record, expression: string, argumentsObj?: CalculatedFieldEventArguments): Observable { - const resultArguments = Object.keys(argumentsList).reduce((acc, key) => { - const type = argumentsList[key].refEntityKey.type; - acc[key] = isObject(argumentsObj) && argumentsObj.hasOwnProperty(key) - ? {...argumentsObj[key], type} - : type === ArgumentType.Rolling ? {values: [], type} : {value: '', type, ts: new Date().getTime()}; - return acc; - }, {}); - return this.dialog.open(CalculatedFieldScriptTestDialogComponent, - { - disableClose: true, - panelClass: ['tb-dialog', 'tb-fullscreen-dialog', 'tb-fullscreen-dialog-gt-xs'], - data: { - arguments: resultArguments, - expression: expression, - argumentsEditorCompleter: getCalculatedFieldArgumentsEditorCompleter(argumentsList), - argumentsHighlightRules: getCalculatedFieldArgumentsHighlights(argumentsList) - } - }).afterClosed() - .pipe( - filter(Boolean), - tap(expression => expression) - ); - } - } diff --git a/ui-ngx/src/app/modules/home/components/alarm-rules/cf-alarm-rule-condition.component.ts b/ui-ngx/src/app/modules/home/components/alarm-rules/cf-alarm-rule-condition.component.ts index 0ae0c4a067..2a0b7a7810 100644 --- a/ui-ngx/src/app/modules/home/components/alarm-rules/cf-alarm-rule-condition.component.ts +++ b/ui-ngx/src/app/modules/home/components/alarm-rules/cf-alarm-rule-condition.component.ts @@ -43,12 +43,13 @@ import { AlarmRuleSchedule, AlarmRuleScheduleType } from "@shared/models/alarm-rule.models"; -import { CalculatedField, CalculatedFieldArgument } from "@shared/models/calculated-field.models"; +import { CalculatedFieldArgument } from "@shared/models/calculated-field.models"; import { AlarmRuleScheduleDialogData, CfAlarmScheduleDialogComponent } from "@home/components/alarm-rules/cf-alarm-schedule-dialog.component"; import { coerceBoolean } from "@shared/decorators/coercion"; +import { Observable } from "rxjs"; @Component({ selector: 'tb-cf-alarm-rule-condition', @@ -84,8 +85,8 @@ export class CfAlarmRuleConditionComponent implements ControlValueAccessor, Vali @coerceBoolean() isClearCondition = false; - @Input() - value: CalculatedField; + @Input({required: true}) + testScript: (expression: string) => Observable; alarmRuleConditionFormGroup = this.fb.group({ type: ['SIMPLE'], @@ -152,7 +153,7 @@ export class CfAlarmRuleConditionComponent implements ControlValueAccessor, Vali readonly: this.disabled, condition: this.disabled ? this.modelValue : deepClone(this.modelValue), arguments: this.arguments, - value: this.value, + testScript: this.testScript } }).afterClosed().subscribe((result) => { if (result) { diff --git a/ui-ngx/src/app/modules/home/components/alarm-rules/cf-alarm-rule.component.html b/ui-ngx/src/app/modules/home/components/alarm-rules/cf-alarm-rule.component.html index 661573fc51..38e0bd8023 100644 --- a/ui-ngx/src/app/modules/home/components/alarm-rules/cf-alarm-rule.component.html +++ b/ui-ngx/src/app/modules/home/components/alarm-rules/cf-alarm-rule.component.html @@ -16,7 +16,7 @@ -->
- + @if (!disabled || alarmRuleFormGroup.get('alarmDetails').value) {
diff --git a/ui-ngx/src/app/modules/home/components/alarm-rules/cf-alarm-rule.component.ts b/ui-ngx/src/app/modules/home/components/alarm-rules/cf-alarm-rule.component.ts index d0947c651f..5db525074d 100644 --- a/ui-ngx/src/app/modules/home/components/alarm-rules/cf-alarm-rule.component.ts +++ b/ui-ngx/src/app/modules/home/components/alarm-rules/cf-alarm-rule.component.ts @@ -28,12 +28,13 @@ import { isDefinedAndNotNull } from '@core/utils'; import { DashboardId } from '@shared/models/id/dashboard-id'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { AlarmRule, AlarmRuleCondition } from "@shared/models/alarm-rule.models"; -import { CalculatedField, CalculatedFieldArgument } from "@shared/models/calculated-field.models"; +import { CalculatedFieldArgument } from "@shared/models/calculated-field.models"; import { AlarmRuleDetailsDialogComponent, AlarmRuleDetailsDialogData } from "@home/components/alarm-rules/alarm-rule-details-dialog.component"; import { coerceBoolean } from "@shared/decorators/coercion"; +import { Observable } from "rxjs"; @Component({ selector: 'tb-cf-alarm-rule', @@ -69,8 +70,8 @@ export class CfAlarmRuleComponent implements ControlValueAccessor, OnInit, Valid @coerceBoolean() isClearCondition = false; - @Input() - value: CalculatedField; + @Input({required: true}) + testScript: (expression: string) => Observable; private modelValue: AlarmRule; diff --git a/ui-ngx/src/app/modules/home/components/alarm-rules/create-cf-alarm-rules.component.html b/ui-ngx/src/app/modules/home/components/alarm-rules/create-cf-alarm-rules.component.html index 6f6784b8d0..359ad104f1 100644 --- a/ui-ngx/src/app/modules/home/components/alarm-rules/create-cf-alarm-rules.component.html +++ b/ui-ngx/src/app/modules/home/components/alarm-rules/create-cf-alarm-rules.component.html @@ -34,7 +34,7 @@
- +
diff --git a/ui-ngx/src/app/modules/home/components/alarm-rules/create-cf-alarm-rules.component.ts b/ui-ngx/src/app/modules/home/components/alarm-rules/create-cf-alarm-rules.component.ts index 5f36f76dae..2197d6d14c 100644 --- a/ui-ngx/src/app/modules/home/components/alarm-rules/create-cf-alarm-rules.component.ts +++ b/ui-ngx/src/app/modules/home/components/alarm-rules/create-cf-alarm-rules.component.ts @@ -29,10 +29,11 @@ import { } from '@angular/forms'; import { AlarmSeverity, alarmSeverityTranslations } from '@shared/models/alarm.models'; import { AlarmRule } from "@shared/models/alarm-rule.models"; -import { CalculatedField, CalculatedFieldArgument } from "@shared/models/calculated-field.models"; +import { CalculatedFieldArgument } from "@shared/models/calculated-field.models"; import { AlarmSeverityNotificationColors } from "@shared/models/notification.models"; import { takeUntilDestroyed } from "@angular/core/rxjs-interop"; import { coerceBoolean } from "@shared/decorators/coercion"; +import { Observable } from "rxjs"; @Component({ selector: 'tb-create-cf-alarm-rules', @@ -60,8 +61,8 @@ export class CreateCfAlarmRulesComponent implements ControlValueAccessor, Valida @Input() arguments: Record; - @Input() - value: CalculatedField; + @Input({required: true}) + testScript: (expression: string) => Observable; alarmSeverities = Object.keys(AlarmSeverity); alarmSeverityEnum = AlarmSeverity; diff --git a/ui-ngx/src/app/modules/home/components/alarm-rules/filter/alarm-rule-filter-list.component.html b/ui-ngx/src/app/modules/home/components/alarm-rules/filter/alarm-rule-filter-list.component.html index 3295bdc85e..19ccff4a52 100644 --- a/ui-ngx/src/app/modules/home/components/alarm-rules/filter/alarm-rule-filter-list.component.html +++ b/ui-ngx/src/app/modules/home/components/alarm-rules/filter/alarm-rule-filter-list.component.html @@ -71,8 +71,7 @@ diff --git a/ui-ngx/src/app/modules/home/components/alarm-rules/filter/alarm-rule-filter-predicate-list.component.html b/ui-ngx/src/app/modules/home/components/alarm-rules/filter/alarm-rule-filter-predicate-list.component.html index 4882d37f0e..c094949fb6 100644 --- a/ui-ngx/src/app/modules/home/components/alarm-rules/filter/alarm-rule-filter-predicate-list.component.html +++ b/ui-ngx/src/app/modules/home/components/alarm-rules/filter/alarm-rule-filter-predicate-list.component.html @@ -69,15 +69,13 @@
diff --git a/ui-ngx/src/app/modules/home/components/calculated-fields/calculated-fields-table-config.ts b/ui-ngx/src/app/modules/home/components/calculated-fields/calculated-fields-table-config.ts index 6ac87e21ce..98e4a29549 100644 --- a/ui-ngx/src/app/modules/home/components/calculated-fields/calculated-fields-table-config.ts +++ b/ui-ngx/src/app/modules/home/components/calculated-fields/calculated-fields-table-config.ts @@ -309,9 +309,10 @@ export class CalculatedFieldsTableConfig extends EntityTableConfig this.updateData()); } - private getTestScriptDialog(calculatedField: CalculatedField, argumentsObj?: CalculatedFieldEventArguments, openCalculatedFieldEdit = true): Observable { + private getTestScriptDialog(calculatedField: CalculatedField, argumentsObj?: CalculatedFieldEventArguments, openCalculatedFieldEdit = true, expression?: string): Observable { if ( calculatedField.type === CalculatedFieldType.SCRIPT || + calculatedField.type === CalculatedFieldType.RELATED_ENTITIES_AGGREGATION || (calculatedField.type === CalculatedFieldType.PROPAGATION && calculatedField.configuration.applyExpressionToResolvedArguments === true) ) { const resultArguments = Object.keys(calculatedField.configuration.arguments).reduce((acc, key) => { @@ -327,7 +328,7 @@ export class CalculatedFieldsTableConfig extends EntityTableConfig value !== ArgumentEntityType.RelationQuery) as ArgumentEntityType[]; + @Input() argumentNameContext: {[key: string]: string} = { + label: 'calculated-fields.argument-name', + required: 'calculated-fields.hint.argument-name-required', + duplicate: 'calculated-fields.hint.argument-name-duplicate', + pattern: 'calculated-fields.hint.argument-name-pattern', + maxlength: 'calculated-fields.hint.argument-name-max-length', + forbidden: 'calculated-fields.hint.argument-name-forbidden' + }; @ViewChild('entityAutocomplete') entityAutocomplete: EntityAutocompleteComponent; @@ -110,15 +117,6 @@ export class CalculatedFieldArgumentPanelComponent implements OnInit, AfterViewI enableAutocomplete = false; - argumentNameContext = { - label: 'calculated-fields.argument-name', - required: 'calculated-fields.hint.argument-name-required', - duplicate: 'calculated-fields.hint.argument-name-duplicate', - pattern: 'calculated-fields.hint.argument-name-pattern', - maxlength: 'calculated-fields.hint.argument-name-max-length', - forbidden: 'calculated-fields.hint.argument-name-forbidden' - } - readonly ArgumentEntityTypeTranslations = ArgumentEntityTypeTranslations; readonly ArgumentType = ArgumentType; readonly DataKeyType = DataKeyType; @@ -171,17 +169,6 @@ export class CalculatedFieldArgumentPanelComponent implements OnInit, AfterViewI this.setWatchKeyChange(); } - if (this.isOutputKey) { - this.argumentNameContext = { - label: 'calculated-fields.output-key', - required: 'calculated-fields.hint.output-key-required', - duplicate: 'calculated-fields.hint.output-key-duplicate', - pattern: 'calculated-fields.hint.output-key-pattern', - maxlength: 'calculated-fields.hint.output-key-max-length', - forbidden: 'calculated-fields.hint.output-key-forbidden' - } - } - if (this.defaultValueRequired) { this.argumentFormGroup.get('defaultValue').addValidators(Validators.required); this.argumentFormGroup.get('defaultValue').updateValueAndValidity({onlySelf: true}); diff --git a/ui-ngx/src/app/modules/home/components/calculated-fields/components/calculated-field-arguments/propagate-arguments-table.component.ts b/ui-ngx/src/app/modules/home/components/calculated-fields/components/calculated-field-arguments/propagate-arguments-table.component.ts index 2e4dd9a04d..7c667999d0 100644 --- a/ui-ngx/src/app/modules/home/components/calculated-fields/components/calculated-field-arguments/propagate-arguments-table.component.ts +++ b/ui-ngx/src/app/modules/home/components/calculated-fields/components/calculated-field-arguments/propagate-arguments-table.component.ts @@ -93,7 +93,14 @@ export class PropagateArgumentsTableComponent extends CalculatedFieldArgumentsTa this.displayColumns = ['name', 'type', 'key', 'actions']; this.panelAdditionalCtx = { argumentEntityTypes: [ArgumentEntityType.Current], - isOutputKey: true, + argumentNameContext: { + label: 'calculated-fields.output-key', + required: 'calculated-fields.hint.output-key-required', + duplicate: 'calculated-fields.hint.output-key-duplicate', + pattern: 'calculated-fields.hint.output-key-pattern', + maxlength: 'calculated-fields.hint.output-key-max-length', + forbidden: 'calculated-fields.hint.output-key-forbidden' + }, watchKeyChange: true, forbiddenNames: [...FORBIDDEN_NAMES, 'propagationCtx'], }; diff --git a/ui-ngx/src/app/modules/home/components/calculated-fields/components/dialog/calculated-field-dialog.component.html b/ui-ngx/src/app/modules/home/components/calculated-fields/components/dialog/calculated-field-dialog.component.html index 49f4664f13..3070a6cbb2 100644 --- a/ui-ngx/src/app/modules/home/components/calculated-fields/components/dialog/calculated-field-dialog.component.html +++ b/ui-ngx/src/app/modules/home/components/calculated-fields/components/dialog/calculated-field-dialog.component.html @@ -88,7 +88,7 @@ [entityId]="data.entityId" [entityName]="data.entityName" [tenantId]="data.tenantId" - [calculatedFieldId]="data.value?.id?.id" + [testScript]="onTestScript.bind(this)" > } @case (CalculatedFieldType.ENTITY_AGGREGATION) { diff --git a/ui-ngx/src/app/modules/home/components/calculated-fields/components/dialog/calculated-field-dialog.component.ts b/ui-ngx/src/app/modules/home/components/calculated-fields/components/dialog/calculated-field-dialog.component.ts index 7174ec9dd9..ee7bafee83 100644 --- a/ui-ngx/src/app/modules/home/components/calculated-fields/components/dialog/calculated-field-dialog.component.ts +++ b/ui-ngx/src/app/modules/home/components/calculated-fields/components/dialog/calculated-field-dialog.component.ts @@ -105,19 +105,19 @@ export class CalculatedFieldDialogComponent extends DialogComponent { + onTestScript(expression?: string): Observable { const calculatedFieldId = this.data.value?.id?.id; if (calculatedFieldId) { return this.calculatedFieldsService.getLatestCalculatedFieldDebugEvent(calculatedFieldId, {ignoreLoading: true}) .pipe( switchMap(event => { const args = event?.arguments ? JSON.parse(event.arguments) : null; - return this.data.getTestScriptDialogFn(this.fromGroupValue, args, false); + return this.data.getTestScriptDialogFn(this.fromGroupValue, args, false, expression); }), takeUntilDestroyed(this.destroyRef) ) } - return this.data.getTestScriptDialogFn(this.fromGroupValue, null, false); + return this.data.getTestScriptDialogFn(this.fromGroupValue, null, false, expression); } private applyDialogData(): void { diff --git a/ui-ngx/src/app/modules/home/components/calculated-fields/components/entity-aggregation-configuration/entity-aggregation-component.component.html b/ui-ngx/src/app/modules/home/components/calculated-fields/components/entity-aggregation-configuration/entity-aggregation-component.component.html index e4a40c3cdd..4dcbf5b38d 100644 --- a/ui-ngx/src/app/modules/home/components/calculated-fields/components/entity-aggregation-configuration/entity-aggregation-component.component.html +++ b/ui-ngx/src/app/modules/home/components/calculated-fields/components/entity-aggregation-configuration/entity-aggregation-component.component.html @@ -34,6 +34,7 @@
diff --git a/ui-ngx/src/app/modules/home/components/calculated-fields/components/entity-aggregation-configuration/entity-aggregation-component.component.ts b/ui-ngx/src/app/modules/home/components/calculated-fields/components/entity-aggregation-configuration/entity-aggregation-component.component.ts index c0afae1c30..a09dd87cfa 100644 --- a/ui-ngx/src/app/modules/home/components/calculated-fields/components/entity-aggregation-configuration/entity-aggregation-component.component.ts +++ b/ui-ngx/src/app/modules/home/components/calculated-fields/components/entity-aggregation-configuration/entity-aggregation-component.component.ts @@ -42,7 +42,7 @@ import { deepClone, isDefinedAndNotNull } from '@core/utils'; import { getCurrentAuthState } from '@core/auth/auth.selectors'; import { Store } from '@ngrx/store'; import { AppState } from '@core/core.state'; -import { merge } from 'rxjs'; +import { merge, Observable } from 'rxjs'; import { TranslateService } from '@ngx-translate/core'; import _moment from 'moment'; @@ -85,6 +85,8 @@ export class EntityAggregationComponentComponent implements ControlValueAccessor @Input({required: true}) entityName: string; + @Input() testScript: (expression?: string) => Observable; + readonly minAllowedAggregationIntervalInSecForCF = getCurrentAuthState(this.store).minAllowedAggregationIntervalInSecForCF; readonly DayInSec = DAY / SECOND; diff --git a/ui-ngx/src/app/modules/home/components/calculated-fields/components/metrics/calculated-field-metrics-panel.component.html b/ui-ngx/src/app/modules/home/components/calculated-fields/components/metrics/calculated-field-metrics-panel.component.html index 0a2358d544..f86c15ba73 100644 --- a/ui-ngx/src/app/modules/home/components/calculated-fields/components/metrics/calculated-field-metrics-panel.component.html +++ b/ui-ngx/src/app/modules/home/components/calculated-fields/components/metrics/calculated-field-metrics-panel.component.html @@ -110,7 +110,7 @@ matTooltip="{{ 'calculated-fields.test-script-function' | translate }}" matTooltipPosition="above" class="tb-mat-32" - [disabled]="!argumentsList.length" + [disabled]="!arguments.length" (click)="onTestScript('filter')"> bug_report @@ -119,7 +119,7 @@ @@ -145,7 +145,7 @@
{{ 'calculated-fields.argument-name' | translate }}
- @for (argument of argumentsList; track argument) { + @for (argument of arguments; track argument) { {{ argument }} } @@ -179,7 +179,7 @@ matTooltip="{{ 'calculated-fields.test-script-function' | translate }}" matTooltipPosition="above" class="tb-mat-32" - [disabled]="!argumentsList.length" + [disabled]="!arguments.length" (click)="onTestScript('input.function')"> bug_report @@ -188,7 +188,7 @@ diff --git a/ui-ngx/src/app/modules/home/components/calculated-fields/components/metrics/calculated-field-metrics-panel.component.ts b/ui-ngx/src/app/modules/home/components/calculated-fields/components/metrics/calculated-field-metrics-panel.component.ts index 1c1c0da7d7..3d5a53ea4e 100644 --- a/ui-ngx/src/app/modules/home/components/calculated-fields/components/metrics/calculated-field-metrics-panel.component.ts +++ b/ui-ngx/src/app/modules/home/components/calculated-fields/components/metrics/calculated-field-metrics-panel.component.ts @@ -14,7 +14,7 @@ /// limitations under the License. /// -import { Component, DestroyRef, Input, OnInit, output } from '@angular/core'; +import { Component, Input, OnInit, output } from '@angular/core'; import { TbPopoverComponent } from '@shared/components/popover.component'; import { FormBuilder, Validators } from '@angular/forms'; import { charsWithNumRegex } from '@shared/models/regex.constants'; @@ -23,14 +23,9 @@ import { AggFunctionTranslations, AggInputType, AggInputTypeTranslations, - ArgumentType, CalculatedFieldAggMetricValue, - CalculatedFieldArgument, - CalculatedFieldEventArguments, FORBIDDEN_NAMES, forbiddenNamesValidator, - getCalculatedFieldArgumentsEditorCompleter, - getCalculatedFieldArgumentsHighlights, uniqueNameValidator } from '@shared/models/calculated-field.models'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; @@ -38,15 +33,7 @@ import { EntityFilter } from '@shared/models/query/query.models'; import { ScriptLanguage } from '@shared/models/rule-node.models'; import { TbEditorCompleter } from '@shared/models/ace/completion.models'; import { AceHighlightRules } from '@shared/models/ace/ace.models'; -import { MatDialog } from "@angular/material/dialog"; import { Observable } from "rxjs"; -import { isObject } from "@core/utils"; -import { - CalculatedFieldScriptTestDialogComponent, - CalculatedFieldTestScriptDialogData -} from "@home/components/calculated-fields/components/test-dialog/calculated-field-script-test-dialog.component"; -import { filter, switchMap, tap } from "rxjs/operators"; -import { CalculatedFieldsService } from "@core/http/calculated-fields.service"; interface CalculatedFieldAggMetricValuePanel extends CalculatedFieldAggMetricValue { allowFilter: boolean; @@ -62,15 +49,14 @@ export class CalculatedFieldMetricsPanelComponent implements OnInit { @Input() buttonTitle: string; @Input() metric: CalculatedFieldAggMetricValue; @Input() usedNames: string[]; - @Input() arguments: Record; + @Input() arguments: Array; @Input() simpleMode: boolean; @Input() editorCompleter: TbEditorCompleter; @Input() highlightRules: AceHighlightRules; - @Input() calculatedFieldId: string; + @Input({required: true}) testScript: (expression?: string) => Observable; metricDataApplied = output(); filterExpanded = false; - argumentsList: Array functionArgs: Array metricForm = this.fb.group({ @@ -98,9 +84,6 @@ export class CalculatedFieldMetricsPanelComponent implements OnInit { constructor( private fb: FormBuilder, private popover: TbPopoverComponent, - private dialog: MatDialog, - private calculatedFieldsService: CalculatedFieldsService, - private destroyRef: DestroyRef ) { this.observeFilterAllowChange(); this.observeInputTypeChange(); @@ -119,8 +102,7 @@ export class CalculatedFieldMetricsPanelComponent implements OnInit { this.validateInputTypeFilter(data.input?.type ?? AggInputType.key); this.validateInputKey(); - this.argumentsList = Object.keys(this.arguments); - this.functionArgs = ['ctx', ...this.argumentsList]; + this.functionArgs = ['ctx', ...this.arguments]; } saveMetric(): void { @@ -178,55 +160,16 @@ export class CalculatedFieldMetricsPanelComponent implements OnInit { } private validateInputKey() { - if (this.metric.input?.type === AggInputType.key && !Object.keys(this.arguments).includes(this.metric.input.key)) { + if (this.metric.input?.type === AggInputType.key && !this.arguments.includes(this.metric.input.key)) { this.metricForm.get('input.key').setValue(null); this.metricForm.get('input.key').markAsTouched(); } } onTestScript(scriptFunc: 'filter' | 'input.function') { - this.testScript(scriptFunc).subscribe(expression => { + this.testScript(this.metricForm.get(scriptFunc).value).subscribe(expression => { this.metricForm.get(scriptFunc).setValue(expression); this.metricForm.get(scriptFunc).markAsDirty(); }); } - - testScript(scriptFunc: 'filter' | 'input.function'): Observable { - if (this.calculatedFieldId) { - return this.calculatedFieldsService.getLatestCalculatedFieldDebugEvent(this.calculatedFieldId, {ignoreLoading: true}) - .pipe( - switchMap(event => { - const args = event?.arguments ? JSON.parse(event.arguments) : null; - return this.getTestScriptDialog(this.arguments, this.metricForm.get(scriptFunc).value, args); - }), - takeUntilDestroyed(this.destroyRef) - ) - } - return this.getTestScriptDialog(this.arguments, this.metricForm.get(scriptFunc).value, null); - } - - getTestScriptDialog(argumentsList: Record, expression: string, argumentsObj?: CalculatedFieldEventArguments): Observable { - const resultArguments = Object.keys(argumentsList).reduce((acc, key) => { - const type = argumentsList[key].refEntityKey.type; - acc[key] = isObject(argumentsObj) && argumentsObj.hasOwnProperty(key) - ? {...argumentsObj[key], type} - : type === ArgumentType.Rolling ? {values: [], type} : {value: '', type, ts: new Date().getTime()}; - return acc; - }, {}); - return this.dialog.open(CalculatedFieldScriptTestDialogComponent, - { - disableClose: true, - panelClass: ['tb-dialog', 'tb-fullscreen-dialog', 'tb-fullscreen-dialog-gt-xs'], - data: { - arguments: resultArguments, - expression: expression, - argumentsEditorCompleter: getCalculatedFieldArgumentsEditorCompleter(argumentsList), - argumentsHighlightRules: getCalculatedFieldArgumentsHighlights(argumentsList) - } - }).afterClosed() - .pipe( - filter(Boolean), - tap(expression => expression) - ); - } } diff --git a/ui-ngx/src/app/modules/home/components/calculated-fields/components/metrics/calculated-field-metrics-table.component.ts b/ui-ngx/src/app/modules/home/components/calculated-fields/components/metrics/calculated-field-metrics-table.component.ts index 1a8821a15c..ec916213e7 100644 --- a/ui-ngx/src/app/modules/home/components/calculated-fields/components/metrics/calculated-field-metrics-table.component.ts +++ b/ui-ngx/src/app/modules/home/components/calculated-fields/components/metrics/calculated-field-metrics-table.component.ts @@ -40,7 +40,6 @@ import { AggInputTypeTranslations, CalculatedFieldAggMetric, CalculatedFieldAggMetricValue, - CalculatedFieldArgument, } from '@shared/models/calculated-field.models'; import { MatButton } from '@angular/material/button'; import { TbPopoverService } from '@shared/components/popover.service'; @@ -57,6 +56,7 @@ import { } from '@home/components/calculated-fields/components/metrics/calculated-field-metrics-panel.component'; import { TbEditorCompleter } from '@shared/models/ace/completion.models'; import { AceHighlightRules } from '@shared/models/ace/ace.models'; +import { Observable } from "rxjs"; @Component({ selector: 'tb-calculated-field-metrics-table', @@ -77,11 +77,11 @@ import { AceHighlightRules } from '@shared/models/ace/ace.models'; }) export class CalculatedFieldMetricsTableComponent implements OnInit, ControlValueAccessor, Validator, AfterViewInit { - @Input() arguments: Record; + @Input() arguments: Array; @Input() editorCompleter: TbEditorCompleter; @Input() highlightRules: AceHighlightRules; @Input({transform: booleanAttribute}) simpleMode: boolean = false; - @Input() calculatedFieldId: string; + @Input({required: true}) testScript: (expression?: string) => Observable; @ViewChild(MatSort, { static: true }) sort: MatSort; @@ -168,7 +168,7 @@ export class CalculatedFieldMetricsTableComponent implements OnInit, ControlValu editorCompleter: this.editorCompleter, highlightRules: this.highlightRules, simpleMode: this.simpleMode, - calculatedFieldId: this.calculatedFieldId + testScript: this.testScript }; this.popoverComponent = this.popoverService.displayPopover({ trigger, diff --git a/ui-ngx/src/app/modules/home/components/calculated-fields/components/related-entities-aggregation-configuration/related-entities-aggregation-component.component.html b/ui-ngx/src/app/modules/home/components/calculated-fields/components/related-entities-aggregation-configuration/related-entities-aggregation-component.component.html index d6b02b3bbe..af3139801b 100644 --- a/ui-ngx/src/app/modules/home/components/calculated-fields/components/related-entities-aggregation-configuration/related-entities-aggregation-component.component.html +++ b/ui-ngx/src/app/modules/home/components/calculated-fields/components/related-entities-aggregation-configuration/related-entities-aggregation-component.component.html @@ -55,7 +55,7 @@ {{ 'calculated-fields.metrics.metrics' | translate }} Observable; readonly ScriptLanguage = ScriptLanguage; readonly CalculatedFieldType = CalculatedFieldType; @@ -95,7 +96,7 @@ export class RelatedEntitiesAggregationComponentComponent implements ControlValu }); arguments$ = this.relatedAggregationConfiguration.get('arguments').valueChanges.pipe( - map(argumentsObj => argumentsObj) + map(argumentsObj => Object.keys(argumentsObj)) ); argumentsEditorCompleter$ = this.relatedAggregationConfiguration.get('arguments').valueChanges.pipe( diff --git a/ui-ngx/src/app/shared/models/alarm-rule.models.ts b/ui-ngx/src/app/shared/models/alarm-rule.models.ts index 35b6690644..5170c916ff 100644 --- a/ui-ngx/src/app/shared/models/alarm-rule.models.ts +++ b/ui-ngx/src/app/shared/models/alarm-rule.models.ts @@ -27,6 +27,8 @@ import { StringOperation } from "@shared/models/query/query.models"; import { EntityType } from "@shared/models/entity-type.models"; +import { Observable } from "rxjs"; +import { CalculatedField } from "@shared/models/calculated-field.models"; export enum AlarmRuleScheduleType { ANY_TIME = 'ANY_TIME', @@ -161,3 +163,5 @@ export interface AlarmRuleFilterConfig { export const alarmRuleDefaultScript = '// Sample expression for an alarm rule: triggers when temperature is above 20 degree\n' + 'return temperature > 20;' + +export type AlarmRuleTestScriptFn = (calculatedField: CalculatedField, expression: string, argumentsObj?: Record, closeAllOnSave?: boolean) => Observable; diff --git a/ui-ngx/src/app/shared/models/calculated-field.models.ts b/ui-ngx/src/app/shared/models/calculated-field.models.ts index 5727bfe063..a070fe23c2 100644 --- a/ui-ngx/src/app/shared/models/calculated-field.models.ts +++ b/ui-ngx/src/app/shared/models/calculated-field.models.ts @@ -496,7 +496,7 @@ export interface CalculatedFieldArgumentValue extends CalculatedFieldArgument { entityName?: string; } -export type CalculatedFieldTestScriptFn = (calculatedField: CalculatedField, argumentsObj?: Record, closeAllOnSave?: boolean) => Observable; +export type CalculatedFieldTestScriptFn = (calculatedField: CalculatedField, argumentsObj?: Record, closeAllOnSave?: boolean, expression?: string) => Observable; export interface CalculatedFieldTestScriptInputParams { arguments: CalculatedFieldEventArguments; From 2fe7d34331b2b8594c325657e053d2f6d620674b Mon Sep 17 00:00:00 2001 From: ArtemDzhereleiko Date: Wed, 26 Nov 2025 15:58:51 +0200 Subject: [PATCH 41/54] UI: Fixed progress filling and add label for entity aliase --- .../widget/lib/cards/api-usage-widget.component.ts | 10 +++++++--- .../cards/api-usage-widget-settings.component.html | 1 + .../common/alias/entity-alias-select.component.ts | 1 + 3 files changed, 9 insertions(+), 3 deletions(-) diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/cards/api-usage-widget.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/cards/api-usage-widget.component.ts index b7f48d41f6..89937cda65 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/cards/api-usage-widget.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/cards/api-usage-widget.component.ts @@ -91,11 +91,11 @@ export class ApiUsageWidgetComponent implements OnInit, OnDestroy { onDataUpdated: (subscription) => { const data = formattedDataFormDatasourceData(subscription.data); this.apiUsages.forEach(key => { - const progress = data[0][key.maxLimit.key] !== 0 ? Math.min(100, ((data[0][key.current.key] / data[0][key.maxLimit.key]) * 100)) : 0; + const progress = (this.isFiniteNumber(data[0][key.maxLimit.key]) && data[0][key.maxLimit.key] !== 0) ? Math.min(100, ((data[0][key.current.key] / data[0][key.maxLimit.key]) * 100)) : 0; key.progress = isFinite(progress) ? progress : 0; key.status.value = data[0][key.status.key] ? data[0][key.status.key].toLowerCase() : 'enabled'; - key.maxLimit.value = isFinite(data[0][key.maxLimit.key]) && data[0][key.maxLimit.key] !== 0 && data[0][key.maxLimit.key] !== '' ? this.toShortNumber(data[0][key.maxLimit.key]) : '∞'; - key.current.value = isFinite(data[0][key.current.key]) ? this.toShortNumber(data[0][key.current.key]) : 0; + key.maxLimit.value = this.isFiniteNumber(data[0][key.maxLimit.key]) && data[0][key.maxLimit.key] !== 0 ? this.toShortNumber(data[0][key.maxLimit.key]) : '∞'; + key.current.value = this.isFiniteNumber(data[0][key.current.key]) ? this.toShortNumber(data[0][key.current.key]) : 0; }); this.cd.detectChanges(); } @@ -114,6 +114,10 @@ export class ApiUsageWidgetComponent implements OnInit, OnDestroy { this.padding = this.settings.background.overlay.enabled ? undefined : this.settings.padding; } + private isFiniteNumber(value: any): boolean { + return typeof value === 'number' && isFinite(value); + } + updateState($event: MouseEvent, stateName: string) { $event?.preventDefault(); if (stateName?.length) { diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/cards/api-usage-widget-settings.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/settings/cards/api-usage-widget-settings.component.html index e96a654c44..f4e366cc6a 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/cards/api-usage-widget-settings.component.html +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/cards/api-usage-widget-settings.component.html @@ -20,6 +20,7 @@
widget-config.datasource
Date: Wed, 26 Nov 2025 17:22:19 +0200 Subject: [PATCH 42/54] Improve tests --- .../notification/NotificationApiTest.java | 4 +-- .../notification/NotificationRuleApiTest.java | 29 +++++++++++++++---- 2 files changed, 25 insertions(+), 8 deletions(-) diff --git a/application/src/test/java/org/thingsboard/server/service/notification/NotificationApiTest.java b/application/src/test/java/org/thingsboard/server/service/notification/NotificationApiTest.java index 2f5f1572b6..43a6832037 100644 --- a/application/src/test/java/org/thingsboard/server/service/notification/NotificationApiTest.java +++ b/application/src/test/java/org/thingsboard/server/service/notification/NotificationApiTest.java @@ -24,8 +24,8 @@ import org.junit.Before; import org.junit.Test; import org.mockito.ArgumentCaptor; import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.boot.test.mock.mockito.MockBean; import org.springframework.http.HttpEntity; +import org.springframework.test.context.bean.override.mockito.MockitoBean; import org.springframework.test.web.servlet.ResultActions; import org.springframework.web.client.RestTemplate; import org.thingsboard.common.util.JacksonUtil; @@ -125,7 +125,7 @@ public class NotificationApiTest extends AbstractNotificationApiTest { private NotificationCenter notificationCenter; @Autowired private MicrosoftTeamsNotificationChannel microsoftTeamsNotificationChannel; - @MockBean + @MockitoBean private FirebaseService firebaseService; private static final String TEST_MOBILE_TOKEN = "tenantFcmToken"; diff --git a/application/src/test/java/org/thingsboard/server/service/notification/NotificationRuleApiTest.java b/application/src/test/java/org/thingsboard/server/service/notification/NotificationRuleApiTest.java index ff49a68d66..3f7adec118 100644 --- a/application/src/test/java/org/thingsboard/server/service/notification/NotificationRuleApiTest.java +++ b/application/src/test/java/org/thingsboard/server/service/notification/NotificationRuleApiTest.java @@ -21,11 +21,13 @@ import com.fasterxml.jackson.databind.node.ObjectNode; import org.junit.Before; import org.junit.Test; import org.junit.function.ThrowingRunnable; +import org.mockito.MockedStatic; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.data.util.Pair; import org.springframework.test.context.TestPropertySource; import org.springframework.test.context.bean.override.mockito.MockitoSpyBean; import org.thingsboard.common.util.JacksonUtil; +import org.thingsboard.common.util.SystemUtil; import org.thingsboard.server.cache.limits.RateLimitService; import org.thingsboard.server.common.data.AttributeScope; import org.thingsboard.server.common.data.Device; @@ -108,6 +110,7 @@ import java.lang.reflect.Method; import java.util.HashMap; import java.util.List; import java.util.Map; +import java.util.Optional; import java.util.Set; import java.util.UUID; import java.util.concurrent.Callable; @@ -120,6 +123,7 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.offset; import static org.assertj.core.api.InstanceOfAssertFactories.type; import static org.awaitility.Awaitility.await; +import static org.mockito.Mockito.mockStatic; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; import static org.thingsboard.server.common.data.notification.rule.trigger.config.AlarmAssignmentNotificationRuleTriggerConfig.Action.ASSIGNED; import static org.thingsboard.server.common.data.notification.rule.trigger.config.AlarmAssignmentNotificationRuleTriggerConfig.Action.UNASSIGNED; @@ -796,9 +800,14 @@ public class NotificationRuleApiTest extends AbstractNotificationApiTest { createNotificationRule(triggerConfig, "Warning: ${resource} shortage", "${resource} shortage", createNotificationTarget(tenantAdminUserId).getId()); loginTenantAdmin(); - Method method = DefaultSystemInfoService.class.getDeclaredMethod("saveCurrentMonolithSystemInfo"); - method.setAccessible(true); - method.invoke(systemInfoService); + // Mock SystemUtil to return 15% memory usage (exceeds 1% threshold) + try (MockedStatic mockedSystemUtil = mockStatic(SystemUtil.class)) { + mockedSystemUtil.when(SystemUtil::getMemoryUsage).thenReturn(Optional.of(15)); + + Method method = DefaultSystemInfoService.class.getDeclaredMethod("saveCurrentMonolithSystemInfo"); + method.setAccessible(true); + method.invoke(systemInfoService); + } await().atMost(10, TimeUnit.SECONDS).until(() -> getMyNotifications(false, 100).size() == 1); Notification notification = getMyNotifications(false, 100).get(0); @@ -853,9 +862,17 @@ public class NotificationRuleApiTest extends AbstractNotificationApiTest { NotificationRule rule = createNotificationRule(triggerConfig, "Warning: ${resource} shortage", "${resource} shortage", createNotificationTarget(tenantAdminUserId).getId()); loginTenantAdmin(); - Method method = DefaultSystemInfoService.class.getDeclaredMethod("saveCurrentMonolithSystemInfo"); - method.setAccessible(true); - method.invoke(systemInfoService); + // Mock SystemUtil to return 15% usages (not exceeds 100% threshold) + Method method; + try (MockedStatic mockedSystemUtil = mockStatic(SystemUtil.class)) { + mockedSystemUtil.when(SystemUtil::getMemoryUsage).thenReturn(Optional.of(15)); + mockedSystemUtil.when(SystemUtil::getCpuUsage).thenReturn(Optional.of(15)); + mockedSystemUtil.when(SystemUtil::getDiscSpaceUsage).thenReturn(Optional.of(15L)); + + method = DefaultSystemInfoService.class.getDeclaredMethod("saveCurrentMonolithSystemInfo"); + method.setAccessible(true); + method.invoke(systemInfoService); + } TimeUnit.SECONDS.sleep(5); assertThat(getMyNotifications(false, 100)).size().isZero(); From e005462fcd64307cb829028660ee175eff0550b0 Mon Sep 17 00:00:00 2001 From: Andrii Landiak Date: Wed, 26 Nov 2025 17:27:56 +0200 Subject: [PATCH 43/54] Make test more logical --- .../service/notification/NotificationRuleApiTest.java | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/application/src/test/java/org/thingsboard/server/service/notification/NotificationRuleApiTest.java b/application/src/test/java/org/thingsboard/server/service/notification/NotificationRuleApiTest.java index 3f7adec118..569bed718e 100644 --- a/application/src/test/java/org/thingsboard/server/service/notification/NotificationRuleApiTest.java +++ b/application/src/test/java/org/thingsboard/server/service/notification/NotificationRuleApiTest.java @@ -855,19 +855,19 @@ public class NotificationRuleApiTest extends AbstractNotificationApiTest { public void testNotificationsResourcesShortage_whenThresholdChangeToMatchingFilter_thenSendNotification() throws Exception { loginSysAdmin(); ResourcesShortageNotificationRuleTriggerConfig triggerConfig = ResourcesShortageNotificationRuleTriggerConfig.builder() - .ramThreshold(1f) - .cpuThreshold(1f) - .storageThreshold(1f) + .ramThreshold(0.99f) + .cpuThreshold(0.99f) + .storageThreshold(0.99f) .build(); NotificationRule rule = createNotificationRule(triggerConfig, "Warning: ${resource} shortage", "${resource} shortage", createNotificationTarget(tenantAdminUserId).getId()); loginTenantAdmin(); - // Mock SystemUtil to return 15% usages (not exceeds 100% threshold) + // Mock SystemUtil to return 15% usages (not exceeds 99% threshold) Method method; try (MockedStatic mockedSystemUtil = mockStatic(SystemUtil.class)) { mockedSystemUtil.when(SystemUtil::getMemoryUsage).thenReturn(Optional.of(15)); mockedSystemUtil.when(SystemUtil::getCpuUsage).thenReturn(Optional.of(15)); - mockedSystemUtil.when(SystemUtil::getDiscSpaceUsage).thenReturn(Optional.of(15L)); + mockedSystemUtil.when(SystemUtil::getDiscSpaceUsage).thenReturn(Optional.of(15)); method = DefaultSystemInfoService.class.getDeclaredMethod("saveCurrentMonolithSystemInfo"); method.setAccessible(true); From c35288af5fadbbbb779dc1b0053db56d22f86d45 Mon Sep 17 00:00:00 2001 From: Nikita Mazurenko Date: Wed, 26 Nov 2025 19:27:09 +0200 Subject: [PATCH 44/54] Refactor deleteUserAndPushEntityDeletedEventToRuleEngine to add additional metadata --- .../rpc/processor/user/BaseUserProcessor.java | 34 ++++++++++++++++++- .../rpc/processor/user/UserEdgeProcessor.java | 11 ------ 2 files changed, 33 insertions(+), 12 deletions(-) diff --git a/application/src/main/java/org/thingsboard/server/service/edge/rpc/processor/user/BaseUserProcessor.java b/application/src/main/java/org/thingsboard/server/service/edge/rpc/processor/user/BaseUserProcessor.java index a742d83ada..fc24588f5b 100644 --- a/application/src/main/java/org/thingsboard/server/service/edge/rpc/processor/user/BaseUserProcessor.java +++ b/application/src/main/java/org/thingsboard/server/service/edge/rpc/processor/user/BaseUserProcessor.java @@ -21,10 +21,14 @@ import org.springframework.data.util.Pair; import org.thingsboard.common.util.JacksonUtil; import org.thingsboard.server.common.data.StringUtils; import org.thingsboard.server.common.data.User; +import org.thingsboard.server.common.data.edge.Edge; +import org.thingsboard.server.common.data.exception.ThingsboardException; import org.thingsboard.server.common.data.id.CustomerId; import org.thingsboard.server.common.data.id.TenantId; import org.thingsboard.server.common.data.id.UserId; +import org.thingsboard.server.common.data.msg.TbMsgType; import org.thingsboard.server.common.data.security.UserCredentials; +import org.thingsboard.server.common.msg.TbMsgMetaData; import org.thingsboard.server.dao.service.DataValidator; import org.thingsboard.server.gen.edge.v1.UserCredentialsUpdateMsg; import org.thingsboard.server.gen.edge.v1.UserUpdateMsg; @@ -82,7 +86,23 @@ public abstract class BaseUserProcessor extends BaseEdgeProcessor { return Pair.of(isCreated, userEmailUpdated); } - protected User deleteUser(TenantId tenantId, UserId userId) { + protected void deleteUserAndPushEntityDeletedEventToRuleEngine(TenantId tenantId, UserId userId) { + deleteUserAndPushEntityDeletedEventToRuleEngine(tenantId, userId, null); + } + + protected void deleteUserAndPushEntityDeletedEventToRuleEngine(TenantId tenantId, UserId userId, Edge edge) { + User removedUser = deleteUser(tenantId, userId); + if (removedUser == null) { + return; + } + CustomerId userCustomerId = removedUser.getCustomerId(); + String userAsString = JacksonUtil.toString(removedUser); + TbMsgMetaData msgMetaData = edge == null ? new TbMsgMetaData() : getEdgeActionTbMsgMetaData(edge, userCustomerId); + addRemovedUserMetadata(msgMetaData, removedUser); + pushEntityEventToRuleEngine(tenantId, userId, userCustomerId, TbMsgType.ENTITY_DELETED, userAsString, msgMetaData); + } + + private User deleteUser(TenantId tenantId, UserId userId) { User userById = edgeCtx.getUserService().findUserById(tenantId, userId); if (userById == null) { log.trace("[{}] User with id {} does not exist", tenantId, userId); @@ -118,6 +138,18 @@ public abstract class BaseUserProcessor extends BaseEdgeProcessor { } } + private void addRemovedUserMetadata(TbMsgMetaData metaData, User removedUser) { + metaData.putValue("userId", removedUser.getId().toString()); + metaData.putValue("userName", removedUser.getName()); + metaData.putValue("userEmail", removedUser.getEmail()); + if (removedUser.getFirstName() != null) { + metaData.putValue("userFirstName", removedUser.getFirstName()); + } + if (removedUser.getLastName() != null) { + metaData.putValue("userLastName", removedUser.getLastName()); + } + } + protected abstract void setCustomerId(TenantId tenantId, CustomerId customerId, User user, UserUpdateMsg userUpdateMsg); } diff --git a/application/src/main/java/org/thingsboard/server/service/edge/rpc/processor/user/UserEdgeProcessor.java b/application/src/main/java/org/thingsboard/server/service/edge/rpc/processor/user/UserEdgeProcessor.java index 968d573618..3b2d76356b 100644 --- a/application/src/main/java/org/thingsboard/server/service/edge/rpc/processor/user/UserEdgeProcessor.java +++ b/application/src/main/java/org/thingsboard/server/service/edge/rpc/processor/user/UserEdgeProcessor.java @@ -120,17 +120,6 @@ public class UserEdgeProcessor extends BaseUserProcessor implements UserProcesso } } - private void deleteUserAndPushEntityDeletedEventToRuleEngine(TenantId tenantId, UserId userId, Edge edge) { - User removedUser = deleteUser(tenantId, userId); - if (removedUser == null) { - return; - } - CustomerId userCustomerId = removedUser.getCustomerId(); - String userAsString = JacksonUtil.toString(removedUser); - TbMsgMetaData msgMetaData = getEdgeActionTbMsgMetaData(edge, userCustomerId); - pushEntityEventToRuleEngine(tenantId, userId, userCustomerId, TbMsgType.ENTITY_DELETED, userAsString, msgMetaData); - } - @Override public DownlinkMsg convertEdgeEventToDownlink(EdgeEvent edgeEvent, EdgeVersion edgeVersion) { UserId userId = new UserId(edgeEvent.getEntityId()); From 09d83b2df35a6924dde70745ef57c938c61f2951 Mon Sep 17 00:00:00 2001 From: ArtemDzhereleiko Date: Thu, 27 Nov 2025 10:06:54 +0200 Subject: [PATCH 45/54] UI: Fixed Alarm Rule validation --- .../alarm-rule-dialog.component.ts | 16 +------------ .../alarm-rules/alarm-rules-table-config.ts | 2 +- .../cf-alarm-rule-condition.component.ts | 10 ++++---- .../alarm-rules/cf-alarm-rule.component.ts | 23 +++++++++---------- .../create-cf-alarm-rules.component.ts | 6 ++--- 5 files changed, 21 insertions(+), 36 deletions(-) diff --git a/ui-ngx/src/app/modules/home/components/alarm-rules/alarm-rule-dialog.component.ts b/ui-ngx/src/app/modules/home/components/alarm-rules/alarm-rule-dialog.component.ts index 2209cbafba..f45e205246 100644 --- a/ui-ngx/src/app/modules/home/components/alarm-rules/alarm-rule-dialog.component.ts +++ b/ui-ngx/src/app/modules/home/components/alarm-rules/alarm-rule-dialog.component.ts @@ -67,7 +67,7 @@ export class AlarmRuleDialogComponent extends DialogComponent(null, Validators.required), - id: ['', Validators.required], + id: [null as null | string, Validators.required], }), configuration: this.fb.group({ arguments: this.fb.control({}), @@ -101,7 +101,6 @@ export class AlarmRuleDialogComponent extends DialogComponent { - if (loading) { - this.fieldFormGroup.disable({emitEvent: false}); - } else { - this.fieldFormGroup.enable({emitEvent: false}); - if (this.data.isDirty) { - this.fieldFormGroup.markAsDirty(); - } - } - }); - } - onTestScript(expression: string): Observable { const calculatedFieldId = this.data.value?.id?.id; if (calculatedFieldId) { diff --git a/ui-ngx/src/app/modules/home/components/alarm-rules/alarm-rules-table-config.ts b/ui-ngx/src/app/modules/home/components/alarm-rules/alarm-rules-table-config.ts index 6fa6515663..8593b28956 100644 --- a/ui-ngx/src/app/modules/home/components/alarm-rules/alarm-rules-table-config.ts +++ b/ui-ngx/src/app/modules/home/components/alarm-rules/alarm-rules-table-config.ts @@ -152,7 +152,7 @@ export class AlarmRulesTableConfig extends EntityTableConfig { this.cellActionDescriptors.push( { - name: this.translate.instant('notification.copy-template'), + name: this.translate.instant('alarm-rule.copy'), icon: 'content_copy', isEnabled: () => true, onAction: ($event, entity) => this.copyCalculatedField(entity) diff --git a/ui-ngx/src/app/modules/home/components/alarm-rules/cf-alarm-rule-condition.component.ts b/ui-ngx/src/app/modules/home/components/alarm-rules/cf-alarm-rule-condition.component.ts index 2a0b7a7810..543b0a86dc 100644 --- a/ui-ngx/src/app/modules/home/components/alarm-rules/cf-alarm-rule-condition.component.ts +++ b/ui-ngx/src/app/modules/home/components/alarm-rules/cf-alarm-rule-condition.component.ts @@ -20,7 +20,7 @@ import { FormBuilder, NG_VALIDATORS, NG_VALUE_ACCESSOR, - UntypedFormControl, + ValidationErrors, Validator } from '@angular/forms'; import { MatDialog } from '@angular/material/dialog'; @@ -130,10 +130,10 @@ export class CfAlarmRuleConditionComponent implements ControlValueAccessor, Vali } public conditionSet() { - return this.modelValue && (this.modelValue.expression?.expression || this.modelValue.expression?.filters) || !this.required; + return this.modelValue && (this.modelValue.expression?.expression || this.modelValue.expression?.filters); } - public validate(c: UntypedFormControl) { + public validate(): ValidationErrors | null { return this.conditionSet() ? null : { alarmRuleCondition: { valid: false, @@ -166,11 +166,11 @@ export class CfAlarmRuleConditionComponent implements ControlValueAccessor, Vali private updateConditionInfo() { this.alarmRuleConditionFormGroup.patchValue( - { + this.modelValue ? { type: this.modelValue?.type, expression: this.modelValue?.expression, schedule: this.modelValue?.schedule, - }, {emitEvent: false} + } : null, {emitEvent: false} ); this.updateScheduleText(); this.updateSpecText(); diff --git a/ui-ngx/src/app/modules/home/components/alarm-rules/cf-alarm-rule.component.ts b/ui-ngx/src/app/modules/home/components/alarm-rules/cf-alarm-rule.component.ts index 5db525074d..cd6e06f293 100644 --- a/ui-ngx/src/app/modules/home/components/alarm-rules/cf-alarm-rule.component.ts +++ b/ui-ngx/src/app/modules/home/components/alarm-rules/cf-alarm-rule.component.ts @@ -20,8 +20,9 @@ import { FormBuilder, NG_VALIDATORS, NG_VALUE_ACCESSOR, - UntypedFormControl, - Validator + ValidationErrors, + Validator, + Validators } from '@angular/forms'; import { MatDialog } from '@angular/material/dialog'; import { isDefinedAndNotNull } from '@core/utils'; @@ -76,7 +77,7 @@ export class CfAlarmRuleComponent implements ControlValueAccessor, OnInit, Valid private modelValue: AlarmRule; alarmRuleFormGroup = this.fb.group({ - condition: this.fb.control(null), + condition: this.fb.control(null, Validators.required), alarmDetails: [null], dashboardId: [null] }); @@ -113,14 +114,12 @@ export class CfAlarmRuleComponent implements ControlValueAccessor, OnInit, Valid } writeValue(value: AlarmRule): void { - if (value) { - this.modelValue = value; - const model = this.modelValue ? { - ...this.modelValue, - dashboardId: this.modelValue.dashboardId?.id - } : null; - this.alarmRuleFormGroup.patchValue(model, {emitEvent: false}); - } + this.modelValue = value; + const model = this.modelValue ? { + ...this.modelValue, + dashboardId: this.modelValue.dashboardId?.id + } : null; + this.alarmRuleFormGroup.patchValue(model, {emitEvent: false}); } public openEditDetailsDialog($event: Event) { @@ -142,7 +141,7 @@ export class CfAlarmRuleComponent implements ControlValueAccessor, OnInit, Valid }); } - public validate(c: UntypedFormControl) { + public validate(): ValidationErrors | null { return (!this.required && !this.modelValue || this.alarmRuleFormGroup.valid) ? null : { alarmRule: { valid: false, diff --git a/ui-ngx/src/app/modules/home/components/alarm-rules/create-cf-alarm-rules.component.ts b/ui-ngx/src/app/modules/home/components/alarm-rules/create-cf-alarm-rules.component.ts index 2197d6d14c..8959d709fa 100644 --- a/ui-ngx/src/app/modules/home/components/alarm-rules/create-cf-alarm-rules.component.ts +++ b/ui-ngx/src/app/modules/home/components/alarm-rules/create-cf-alarm-rules.component.ts @@ -23,7 +23,7 @@ import { NG_VALIDATORS, NG_VALUE_ACCESSOR, UntypedFormArray, - UntypedFormControl, + ValidationErrors, Validator, Validators } from '@angular/forms'; @@ -159,8 +159,8 @@ export class CreateCfAlarmRulesComponent implements ControlValueAccessor, Valida return null; } - public validate(c: UntypedFormControl) { - return this.createAlarmRulesFormArray().length ? null : { + public validate(): ValidationErrors | null { + return this.createAlarmRulesFormGroup.valid && this.createAlarmRulesFormArray().length > 0 ? null : { createAlarmRules: { valid: false, }, From fc3e7d69d8c88f82cfc3df10a0de2cc0d03eeaf0 Mon Sep 17 00:00:00 2001 From: Maksym Tsymbarov Date: Thu, 27 Nov 2025 12:20:43 +0200 Subject: [PATCH 46/54] fixed displaying of disabled data --- .../home/components/widget/widget-config.component.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/ui-ngx/src/app/modules/home/components/widget/widget-config.component.ts b/ui-ngx/src/app/modules/home/components/widget/widget-config.component.ts index c23619f5c0..87951d263b 100644 --- a/ui-ngx/src/app/modules/home/components/widget/widget-config.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/widget-config.component.ts @@ -934,15 +934,15 @@ export class WidgetConfigComponent extends PageComponent implements OnInit, OnDe entityLabelColumnTitle } = this.modelValue.config.settings; const displayEntitiesArray = []; - if (isDefined(displayEntityName)) { + if (displayEntityName) { const displayName = entityNameColumnTitle ? entityNameColumnTitle : 'entityName'; displayEntitiesArray.push({name: displayName, label: displayName}); } - if (isDefined(displayEntityLabel)) { + if (displayEntityLabel) { const displayLabel = entityLabelColumnTitle ? entityLabelColumnTitle : 'entityLabel'; displayEntitiesArray.push({name: displayLabel, label: displayLabel}); } - if (isDefined(displayEntityType)) { + if (displayEntityType) { displayEntitiesArray.push({name: 'entityType', label: 'entityType'}); } configuredColumns.push(...displayEntitiesArray, ...this.keysToCellClickColumns(this.modelValue.config.datasources[0].dataKeys)); From 7ec59138e8a227d4fbf5c5f794ee5b664a3edfb5 Mon Sep 17 00:00:00 2001 From: Ekaterina Chantsova Date: Thu, 27 Nov 2025 13:21:16 +0200 Subject: [PATCH 47/54] Dashboard timewindow: remove unused parameters on creating/editing dashboard --- ui-ngx/src/app/core/services/dashboard-utils.service.ts | 5 ++--- .../home/components/dashboard/dashboard.component.ts | 7 +++---- ui-ngx/src/app/shared/models/time/time.models.ts | 5 +++-- 3 files changed, 8 insertions(+), 9 deletions(-) diff --git a/ui-ngx/src/app/core/services/dashboard-utils.service.ts b/ui-ngx/src/app/core/services/dashboard-utils.service.ts index b630811f93..a23dd04337 100644 --- a/ui-ngx/src/app/core/services/dashboard-utils.service.ts +++ b/ui-ngx/src/app/core/services/dashboard-utils.service.ts @@ -168,9 +168,8 @@ export class DashboardUtilsService { dashboard.configuration.filters = {}; } - if (isUndefined(dashboard.configuration.timewindow)) { - dashboard.configuration.timewindow = this.timeService.defaultTimewindow(true); - } + dashboard.configuration.timewindow = initModelFromDefaultTimewindow(dashboard.configuration.timewindow, + false, false, this.timeService, true, true); if (isUndefined(dashboard.configuration.settings)) { dashboard.configuration.settings = {}; dashboard.configuration.settings.stateControllerId = 'entity'; diff --git a/ui-ngx/src/app/modules/home/components/dashboard/dashboard.component.ts b/ui-ngx/src/app/modules/home/components/dashboard/dashboard.component.ts index 91888d9b5a..75a229c4a1 100644 --- a/ui-ngx/src/app/modules/home/components/dashboard/dashboard.component.ts +++ b/ui-ngx/src/app/modules/home/components/dashboard/dashboard.component.ts @@ -34,7 +34,7 @@ import { AppState } from '@core/core.state'; import { PageComponent } from '@shared/components/page.component'; import { AuthUser } from '@shared/models/user.model'; import { getCurrentAuthUser } from '@core/auth/auth.selectors'; -import { Timewindow, toHistoryTimewindow } from '@shared/models/time/time.models'; +import { initModelFromDefaultTimewindow, Timewindow, toHistoryTimewindow } from '@shared/models/time/time.models'; import { TimeService } from '@core/services/time.service'; import { GridsterComponent, GridsterConfig, GridType } from 'angular-gridster2'; import { @@ -223,9 +223,8 @@ export class DashboardComponent extends PageComponent implements IDashboardCompo ngOnInit(): void { this.dashboardWidgets.parentDashboard = this.parentDashboard; this.dashboardWidgets.popoverComponent = this.popoverComponent; - if (!this.dashboardTimewindow) { - this.dashboardTimewindow = this.timeService.defaultTimewindow(true); - } + this.dashboardTimewindow = initModelFromDefaultTimewindow(this.dashboardTimewindow, + false, false, this.timeService, true, true); this.gridsterOpts = { gridType: this.gridType || GridType.ScrollVertical, keepFixedHeightInMobile: true, diff --git a/ui-ngx/src/app/shared/models/time/time.models.ts b/ui-ngx/src/app/shared/models/time/time.models.ts index 646e7bdc64..1046feb2ca 100644 --- a/ui-ngx/src/app/shared/models/time/time.models.ts +++ b/ui-ngx/src/app/shared/models/time/time.models.ts @@ -315,8 +315,9 @@ const getTimewindowType = (timewindow: Timewindow): TimewindowType => { }; export const initModelFromDefaultTimewindow = (value: Timewindow, quickIntervalOnly: boolean, - historyOnly: boolean, timeService: TimeService, hasAggregation: boolean): Timewindow => { - const model = defaultTimewindow(timeService); + historyOnly: boolean, timeService: TimeService, hasAggregation: boolean, + isDashboard = false): Timewindow => { + const model = defaultTimewindow(timeService, isDashboard); if (value) { if (value.allowedAggTypes?.length) { model.allowedAggTypes = value.allowedAggTypes; From f9393ba52cb20b34d150751236701d678844868f Mon Sep 17 00:00:00 2001 From: Vladyslav_Prykhodko Date: Thu, 27 Nov 2025 15:12:29 +0200 Subject: [PATCH 48/54] UI: Redesign login page OAuth2 providers --- .../oauth2/clients/client.component.html | 14 ++++--- .../domains/domain-table-config.resolver.ts | 4 +- .../login/pages/login/login.component.html | 40 +++++++++++++------ .../login/pages/login/login.component.scss | 36 +++++++++++++---- 4 files changed, 68 insertions(+), 26 deletions(-) diff --git a/ui-ngx/src/app/modules/home/pages/admin/oauth2/clients/client.component.html b/ui-ngx/src/app/modules/home/pages/admin/oauth2/clients/client.component.html index 60a837c583..37a5c3b464 100644 --- a/ui-ngx/src/app/modules/home/pages/admin/oauth2/clients/client.component.html +++ b/ui-ngx/src/app/modules/home/pages/admin/oauth2/clients/client.component.html @@ -136,7 +136,7 @@
-
+
admin.oauth2.login-button-label - - admin.oauth2.login-button-icon - - +
+
admin.oauth2.login-button-icon
+ + +
diff --git a/ui-ngx/src/app/modules/home/pages/admin/oauth2/domains/domain-table-config.resolver.ts b/ui-ngx/src/app/modules/home/pages/admin/oauth2/domains/domain-table-config.resolver.ts index 4b3c57f942..aa3f3fc7dc 100644 --- a/ui-ngx/src/app/modules/home/pages/admin/oauth2/domains/domain-table-config.resolver.ts +++ b/ui-ngx/src/app/modules/home/pages/admin/oauth2/domains/domain-table-config.resolver.ts @@ -85,8 +85,8 @@ export class DomainTableConfigResolver { this.config.loadEntity = id => this.domainService.getDomainInfoById(id.id); this.config.saveEntity = (domain, originalDomain) => { const clientsIds = domain.oauth2ClientInfos as Array || []; - const shouldUpdateClients = domain.id && !isEqual(domain.oauth2ClientInfos?.sort(), - originalDomain.oauth2ClientInfos?.map(info => info.id ? info.id.id : info).sort()); + const shouldUpdateClients = domain.id && !isEqual(domain.oauth2ClientInfos, + originalDomain.oauth2ClientInfos?.map(info => info.id ? info.id.id : info)); delete domain.oauth2ClientInfos; return this.domainService.saveDomain(domain, domain.id ? null : clientsIds).pipe( diff --git a/ui-ngx/src/app/modules/login/pages/login/login.component.html b/ui-ngx/src/app/modules/login/pages/login/login.component.html index ad95027958..94350c80f0 100644 --- a/ui-ngx/src/app/modules/login/pages/login/login.component.html +++ b/ui-ngx/src/app/modules/login/pages/login/login.component.html @@ -28,19 +28,35 @@
-
- - - -
-
-
{{ "login.or" | translate | uppercase }}
-
+ @if(oauth2Clients?.length) { +
+ @if(oauth2Clients.length === 1) { + + } @else { + + } +
+
+
{{ "login.or" | translate | uppercase }}
+
+
-
+ } login.username diff --git a/ui-ngx/src/app/modules/login/pages/login/login.component.scss b/ui-ngx/src/app/modules/login/pages/login/login.component.scss index 2784335d7b..8859aed7e4 100644 --- a/ui-ngx/src/app/modules/login/pages/login/login.component.scss +++ b/ui-ngx/src/app/modules/login/pages/login/login.component.scss @@ -59,8 +59,11 @@ } .text { - padding-right: 10px; - padding-left: 10px; + padding-right: 8px; + padding-left: 8px; + font-size: 16px; + line-height: 24px; + letter-spacing: 0.15px; } } @@ -72,14 +75,33 @@ a.login-with-button { color: rgba(black, 0.87); background-color: map-get($tb-dark-theme-background, raised-button); + } + + .login-button-container { + a.login-with-button { + --mdc-outlined-button-container-shape: 8px; + max-width: 123px; + min-width: 60px; + flex-grow: 1; + flex-basis: 120px; + + .tb-mat-20 { + margin: 0; + vertical-align: text-top; + } + } - &:hover { - border-bottom: 0; + &:has(> :nth-child(2):last-child) { + a.login-with-button { + max-width: 180px; + flex-basis: 180px; + } } - .icon { - height: 20px; - width: 20px; + &:has(> :nth-child(3):last-child) { + a.login-with-button { + max-width: 180px; + } } } } From a164e074b373b7af385cc8087f33962818b2a4c3 Mon Sep 17 00:00:00 2001 From: IrynaMatveieva Date: Thu, 27 Nov 2025 16:18:32 +0200 Subject: [PATCH 49/54] get all entity infos instead of getting for each separately --- ...CalculatedFieldEntityMessageProcessor.java | 4 ++-- ...titiesAggregationCalculatedFieldState.java | 14 +++++++------- ...EntityAggregationCalculatedFieldState.java | 19 ++++++++++++------- .../server/common/data/DataConstants.java | 2 +- 4 files changed, 22 insertions(+), 17 deletions(-) diff --git a/application/src/main/java/org/thingsboard/server/actors/calculatedField/CalculatedFieldEntityMessageProcessor.java b/application/src/main/java/org/thingsboard/server/actors/calculatedField/CalculatedFieldEntityMessageProcessor.java index 27206dc614..9fe0698f88 100644 --- a/application/src/main/java/org/thingsboard/server/actors/calculatedField/CalculatedFieldEntityMessageProcessor.java +++ b/application/src/main/java/org/thingsboard/server/actors/calculatedField/CalculatedFieldEntityMessageProcessor.java @@ -70,7 +70,7 @@ import java.util.UUID; import java.util.concurrent.TimeUnit; import java.util.stream.Collectors; -import static org.thingsboard.server.common.data.DataConstants.CF_REEVALUATION_MSG; +import static org.thingsboard.server.common.data.DataConstants.REEVALUATION_MSG; import static org.thingsboard.server.utils.CalculatedFieldArgumentUtils.createStateByType; /** @@ -355,7 +355,7 @@ public class CalculatedFieldEntityMessageProcessor extends AbstractContextAwareM } if (state.isSizeOk()) { log.debug("[{}][{}] Reevaluating CF state", entityId, cfId); - processStateIfReady(state, null, ctx, Collections.singletonList(cfId), null, CF_REEVALUATION_MSG, msg.getCallback()); + processStateIfReady(state, null, ctx, Collections.singletonList(cfId), null, REEVALUATION_MSG, msg.getCallback()); } else { throw new RuntimeException(ctx.getSizeExceedsLimitMessage()); } diff --git a/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/aggregation/RelatedEntitiesAggregationCalculatedFieldState.java b/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/aggregation/RelatedEntitiesAggregationCalculatedFieldState.java index dfffcb3777..3b4092a885 100644 --- a/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/aggregation/RelatedEntitiesAggregationCalculatedFieldState.java +++ b/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/aggregation/RelatedEntitiesAggregationCalculatedFieldState.java @@ -257,14 +257,14 @@ public class RelatedEntitiesAggregationCalculatedFieldState extends BaseCalculat @Override public JsonNode getArgumentsJson() { + Map> inputs = prepareInputs(); + Map entityIdEntityInfos = entityService.fetchEntityInfos(ctx.getTenantId(), null, inputs.keySet()); List entitiesArguments = new ArrayList<>(); - prepareInputs().forEach((entityId, entityArguments) -> { - entityService.fetchEntityName(ctx.getTenantId(), entityId).ifPresent(entityName -> { - EntityInfo entityInfo = new EntityInfo(entityId, entityName); - JsonNode entityArgumentsJson = JacksonUtil.valueToTree(entityArguments.entrySet().stream() - .collect(Collectors.toMap(Entry::getKey, e -> e.getValue().jsonValue()))); - entitiesArguments.add(new EntityArgument(entityInfo, entityArgumentsJson)); - }); + inputs.forEach((entityId, entityArguments) -> { + EntityInfo entityInfo = entityIdEntityInfos.get(entityId); + JsonNode entityArgumentsJson = JacksonUtil.valueToTree(entityArguments.entrySet().stream() + .collect(Collectors.toMap(Entry::getKey, e -> e.getValue().jsonValue()))); + entitiesArguments.add(new EntityArgument(entityInfo, entityArgumentsJson)); }); return JacksonUtil.valueToTree(new RelatedEntitiesArgument(ArgumentEntryType.RELATED_ENTITIES, entitiesArguments)); } diff --git a/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/aggregation/single/EntityAggregationCalculatedFieldState.java b/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/aggregation/single/EntityAggregationCalculatedFieldState.java index dd67ec8123..17aa90f099 100644 --- a/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/aggregation/single/EntityAggregationCalculatedFieldState.java +++ b/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/aggregation/single/EntityAggregationCalculatedFieldState.java @@ -21,6 +21,7 @@ import com.fasterxml.jackson.databind.node.ArrayNode; import com.fasterxml.jackson.databind.node.ObjectNode; import com.google.common.util.concurrent.Futures; import com.google.common.util.concurrent.ListenableFuture; +import org.apache.commons.lang3.concurrent.LazyInitializer; import org.thingsboard.common.util.DebugModeUtil; import org.thingsboard.common.util.JacksonUtil; import org.thingsboard.script.api.tbel.TbUtils; @@ -63,7 +64,7 @@ public class EntityAggregationCalculatedFieldState extends BaseCalculatedFieldSt private long checkInterval; private Map metrics; - private final EntityAggregationDebugArgumentsTracker debugTracker = new EntityAggregationDebugArgumentsTracker(new HashMap<>()); + private EntityAggregationDebugArgumentsTracker debugTracker; private CalculatedFieldProcessingService cfProcessingService; @@ -98,11 +99,14 @@ public class EntityAggregationCalculatedFieldState extends BaseCalculatedFieldSt @Override public ListenableFuture performCalculation(Map updatedArgs, CalculatedFieldCtx ctx) throws Exception { - debugTracker.reset(); createIntervalIfNotExist(); long now = System.currentTimeMillis(); if (DebugModeUtil.isDebugFailuresAvailable(ctx.getCalculatedField())) { + LazyInitializer lazy = LazyInitializer.builder() + .setInitializer(() -> new EntityAggregationDebugArgumentsTracker(new HashMap<>())) + .get(); + debugTracker = lazy.get(); debugTracker.recordUpdatedArgs(updatedArgs, arguments); } @@ -285,7 +289,9 @@ public class EntityAggregationCalculatedFieldState extends BaseCalculatedFieldSt result.add(resultNode); if (DebugModeUtil.isDebugFailuresAvailable(ctx.getCalculatedField())) { - debugTracker.addInterval(interval); + if (debugTracker != null) { + debugTracker.addInterval(interval); + } } } }); @@ -294,6 +300,9 @@ public class EntityAggregationCalculatedFieldState extends BaseCalculatedFieldSt @Override public JsonNode getArgumentsJson() { + if (debugTracker == null) { + return null; + } EntityAggregationDebugArguments debugArguments = debugTracker.toDebugArguments(); return debugArguments == null ? null : JacksonUtil.valueToTree(debugArguments); } @@ -305,10 +314,6 @@ public class EntityAggregationCalculatedFieldState extends BaseCalculatedFieldSt record EntityAggregationDebugArgumentsTracker(Map> processedIntervals) { - public void reset() { - processedIntervals.clear(); - } - public void addInterval(AggIntervalEntry interval) { processedIntervals.computeIfAbsent(interval, k -> new HashMap<>()); } diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/DataConstants.java b/common/data/src/main/java/org/thingsboard/server/common/data/DataConstants.java index 7830461109..913b8170ae 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/DataConstants.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/DataConstants.java @@ -105,7 +105,7 @@ public class DataConstants { public static final String RPC_FAILED = "RPC_FAILED"; public static final String RPC_DELETED = "RPC_DELETED"; - public static final String CF_REEVALUATION_MSG = "CF_REEVALUATION_MSG"; + public static final String REEVALUATION_MSG = "REEVALUATION_MSG"; public static final String DEFAULT_SECRET_KEY = ""; public static final String SECRET_KEY_FIELD_NAME = "secretKey"; From 66936c48ab72768029c83f59662a2cef81425f6f Mon Sep 17 00:00:00 2001 From: IrynaMatveieva Date: Thu, 27 Nov 2025 16:39:09 +0200 Subject: [PATCH 50/54] removed lazy initializer --- ...tedEntitiesAggregationCalculatedFieldState.java | 8 +++++--- .../EntityAggregationCalculatedFieldState.java | 14 +++++++++----- 2 files changed, 14 insertions(+), 8 deletions(-) diff --git a/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/aggregation/RelatedEntitiesAggregationCalculatedFieldState.java b/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/aggregation/RelatedEntitiesAggregationCalculatedFieldState.java index 3b4092a885..264f651eb0 100644 --- a/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/aggregation/RelatedEntitiesAggregationCalculatedFieldState.java +++ b/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/aggregation/RelatedEntitiesAggregationCalculatedFieldState.java @@ -262,9 +262,11 @@ public class RelatedEntitiesAggregationCalculatedFieldState extends BaseCalculat List entitiesArguments = new ArrayList<>(); inputs.forEach((entityId, entityArguments) -> { EntityInfo entityInfo = entityIdEntityInfos.get(entityId); - JsonNode entityArgumentsJson = JacksonUtil.valueToTree(entityArguments.entrySet().stream() - .collect(Collectors.toMap(Entry::getKey, e -> e.getValue().jsonValue()))); - entitiesArguments.add(new EntityArgument(entityInfo, entityArgumentsJson)); + if (entityInfo != null) { + JsonNode entityArgumentsJson = JacksonUtil.valueToTree(entityArguments.entrySet().stream() + .collect(Collectors.toMap(Entry::getKey, e -> e.getValue().jsonValue()))); + entitiesArguments.add(new EntityArgument(entityInfo, entityArgumentsJson)); + } }); return JacksonUtil.valueToTree(new RelatedEntitiesArgument(ArgumentEntryType.RELATED_ENTITIES, entitiesArguments)); } diff --git a/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/aggregation/single/EntityAggregationCalculatedFieldState.java b/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/aggregation/single/EntityAggregationCalculatedFieldState.java index 17aa90f099..f3c3e8a1cc 100644 --- a/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/aggregation/single/EntityAggregationCalculatedFieldState.java +++ b/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/aggregation/single/EntityAggregationCalculatedFieldState.java @@ -21,7 +21,6 @@ import com.fasterxml.jackson.databind.node.ArrayNode; import com.fasterxml.jackson.databind.node.ObjectNode; import com.google.common.util.concurrent.Futures; import com.google.common.util.concurrent.ListenableFuture; -import org.apache.commons.lang3.concurrent.LazyInitializer; import org.thingsboard.common.util.DebugModeUtil; import org.thingsboard.common.util.JacksonUtil; import org.thingsboard.script.api.tbel.TbUtils; @@ -103,10 +102,11 @@ public class EntityAggregationCalculatedFieldState extends BaseCalculatedFieldSt long now = System.currentTimeMillis(); if (DebugModeUtil.isDebugFailuresAvailable(ctx.getCalculatedField())) { - LazyInitializer lazy = LazyInitializer.builder() - .setInitializer(() -> new EntityAggregationDebugArgumentsTracker(new HashMap<>())) - .get(); - debugTracker = lazy.get(); + if (debugTracker == null) { + debugTracker = new EntityAggregationDebugArgumentsTracker(new HashMap<>()); + } else { + debugTracker.reset(); + } debugTracker.recordUpdatedArgs(updatedArgs, arguments); } @@ -314,6 +314,10 @@ public class EntityAggregationCalculatedFieldState extends BaseCalculatedFieldSt record EntityAggregationDebugArgumentsTracker(Map> processedIntervals) { + public void reset() { + processedIntervals.clear(); + } + public void addInterval(AggIntervalEntry interval) { processedIntervals.computeIfAbsent(interval, k -> new HashMap<>()); } From 7f73097f9011571607ff574373948f0a23a96dd6 Mon Sep 17 00:00:00 2001 From: Vladyslav_Prykhodko Date: Thu, 27 Nov 2025 16:41:41 +0200 Subject: [PATCH 51/54] UI: Mark required field description in API key --- .../components/api-key/add-api-key-dialog.component.html | 5 ++++- .../api-key/edit-api-key-description-panel.component.html | 5 ++++- .../api-key/edit-api-key-description-panel.component.ts | 4 ++-- ui-ngx/src/assets/locale/locale.constant-en_US.json | 1 + 4 files changed, 11 insertions(+), 4 deletions(-) diff --git a/ui-ngx/src/app/modules/home/components/api-key/add-api-key-dialog.component.html b/ui-ngx/src/app/modules/home/components/api-key/add-api-key-dialog.component.html index dd91707449..defcdbb81d 100644 --- a/ui-ngx/src/app/modules/home/components/api-key/add-api-key-dialog.component.html +++ b/ui-ngx/src/app/modules/home/components/api-key/add-api-key-dialog.component.html @@ -35,7 +35,10 @@
api-key.description - + + + {{ 'asset.description-required' | translate }} + {{ 'api-key.enable' | translate }} diff --git a/ui-ngx/src/app/modules/home/components/api-key/edit-api-key-description-panel.component.html b/ui-ngx/src/app/modules/home/components/api-key/edit-api-key-description-panel.component.html index 11f9e0e657..bd889248c9 100644 --- a/ui-ngx/src/app/modules/home/components/api-key/edit-api-key-description-panel.component.html +++ b/ui-ngx/src/app/modules/home/components/api-key/edit-api-key-description-panel.component.html @@ -21,7 +21,10 @@ api-key.description - {{input.value?.length || 0}}/255 + + {{ 'asset.description-required' | translate }} + + {{ input.value?.length || 0 }}/255