From b1d19eb3ccc3c4ec17d5c873369f285e139d626b Mon Sep 17 00:00:00 2001 From: YevhenBondarenko Date: Fri, 14 Feb 2025 16:45:41 +0100 Subject: [PATCH 001/102] enforce 2FA feature draft --- .../server/controller/AuthController.java | 14 ++++++- .../TwoFactorAuthConfigController.java | 13 ++---- .../controller/TwoFactorAuthController.java | 34 ++++++++++----- .../auth/ForceMfaAuthenticationToken.java | 24 +++++++++++ .../auth/mfa/DefaultTwoFactorAuthService.java | 7 ++++ .../auth/mfa/TwoFactorAuthService.java | 3 +- .../mfa/config/DefaultTwoFaConfigManager.java | 5 ++- .../auth/rest/RestAuthenticationProvider.java | 3 ++ ...RestAwareAuthenticationSuccessHandler.java | 26 ++++++++---- .../security/model/token/JwtTokenFactory.java | 13 +++--- .../controller/TwoFactorAuthConfigTest.java | 17 +++++++- .../server/controller/TwoFactorAuthTest.java | 41 +++++++++++++++++++ .../security/auth/JwtTokenFactoryTest.java | 2 +- .../common/data/security/Authority.java | 3 +- .../model/mfa/PlatformTwoFaSettings.java | 1 + 15 files changed, 166 insertions(+), 40 deletions(-) create mode 100644 application/src/main/java/org/thingsboard/server/service/security/auth/ForceMfaAuthenticationToken.java diff --git a/application/src/main/java/org/thingsboard/server/controller/AuthController.java b/application/src/main/java/org/thingsboard/server/controller/AuthController.java index 600980316a..521c1c4ef6 100644 --- a/application/src/main/java/org/thingsboard/server/controller/AuthController.java +++ b/application/src/main/java/org/thingsboard/server/controller/AuthController.java @@ -39,6 +39,7 @@ import org.thingsboard.server.common.data.exception.ThingsboardErrorCode; import org.thingsboard.server.common.data.exception.ThingsboardException; import org.thingsboard.server.common.data.id.TenantId; import org.thingsboard.server.common.data.limit.LimitedApi; +import org.thingsboard.server.common.data.security.Authority; import org.thingsboard.server.common.data.security.UserCredentials; import org.thingsboard.server.common.data.security.event.UserCredentialsInvalidationEvent; import org.thingsboard.server.common.data.security.event.UserSessionInvalidationEvent; @@ -48,7 +49,9 @@ import org.thingsboard.server.common.data.security.model.UserPasswordPolicy; import org.thingsboard.server.config.annotations.ApiOperation; import org.thingsboard.server.dao.settings.SecuritySettingsService; import org.thingsboard.server.queue.util.TbCoreComponent; +import org.thingsboard.server.service.security.auth.mfa.TwoFactorAuthService; import org.thingsboard.server.service.security.auth.rest.RestAuthenticationDetails; +import org.thingsboard.server.service.security.auth.rest.RestAwareAuthenticationSuccessHandler; import org.thingsboard.server.service.security.model.ActivateUserRequest; import org.thingsboard.server.service.security.model.ChangePasswordRequest; import org.thingsboard.server.service.security.model.ResetPasswordEmailRequest; @@ -74,7 +77,8 @@ public class AuthController extends BaseController { private final SecuritySettingsService securitySettingsService; private final RateLimitService rateLimitService; private final ApplicationEventPublisher eventPublisher; - + private final TwoFactorAuthService twoFactorAuthService; + private final RestAwareAuthenticationSuccessHandler authenticationSuccessHandler; @ApiOperation(value = "Get current User (getUser)", notes = "Get the information about the User which credentials are used to perform this REST API call.") @@ -221,7 +225,13 @@ public class AuthController extends BaseController { } } - var tokenPair = tokenFactory.createTokenPair(securityUser); + JwtPair tokenPair; + if (twoFactorAuthService.isEnforceTwoFaEnabled(securityUser.getTenantId())) { + tokenPair = authenticationSuccessHandler.createMfaTokenPair(securityUser, Authority.ENFORCE_MFA_TOKEN); + } else { + tokenPair = tokenFactory.createTokenPair(securityUser); + } + systemSecurityService.logLoginAction(user, new RestAuthenticationDetails(request), ActionType.LOGIN, null); return tokenPair; } diff --git a/application/src/main/java/org/thingsboard/server/controller/TwoFactorAuthConfigController.java b/application/src/main/java/org/thingsboard/server/controller/TwoFactorAuthConfigController.java index 01487b20a0..7a7a47027e 100644 --- a/application/src/main/java/org/thingsboard/server/controller/TwoFactorAuthConfigController.java +++ b/application/src/main/java/org/thingsboard/server/controller/TwoFactorAuthConfigController.java @@ -56,7 +56,6 @@ public class TwoFactorAuthConfigController extends BaseController { private final TwoFaConfigManager twoFaConfigManager; private final TwoFactorAuthService twoFactorAuthService; - @ApiOperation(value = "Get account 2FA settings (getAccountTwoFaSettings)", notes = "Get user's account 2FA configuration. Configuration contains configs for different 2FA providers." + NEW_LINE + "Example:\n" + @@ -73,7 +72,6 @@ public class TwoFactorAuthConfigController extends BaseController { return twoFaConfigManager.getAccountTwoFaSettings(user.getTenantId(), user.getId()).orElse(null); } - @ApiOperation(value = "Generate 2FA account config (generateTwoFaAccountConfig)", notes = "Generate new 2FA account config template for specified provider type. " + NEW_LINE + "For TOTP, this will return a corresponding account config template " + @@ -99,7 +97,7 @@ public class TwoFactorAuthConfigController extends BaseController { "Will throw an error (Bad Request) if the provider is not configured for usage. " + ControllerConstants.AVAILABLE_FOR_ANY_AUTHORIZED_USER) @PostMapping("/account/config/generate") - @PreAuthorize("hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN', 'CUSTOMER_USER')") + @PreAuthorize("hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN', 'CUSTOMER_USER', 'ENFORCE_MFA_TOKEN')") public TwoFaAccountConfig generateTwoFaAccountConfig(@Parameter(description = "2FA provider type to generate new account config for", schema = @Schema(defaultValue = "TOTP", requiredMode = Schema.RequiredMode.REQUIRED)) @RequestParam TwoFaProviderType providerType) throws Exception { SecurityUser user = getCurrentUser(); @@ -139,7 +137,7 @@ public class TwoFactorAuthConfigController extends BaseController { "Will throw an error (Bad Request) if the provider is not configured for usage. " + ControllerConstants.AVAILABLE_FOR_ANY_AUTHORIZED_USER) @PostMapping("/account/config") - @PreAuthorize("hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN', 'CUSTOMER_USER')") + @PreAuthorize("hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN', 'CUSTOMER_USER', 'ENFORCE_MFA_TOKEN')") public AccountTwoFaSettings verifyAndSaveTwoFaAccountConfig(@Valid @RequestBody TwoFaAccountConfig accountConfig, @RequestParam(required = false) String verificationCode) throws Exception { SecurityUser user = getCurrentUser(); @@ -189,7 +187,6 @@ public class TwoFactorAuthConfigController extends BaseController { return twoFaConfigManager.deleteTwoFaAccountConfig(user.getTenantId(), user.getId(), providerType); } - @ApiOperation(value = "Get available 2FA providers (getAvailableTwoFaProviders)", notes = "Get the list of provider types available for user to use (the ones configured by tenant or sysadmin).\n" + "Example of response:\n" + @@ -197,7 +194,7 @@ public class TwoFactorAuthConfigController extends BaseController { ControllerConstants.AVAILABLE_FOR_ANY_AUTHORIZED_USER ) @GetMapping("/providers") - @PreAuthorize("hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN', 'CUSTOMER_USER')") + @PreAuthorize("hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN', 'CUSTOMER_USER', 'ENFORCE_MFA_TOKEN')") public List getAvailableTwoFaProviders() throws ThingsboardException { return twoFaConfigManager.getPlatformTwoFaSettings(getTenantId(), true) .map(PlatformTwoFaSettings::getProviders).orElse(Collections.emptyList()).stream() @@ -205,7 +202,6 @@ public class TwoFactorAuthConfigController extends BaseController { .collect(Collectors.toList()); } - @ApiOperation(value = "Get platform 2FA settings (getPlatformTwoFaSettings)", notes = "Get platform settings for 2FA. The settings are described for savePlatformTwoFaSettings API method. " + "If 2FA is not configured, then an empty response will be returned." + @@ -260,11 +256,10 @@ public class TwoFactorAuthConfigController extends BaseController { @PostMapping("/settings") @PreAuthorize("hasAnyAuthority('SYS_ADMIN')") public PlatformTwoFaSettings savePlatformTwoFaSettings(@Parameter(description = "Settings value", required = true) - @RequestBody PlatformTwoFaSettings twoFaSettings) throws ThingsboardException { + @RequestBody PlatformTwoFaSettings twoFaSettings) throws ThingsboardException { return twoFaConfigManager.savePlatformTwoFaSettings(getTenantId(), twoFaSettings); } - @Data public static class TwoFaAccountConfigUpdateRequest { private boolean useByDefault; diff --git a/application/src/main/java/org/thingsboard/server/controller/TwoFactorAuthController.java b/application/src/main/java/org/thingsboard/server/controller/TwoFactorAuthController.java index 4e4b30a542..9d7d6ba897 100644 --- a/application/src/main/java/org/thingsboard/server/controller/TwoFactorAuthController.java +++ b/application/src/main/java/org/thingsboard/server/controller/TwoFactorAuthController.java @@ -64,7 +64,6 @@ public class TwoFactorAuthController extends BaseController { private final SystemSecurityService systemSecurityService; private final UserService userService; - @ApiOperation(value = "Request 2FA verification code (requestTwoFaVerificationCode)", notes = "Request 2FA verification code." + NEW_LINE + "To make a request to this endpoint, you need an access token with the scope of PRE_VERIFICATION_TOKEN, " + @@ -91,18 +90,9 @@ public class TwoFactorAuthController extends BaseController { @RequestParam String verificationCode, HttpServletRequest servletRequest) throws Exception { SecurityUser user = getCurrentUser(); boolean verificationSuccess = twoFactorAuthService.checkVerificationCode(user, providerType, verificationCode, true); - if (verificationSuccess) { - systemSecurityService.logLoginAction(user, new RestAuthenticationDetails(servletRequest), ActionType.LOGIN, null); - user = new SecurityUser(userService.findUserById(user.getTenantId(), user.getId()), true, user.getUserPrincipal()); - return tokenFactory.createTokenPair(user); - } else { - ThingsboardException error = new ThingsboardException("Verification code is incorrect", ThingsboardErrorCode.BAD_REQUEST_PARAMS); - systemSecurityService.logLoginAction(user, new RestAuthenticationDetails(servletRequest), ActionType.LOGIN, error); - throw error; - } + return getRegularJwtPair(servletRequest, user, verificationSuccess, "Verification code is incorrect"); } - @ApiOperation(value = "Get available 2FA providers (getAvailableTwoFaProviders)", notes = "Get the list of 2FA provider infos available for user to use. Example:\n" + "```\n[\n" + @@ -139,6 +129,28 @@ public class TwoFactorAuthController extends BaseController { .collect(Collectors.toList()); } + @ApiOperation(value = "Get regular token pair after successfully saved two factor settings", + notes = "Checks 2FA setting saved, and if it success the method returns a regular access and refresh token pair.") + @PostMapping("/login") + @PreAuthorize("hasAuthority('ENFORCE_MFA_TOKEN')") + public JwtPair authorizeByTwoFaEnforceToken(HttpServletRequest servletRequest) throws ThingsboardException { + SecurityUser user = getCurrentUser(); + boolean isEnabled = twoFactorAuthService.isTwoFaEnabled(user.getTenantId(), user.getId()); + return getRegularJwtPair(servletRequest, user, isEnabled, "Two factor settings is not set up!"); + } + + private JwtPair getRegularJwtPair(HttpServletRequest servletRequest, SecurityUser user, boolean isAvailable, String errorMessage) throws ThingsboardException { + if (isAvailable) { + systemSecurityService.logLoginAction(user, new RestAuthenticationDetails(servletRequest), ActionType.LOGIN, null); + user = new SecurityUser(userService.findUserById(user.getTenantId(), user.getId()), true, user.getUserPrincipal()); + return tokenFactory.createTokenPair(user); + } else { + ThingsboardException error = new ThingsboardException(errorMessage, ThingsboardErrorCode.BAD_REQUEST_PARAMS); + systemSecurityService.logLoginAction(user, new RestAuthenticationDetails(servletRequest), ActionType.LOGIN, error); + throw error; + } + } + @Data @AllArgsConstructor @Builder diff --git a/application/src/main/java/org/thingsboard/server/service/security/auth/ForceMfaAuthenticationToken.java b/application/src/main/java/org/thingsboard/server/service/security/auth/ForceMfaAuthenticationToken.java new file mode 100644 index 0000000000..5cb0c752ab --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/security/auth/ForceMfaAuthenticationToken.java @@ -0,0 +1,24 @@ +/** + * Copyright © 2016-2024 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 org.thingsboard.server.service.security.model.SecurityUser; + +public class ForceMfaAuthenticationToken extends AbstractJwtAuthenticationToken { + public ForceMfaAuthenticationToken(SecurityUser securityUser) { + super(securityUser); + } +} diff --git a/application/src/main/java/org/thingsboard/server/service/security/auth/mfa/DefaultTwoFactorAuthService.java b/application/src/main/java/org/thingsboard/server/service/security/auth/mfa/DefaultTwoFactorAuthService.java index ab7fc19202..33911eef3b 100644 --- a/application/src/main/java/org/thingsboard/server/service/security/auth/mfa/DefaultTwoFactorAuthService.java +++ b/application/src/main/java/org/thingsboard/server/service/security/auth/mfa/DefaultTwoFactorAuthService.java @@ -67,6 +67,13 @@ public class DefaultTwoFactorAuthService implements TwoFactorAuthService { .orElse(false); } + @Override + public boolean isEnforceTwoFaEnabled(TenantId tenantId) { + return configManager.getPlatformTwoFaSettings(tenantId, true) + .map(PlatformTwoFaSettings::isEnforceTwoFa) + .orElse(false); + } + @Override public void checkProvider(TenantId tenantId, TwoFaProviderType providerType) throws ThingsboardException { getTwoFaProvider(providerType).check(tenantId); diff --git a/application/src/main/java/org/thingsboard/server/service/security/auth/mfa/TwoFactorAuthService.java b/application/src/main/java/org/thingsboard/server/service/security/auth/mfa/TwoFactorAuthService.java index f1e2e34322..9336bc0d65 100644 --- a/application/src/main/java/org/thingsboard/server/service/security/auth/mfa/TwoFactorAuthService.java +++ b/application/src/main/java/org/thingsboard/server/service/security/auth/mfa/TwoFactorAuthService.java @@ -27,8 +27,9 @@ public interface TwoFactorAuthService { boolean isTwoFaEnabled(TenantId tenantId, UserId userId); - void checkProvider(TenantId tenantId, TwoFaProviderType providerType) throws ThingsboardException; + boolean isEnforceTwoFaEnabled(TenantId tenantId); + void checkProvider(TenantId tenantId, TwoFaProviderType providerType) throws ThingsboardException; void prepareVerificationCode(SecurityUser user, TwoFaProviderType providerType, boolean checkLimits) throws Exception; diff --git a/application/src/main/java/org/thingsboard/server/service/security/auth/mfa/config/DefaultTwoFaConfigManager.java b/application/src/main/java/org/thingsboard/server/service/security/auth/mfa/config/DefaultTwoFaConfigManager.java index a5b44b2add..7e505da11d 100644 --- a/application/src/main/java/org/thingsboard/server/service/security/auth/mfa/config/DefaultTwoFaConfigManager.java +++ b/application/src/main/java/org/thingsboard/server/service/security/auth/mfa/config/DefaultTwoFaConfigManager.java @@ -30,6 +30,7 @@ import org.thingsboard.server.common.data.security.model.mfa.account.AccountTwoF import org.thingsboard.server.common.data.security.model.mfa.account.TwoFaAccountConfig; import org.thingsboard.server.common.data.security.model.mfa.provider.TwoFaProviderConfig; import org.thingsboard.server.common.data.security.model.mfa.provider.TwoFaProviderType; +import org.thingsboard.server.dao.exception.DataValidationException; import org.thingsboard.server.dao.service.ConstraintValidator; import org.thingsboard.server.dao.settings.AdminSettingsDao; import org.thingsboard.server.dao.settings.AdminSettingsService; @@ -166,7 +167,9 @@ public class DefaultTwoFaConfigManager implements TwoFaConfigManager { for (TwoFaProviderConfig providerConfig : twoFactorAuthSettings.getProviders()) { twoFactorAuthService.checkProvider(tenantId, providerConfig.getProviderType()); } - + if (twoFactorAuthSettings.isEnforceTwoFa() && twoFactorAuthSettings.getProviders().isEmpty()) { + throw new DataValidationException("At least one 2FA provider is required if enforce enabled!"); + } AdminSettings settings = Optional.ofNullable(adminSettingsService.findAdminSettingsByKey(tenantId, TWO_FACTOR_AUTH_SETTINGS_KEY)) .orElseGet(() -> { AdminSettings newSettings = new AdminSettings(); 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 b9fe54deec..08166ab6c9 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 @@ -43,6 +43,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.ForceMfaAuthenticationToken; import org.thingsboard.server.service.security.auth.MfaAuthenticationToken; import org.thingsboard.server.service.security.auth.mfa.TwoFactorAuthService; import org.thingsboard.server.service.security.exception.UserPasswordNotValidException; @@ -105,6 +106,8 @@ public class RestAuthenticationProvider implements AuthenticationProvider { securityUser = authenticateByUsernameAndPassword(authentication, userPrincipal, username, password); if (twoFactorAuthService.isTwoFaEnabled(securityUser.getTenantId(), securityUser.getId())) { return new MfaAuthenticationToken(securityUser); + } else if (twoFactorAuthService.isEnforceTwoFaEnabled(securityUser.getTenantId())) { + return new ForceMfaAuthenticationToken(securityUser); } else { systemSecurityService.logLoginAction(securityUser, authentication.getDetails(), ActionType.LOGIN, null); } diff --git a/application/src/main/java/org/thingsboard/server/service/security/auth/rest/RestAwareAuthenticationSuccessHandler.java b/application/src/main/java/org/thingsboard/server/service/security/auth/rest/RestAwareAuthenticationSuccessHandler.java index 5dbcac0f84..ebaab2dd5e 100644 --- a/application/src/main/java/org/thingsboard/server/service/security/auth/rest/RestAwareAuthenticationSuccessHandler.java +++ b/application/src/main/java/org/thingsboard/server/service/security/auth/rest/RestAwareAuthenticationSuccessHandler.java @@ -29,6 +29,7 @@ import org.springframework.stereotype.Component; import org.thingsboard.common.util.JacksonUtil; import org.thingsboard.server.common.data.security.Authority; import org.thingsboard.server.common.data.security.model.JwtPair; +import org.thingsboard.server.service.security.auth.ForceMfaAuthenticationToken; import org.thingsboard.server.service.security.auth.MfaAuthenticationToken; import org.thingsboard.server.service.security.auth.mfa.config.TwoFaConfigManager; import org.thingsboard.server.service.security.model.SecurityUser; @@ -48,16 +49,13 @@ public class RestAwareAuthenticationSuccessHandler implements AuthenticationSucc public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException, ServletException { SecurityUser securityUser = (SecurityUser) authentication.getPrincipal(); - JwtPair tokenPair = new JwtPair(); + JwtPair tokenPair; if (authentication instanceof MfaAuthenticationToken) { - int preVerificationTokenLifetime = twoFaConfigManager.getPlatformTwoFaSettings(securityUser.getTenantId(), true) - .flatMap(settings -> Optional.ofNullable(settings.getTotalAllowedTimeForVerification()) - .filter(time -> time > 0)) - .orElse((int) TimeUnit.MINUTES.toSeconds(30)); - tokenPair.setToken(tokenFactory.createPreVerificationToken(securityUser, preVerificationTokenLifetime).getToken()); - tokenPair.setRefreshToken(null); - tokenPair.setScope(Authority.PRE_VERIFICATION_TOKEN); + tokenPair = createMfaTokenPair(securityUser, Authority.PRE_VERIFICATION_TOKEN); + } + else if (authentication instanceof ForceMfaAuthenticationToken) { + tokenPair = createMfaTokenPair(securityUser, Authority.ENFORCE_MFA_TOKEN); } else { tokenPair = tokenFactory.createTokenPair(securityUser); } @@ -69,6 +67,18 @@ public class RestAwareAuthenticationSuccessHandler implements AuthenticationSucc clearAuthenticationAttributes(request); } + public JwtPair createMfaTokenPair(SecurityUser securityUser, Authority scope) { + JwtPair tokenPair = new JwtPair(); + int preVerificationTokenLifetime = twoFaConfigManager.getPlatformTwoFaSettings(securityUser.getTenantId(), true) + .flatMap(settings -> Optional.ofNullable(settings.getTotalAllowedTimeForVerification()) + .filter(time -> time > 0)) + .orElse((int) TimeUnit.MINUTES.toSeconds(30)); + tokenPair.setToken(tokenFactory.createMfaToken(securityUser, scope, preVerificationTokenLifetime).getToken()); + tokenPair.setRefreshToken(null); + tokenPair.setScope(scope); + return tokenPair; + } + /** * Removes temporary authentication-related data which may have been stored * in the session during the authentication process.. diff --git a/application/src/main/java/org/thingsboard/server/service/security/model/token/JwtTokenFactory.java b/application/src/main/java/org/thingsboard/server/service/security/model/token/JwtTokenFactory.java index f68d954aa1..52ba9981bf 100644 --- a/application/src/main/java/org/thingsboard/server/service/security/model/token/JwtTokenFactory.java +++ b/application/src/main/java/org/thingsboard/server/service/security/model/token/JwtTokenFactory.java @@ -115,13 +115,16 @@ public class JwtTokenFactory { throw new IllegalArgumentException("JWT Token doesn't have any scopes"); } + Authority authority = Authority.parse(scopes.get(0)); + SecurityUser securityUser = new SecurityUser(new UserId(UUID.fromString(claims.get(USER_ID, String.class)))); securityUser.setEmail(subject); - securityUser.setAuthority(Authority.parse(scopes.get(0))); + securityUser.setAuthority(authority); String tenantId = claims.get(TENANT_ID, String.class); + if (tenantId != null) { securityUser.setTenantId(TenantId.fromUUID(UUID.fromString(tenantId))); - } else if (securityUser.getAuthority() == Authority.SYS_ADMIN) { + } else if (authority == Authority.SYS_ADMIN) { securityUser.setTenantId(TenantId.SYS_TENANT_ID); } String customerId = claims.get(CUSTOMER_ID, String.class); @@ -133,7 +136,7 @@ public class JwtTokenFactory { } UserPrincipal principal; - if (securityUser.getAuthority() != Authority.PRE_VERIFICATION_TOKEN) { + if (authority != Authority.PRE_VERIFICATION_TOKEN && authority != Authority.ENFORCE_MFA_TOKEN) { securityUser.setFirstName(claims.get(FIRST_NAME, String.class)); securityUser.setLastName(claims.get(LAST_NAME, String.class)); securityUser.setEnabled(claims.get(ENABLED, Boolean.class)); @@ -179,8 +182,8 @@ public class JwtTokenFactory { return securityUser; } - public JwtToken createPreVerificationToken(SecurityUser user, Integer expirationTime) { - JwtBuilder jwtBuilder = setUpToken(user, Collections.singletonList(Authority.PRE_VERIFICATION_TOKEN.name()), expirationTime) + public JwtToken createMfaToken(SecurityUser user, Authority scope, Integer expirationTime) { + JwtBuilder jwtBuilder = setUpToken(user, Collections.singletonList(scope.name()), expirationTime) .claim(TENANT_ID, user.getTenantId().toString()); if (user.getCustomerId() != null) { jwtBuilder.claim(CUSTOMER_ID, user.getCustomerId().toString()); diff --git a/application/src/test/java/org/thingsboard/server/controller/TwoFactorAuthConfigTest.java b/application/src/test/java/org/thingsboard/server/controller/TwoFactorAuthConfigTest.java index a8101c7801..f5703ea13a 100644 --- a/application/src/test/java/org/thingsboard/server/controller/TwoFactorAuthConfigTest.java +++ b/application/src/test/java/org/thingsboard/server/controller/TwoFactorAuthConfigTest.java @@ -85,7 +85,6 @@ public class TwoFactorAuthConfigTest extends AbstractControllerTest { twoFaConfigManager.deletePlatformTwoFaSettings(tenantId); } - @Test public void testSavePlatformTwoFaSettings() throws Exception { loginSysAdmin(); @@ -102,6 +101,7 @@ public class TwoFactorAuthConfigTest extends AbstractControllerTest { twoFaSettings.setVerificationCodeCheckRateLimit("3:900"); twoFaSettings.setMaxVerificationFailuresBeforeUserLockout(10); twoFaSettings.setTotalAllowedTimeForVerification(3600); + twoFaSettings.setEnforceTwoFa(true); doPost("/api/2fa/settings", twoFaSettings).andExpect(status().isOk()); @@ -111,6 +111,21 @@ public class TwoFactorAuthConfigTest extends AbstractControllerTest { assertThat(savedTwoFaSettings.getProviders()).contains(totpTwoFaProviderConfig, smsTwoFaProviderConfig); } + @Test + public void testSavePlatformTwoFaSettingsWithEnforceTwoFaWithoutProviders() throws Exception { + loginSysAdmin(); + + PlatformTwoFaSettings twoFaSettings = new PlatformTwoFaSettings(); + twoFaSettings.setProviders(List.of()); + twoFaSettings.setMinVerificationCodeSendPeriod(5); + twoFaSettings.setVerificationCodeCheckRateLimit("3:900"); + twoFaSettings.setMaxVerificationFailuresBeforeUserLockout(10); + twoFaSettings.setTotalAllowedTimeForVerification(3600); + twoFaSettings.setEnforceTwoFa(true); + + doPost("/api/2fa/settings", twoFaSettings).andExpect(status().isBadRequest()); + } + @Test public void testSavePlatformTwoFaSettings_validationError() throws Exception { loginSysAdmin(); diff --git a/application/src/test/java/org/thingsboard/server/controller/TwoFactorAuthTest.java b/application/src/test/java/org/thingsboard/server/controller/TwoFactorAuthTest.java index 7fa848f95d..2f21e1afe6 100644 --- a/application/src/test/java/org/thingsboard/server/controller/TwoFactorAuthTest.java +++ b/application/src/test/java/org/thingsboard/server/controller/TwoFactorAuthTest.java @@ -25,6 +25,7 @@ import org.mockito.ArgumentCaptor; 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.web.util.UriComponentsBuilder; import org.thingsboard.rule.engine.api.SmsService; import org.thingsboard.server.common.data.StringUtils; import org.thingsboard.server.common.data.User; @@ -66,6 +67,10 @@ import java.util.stream.Stream; import static org.assertj.core.api.Assertions.assertThat; import static org.awaitility.Awaitility.await; import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; import static org.junit.jupiter.api.Assertions.fail; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.eq; @@ -395,6 +400,42 @@ public class TwoFactorAuthTest extends AbstractControllerTest { assertThat(providersInfos.get(TwoFaProviderType.EMAIL).isDefault()).isFalse(); } + @Test + public void testEnforceTwoFactorSetting() throws Exception { + TotpTwoFaProviderConfig totpTwoFaProviderConfig = new TotpTwoFaProviderConfig(); + totpTwoFaProviderConfig.setIssuerName("tb"); + + PlatformTwoFaSettings twoFaSettings = new PlatformTwoFaSettings(); + twoFaSettings.setProviders(Arrays.stream(new TwoFaProviderConfig[]{totpTwoFaProviderConfig}).collect(Collectors.toList())); + twoFaSettings.setMinVerificationCodeSendPeriod(5); + twoFaSettings.setTotalAllowedTimeForVerification(100); + twoFaSettings.setEnforceTwoFa(true); + twoFaSettings = twoFaConfigManager.savePlatformTwoFaSettings(TenantId.SYS_TENANT_ID, twoFaSettings); + + JsonNode node = readResponse(doPost("/api/auth/login", new LoginRequest(username, password)).andExpect(status().isOk()), JsonNode.class); + assertNotNull(node.get("token").asText()); + assertNull(node.get("refreshToken")); + assertEquals(node.get("scope").asText(), Authority.ENFORCE_MFA_TOKEN.name()); + + this.token = node.get("token").asText(); + TotpTwoFaAccountConfig totpTwoFaAccountConfig = (TotpTwoFaAccountConfig) twoFactorAuthService.generateNewAccountConfig(user, totpTwoFaProviderConfig.getProviderType()); + String secret = UriComponentsBuilder.fromUriString(totpTwoFaAccountConfig.getAuthUrl()).build() + .getQueryParams().getFirst("secret"); + String verificationCode = new Totp(secret).now(); + readResponse(doPost("/api/2fa/account/config?verificationCode=" + verificationCode, totpTwoFaAccountConfig).andExpect(status().isOk()), JsonNode.class); + + JwtPair tokenPair = readResponse(doPost("/api/auth/2fa/login").andExpect(status().isOk()), JwtPair.class); + assertNotNull(tokenPair); + + this.token = tokenPair.getToken(); + this.refreshToken = tokenPair.getRefreshToken(); + + doGet("/api/user/" + user.getId()).andExpect(status().isOk()); + + twoFaSettings.setEnforceTwoFa(false); + twoFaConfigManager.savePlatformTwoFaSettings(TenantId.SYS_TENANT_ID, twoFaSettings); + } + private void logInWithPreVerificationToken(String username, String password) throws Exception { LoginRequest loginRequest = new LoginRequest(username, password); diff --git a/application/src/test/java/org/thingsboard/server/service/security/auth/JwtTokenFactoryTest.java b/application/src/test/java/org/thingsboard/server/service/security/auth/JwtTokenFactoryTest.java index e9d874084d..07dd3a422c 100644 --- a/application/src/test/java/org/thingsboard/server/service/security/auth/JwtTokenFactoryTest.java +++ b/application/src/test/java/org/thingsboard/server/service/security/auth/JwtTokenFactoryTest.java @@ -125,7 +125,7 @@ public class JwtTokenFactoryTest { public void testCreateAndParsePreVerificationJwtToken() { SecurityUser securityUser = createSecurityUser(); int tokenLifetime = (int) TimeUnit.MINUTES.toSeconds(30); - JwtToken preVerificationToken = tokenFactory.createPreVerificationToken(securityUser, tokenLifetime); + JwtToken preVerificationToken = tokenFactory.createMfaToken(securityUser, Authority.PRE_VERIFICATION_TOKEN, tokenLifetime); checkExpirationTime(preVerificationToken, tokenLifetime); SecurityUser parsedSecurityUser = tokenFactory.parseAccessJwtToken(preVerificationToken.getToken()); diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/security/Authority.java b/common/data/src/main/java/org/thingsboard/server/common/data/security/Authority.java index 699decabd9..ccebd43ccb 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/security/Authority.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/security/Authority.java @@ -21,7 +21,8 @@ public enum Authority { TENANT_ADMIN(1), CUSTOMER_USER(2), REFRESH_TOKEN(10), - PRE_VERIFICATION_TOKEN(11); + PRE_VERIFICATION_TOKEN(11), + ENFORCE_MFA_TOKEN(12); private int code; diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/security/model/mfa/PlatformTwoFaSettings.java b/common/data/src/main/java/org/thingsboard/server/common/data/security/model/mfa/PlatformTwoFaSettings.java index bd2cd1fad7..35a2fac352 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/security/model/mfa/PlatformTwoFaSettings.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/security/model/mfa/PlatformTwoFaSettings.java @@ -46,6 +46,7 @@ public class PlatformTwoFaSettings { @Min(value = 60) private Integer totalAllowedTimeForVerification; + private boolean enforceTwoFa; public Optional getProviderConfig(TwoFaProviderType providerType) { return Optional.ofNullable(providers) From 57024c223e7ef682fdb6c2cc4a89767c4186f4b0 Mon Sep 17 00:00:00 2001 From: Paolo Cristiani <42511852+pgrisu@users.noreply.github.com> Date: Tue, 13 May 2025 14:44:27 +0200 Subject: [PATCH 002/102] feat: add tooltip stacked total option in time series chart settings --- ...e-series-chart-basic-config.component.html | 5 +++ ...ime-series-chart-basic-config.component.ts | 10 ++++- .../chart/time-series-chart-tooltip.models.ts | 45 ++++++++++++++++++- .../lib/chart/time-series-chart.models.ts | 1 + ...eries-chart-widget-settings.component.html | 5 +++ ...-series-chart-widget-settings.component.ts | 9 +++- .../assets/locale/locale.constant-en_US.json | 1 + 7 files changed, 73 insertions(+), 3 deletions(-) diff --git a/ui-ngx/src/app/modules/home/components/widget/config/basic/chart/time-series-chart-basic-config.component.html b/ui-ngx/src/app/modules/home/components/widget/config/basic/chart/time-series-chart-basic-config.component.html index 61c9f89c7c..8d1b5eaad7 100644 --- a/ui-ngx/src/app/modules/home/components/widget/config/basic/chart/time-series-chart-basic-config.component.html +++ b/ui-ngx/src/app/modules/home/components/widget/config/basic/chart/time-series-chart-basic-config.component.html @@ -311,6 +311,11 @@ +
+ + {{ 'tooltip.show-stack-total' | translate }} + +
{{ 'tooltip.background-color' | translate }}
+
+ + {{ 'tooltip.show-stack-total' | translate }} + +
{{ 'tooltip.background-color' | translate }}
Date: Wed, 14 May 2025 18:11:20 +0200 Subject: [PATCH 003/102] Add show-total legend setting to latest-chart widgets --- .../basic/chart/latest-chart-basic-config.component.html | 3 +++ .../basic/chart/latest-chart-basic-config.component.ts | 4 ++++ .../components/widget/lib/chart/latest-chart.component.ts | 5 ++++- .../home/components/widget/lib/chart/latest-chart.models.ts | 2 ++ .../chart/latest-chart-widget-settings.component.html | 3 +++ .../settings/chart/latest-chart-widget-settings.component.ts | 3 +++ 6 files changed, 19 insertions(+), 1 deletion(-) diff --git a/ui-ngx/src/app/modules/home/components/widget/config/basic/chart/latest-chart-basic-config.component.html b/ui-ngx/src/app/modules/home/components/widget/config/basic/chart/latest-chart-basic-config.component.html index 4a9569fb29..4601eaa4f9 100644 --- a/ui-ngx/src/app/modules/home/components/widget/config/basic/chart/latest-chart-basic-config.component.html +++ b/ui-ngx/src/app/modules/home/components/widget/config/basic/chart/latest-chart-basic-config.component.html @@ -143,6 +143,9 @@
+ + {{ 'legend.show-total' | translate }} + diff --git a/ui-ngx/src/app/modules/home/components/widget/config/basic/chart/latest-chart-basic-config.component.ts b/ui-ngx/src/app/modules/home/components/widget/config/basic/chart/latest-chart-basic-config.component.ts index 9890895938..1a75f95d2f 100644 --- a/ui-ngx/src/app/modules/home/components/widget/config/basic/chart/latest-chart-basic-config.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/config/basic/chart/latest-chart-basic-config.component.ts @@ -176,6 +176,7 @@ export abstract class LatestChartBasicConfigComponent !item.total); } legendLabelStyle: ComponentStyle; @@ -92,6 +93,7 @@ export class LatestChartComponent implements OnInit, OnDestroy, AfterViewInit { private shapeResize$: ResizeObserver; private legendHorizontal: boolean; + private legendShowTotal: boolean; private latestChart: TbLatestChart; @@ -119,6 +121,7 @@ export class LatestChartComponent implements OnInit, OnDestroy, AfterViewInit { this.legendValueStyle = textStyle(this.settings.legendValueFont); this.disabledLegendValueStyle = textStyle(this.settings.legendValueFont); this.legendValueStyle.color = this.settings.legendValueColor; + this.legendShowTotal = this.settings.legendShowTotal; } } diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/chart/latest-chart.models.ts b/ui-ngx/src/app/modules/home/components/widget/lib/chart/latest-chart.models.ts index 931d3c52ed..9184bc59e7 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/chart/latest-chart.models.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/chart/latest-chart.models.ts @@ -103,6 +103,7 @@ export interface LatestChartWidgetSettings extends LatestChartSettings { legendLabelColor: string; legendValueFont: Font; legendValueColor: string; + legendShowTotal: boolean; background: BackgroundSettings; padding: string; } @@ -129,6 +130,7 @@ export const latestChartWidgetDefaultSettings: LatestChartWidgetSettings = { lineHeight: '20px' }, legendValueColor: 'rgba(0, 0, 0, 0.87)', + legendShowTotal: true, background: { type: BackgroundType.color, color: '#fff', diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/chart/latest-chart-widget-settings.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/settings/chart/latest-chart-widget-settings.component.html index 04476e69e2..5246747c20 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/chart/latest-chart-widget-settings.component.html +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/chart/latest-chart-widget-settings.component.html @@ -61,6 +61,9 @@ + + {{ 'legend.show-total' | translate }} + diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/chart/latest-chart-widget-settings.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/settings/chart/latest-chart-widget-settings.component.ts index 1d7baebd33..b67a1d85f5 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/chart/latest-chart-widget-settings.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/chart/latest-chart-widget-settings.component.ts @@ -134,6 +134,7 @@ export abstract class LatestChartWidgetSettingsComponent Date: Tue, 24 Jun 2025 11:43:09 +0300 Subject: [PATCH 004/102] Add users filter to enforce 2FA for --- .../server/controller/AuthController.java | 4 +- .../TwoFactorAuthConfigController.java | 6 +- .../controller/TwoFactorAuthController.java | 44 ++++++---- ...nToken.java => MfaConfigurationToken.java} | 6 +- .../auth/mfa/DefaultTwoFactorAuthService.java | 15 +++- .../auth/mfa/TwoFactorAuthService.java | 2 +- .../mfa/config/DefaultTwoFaConfigManager.java | 4 +- .../auth/rest/RestAuthenticationProvider.java | 9 +-- ...RestAwareAuthenticationSuccessHandler.java | 14 ++-- .../security/model/token/JwtTokenFactory.java | 2 +- .../server/controller/AbstractWebTest.java | 12 ++- .../server/controller/TwoFactorAuthTest.java | 50 ++++++------ .../dao/tenant/TbTenantProfileCache.java | 1 - .../server/dao/user/UserService.java | 6 ++ .../targets/platform/AllUsersFilter.java | 2 +- .../platform/SystemAdministratorsFilter.java | 2 +- .../platform/SystemLevelUsersFilter.java | 19 +++++ .../platform/TenantAdministratorsFilter.java | 2 +- .../common/data/security/Authority.java | 3 +- .../model/mfa/PlatformTwoFaSettings.java | 2 + .../DefaultNotificationTargetService.java | 51 +----------- .../server/dao/tenant/TenantServiceImpl.java | 1 + .../server/dao/user/UserServiceImpl.java | 80 +++++++++++++++++++ 23 files changed, 209 insertions(+), 128 deletions(-) rename application/src/main/java/org/thingsboard/server/service/security/auth/{ForceMfaAuthenticationToken.java => MfaConfigurationToken.java} (78%) create mode 100644 common/data/src/main/java/org/thingsboard/server/common/data/notification/targets/platform/SystemLevelUsersFilter.java diff --git a/application/src/main/java/org/thingsboard/server/controller/AuthController.java b/application/src/main/java/org/thingsboard/server/controller/AuthController.java index bb14bf76c5..b15b650c33 100644 --- a/application/src/main/java/org/thingsboard/server/controller/AuthController.java +++ b/application/src/main/java/org/thingsboard/server/controller/AuthController.java @@ -226,8 +226,8 @@ public class AuthController extends BaseController { } JwtPair tokenPair; - if (twoFactorAuthService.isEnforceTwoFaEnabled(securityUser.getTenantId())) { - tokenPair = authenticationSuccessHandler.createMfaTokenPair(securityUser, Authority.ENFORCE_MFA_TOKEN); + if (twoFactorAuthService.isEnforceTwoFaEnabled(securityUser.getTenantId(), user)) { + tokenPair = authenticationSuccessHandler.createMfaTokenPair(securityUser, Authority.MFA_CONFIGURATION_TOKEN); } else { tokenPair = tokenFactory.createTokenPair(securityUser); } diff --git a/application/src/main/java/org/thingsboard/server/controller/TwoFactorAuthConfigController.java b/application/src/main/java/org/thingsboard/server/controller/TwoFactorAuthConfigController.java index eeb0b4553b..fd78e5c945 100644 --- a/application/src/main/java/org/thingsboard/server/controller/TwoFactorAuthConfigController.java +++ b/application/src/main/java/org/thingsboard/server/controller/TwoFactorAuthConfigController.java @@ -97,7 +97,7 @@ public class TwoFactorAuthConfigController extends BaseController { "Will throw an error (Bad Request) if the provider is not configured for usage. " + ControllerConstants.AVAILABLE_FOR_ANY_AUTHORIZED_USER) @PostMapping("/account/config/generate") - @PreAuthorize("hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN', 'CUSTOMER_USER', 'ENFORCE_MFA_TOKEN')") + @PreAuthorize("hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN', 'CUSTOMER_USER', 'MFA_CONFIGURATION_TOKEN')") public TwoFaAccountConfig generateTwoFaAccountConfig(@Parameter(description = "2FA provider type to generate new account config for", schema = @Schema(defaultValue = "TOTP", requiredMode = Schema.RequiredMode.REQUIRED)) @RequestParam TwoFaProviderType providerType) throws Exception { SecurityUser user = getCurrentUser(); @@ -137,7 +137,7 @@ public class TwoFactorAuthConfigController extends BaseController { "Will throw an error (Bad Request) if the provider is not configured for usage. " + ControllerConstants.AVAILABLE_FOR_ANY_AUTHORIZED_USER) @PostMapping("/account/config") - @PreAuthorize("hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN', 'CUSTOMER_USER', 'ENFORCE_MFA_TOKEN')") + @PreAuthorize("hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN', 'CUSTOMER_USER', 'MFA_CONFIGURATION_TOKEN')") public AccountTwoFaSettings verifyAndSaveTwoFaAccountConfig(@Valid @RequestBody TwoFaAccountConfig accountConfig, @RequestParam(required = false) String verificationCode) throws Exception { SecurityUser user = getCurrentUser(); @@ -194,7 +194,7 @@ public class TwoFactorAuthConfigController extends BaseController { ControllerConstants.AVAILABLE_FOR_ANY_AUTHORIZED_USER ) @GetMapping("/providers") - @PreAuthorize("hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN', 'CUSTOMER_USER', 'ENFORCE_MFA_TOKEN')") + @PreAuthorize("hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN', 'CUSTOMER_USER', 'MFA_CONFIGURATION_TOKEN')") public List getAvailableTwoFaProviders() throws ThingsboardException { return twoFaConfigManager.getPlatformTwoFaSettings(getTenantId(), true) .map(PlatformTwoFaSettings::getProviders).orElse(Collections.emptyList()).stream() diff --git a/application/src/main/java/org/thingsboard/server/controller/TwoFactorAuthController.java b/application/src/main/java/org/thingsboard/server/controller/TwoFactorAuthController.java index b31db74095..ec5bf0e054 100644 --- a/application/src/main/java/org/thingsboard/server/controller/TwoFactorAuthController.java +++ b/application/src/main/java/org/thingsboard/server/controller/TwoFactorAuthController.java @@ -28,7 +28,6 @@ import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; import org.thingsboard.server.common.data.StringUtils; import org.thingsboard.server.common.data.audit.ActionType; -import org.thingsboard.server.common.data.exception.ThingsboardErrorCode; import org.thingsboard.server.common.data.exception.ThingsboardException; import org.thingsboard.server.common.data.security.model.JwtPair; import org.thingsboard.server.common.data.security.model.mfa.PlatformTwoFaSettings; @@ -90,7 +89,14 @@ public class TwoFactorAuthController extends BaseController { @RequestParam String verificationCode, HttpServletRequest servletRequest) throws Exception { SecurityUser user = getCurrentUser(); boolean verificationSuccess = twoFactorAuthService.checkVerificationCode(user, providerType, verificationCode, true); - return getRegularJwtPair(servletRequest, user, verificationSuccess, "Verification code is incorrect"); + if (verificationSuccess) { + logLogInAction(servletRequest, user, null); + return createTokenPair(user); + } else { + IllegalArgumentException error = new IllegalArgumentException("Verification code is incorrect"); + logLogInAction(servletRequest, user, error); + throw error; + } } @ApiOperation(value = "Get available 2FA providers (getAvailableTwoFaProviders)", notes = @@ -129,28 +135,32 @@ public class TwoFactorAuthController extends BaseController { .collect(Collectors.toList()); } - @ApiOperation(value = "Get regular token pair after successfully saved two factor settings", - notes = "Checks 2FA setting saved, and if it success the method returns a regular access and refresh token pair.") + @ApiOperation(value = "Get regular token pair after successfully configuring 2FA", + notes = "Checks 2FA is configured, returning token pair on success.") @PostMapping("/login") - @PreAuthorize("hasAuthority('ENFORCE_MFA_TOKEN')") - public JwtPair authorizeByTwoFaEnforceToken(HttpServletRequest servletRequest) throws ThingsboardException { + @PreAuthorize("hasAuthority('MFA_CONFIGURATION_TOKEN')") + public JwtPair authenticateByTwoFaConfigurationToken(HttpServletRequest servletRequest) throws ThingsboardException { SecurityUser user = getCurrentUser(); - boolean isEnabled = twoFactorAuthService.isTwoFaEnabled(user.getTenantId(), user.getId()); - return getRegularJwtPair(servletRequest, user, isEnabled, "Two factor settings is not set up!"); - } - - private JwtPair getRegularJwtPair(HttpServletRequest servletRequest, SecurityUser user, boolean isAvailable, String errorMessage) throws ThingsboardException { - if (isAvailable) { - systemSecurityService.logLoginAction(user, new RestAuthenticationDetails(servletRequest), ActionType.LOGIN, null); - user = new SecurityUser(userService.findUserById(user.getTenantId(), user.getId()), true, user.getUserPrincipal()); - return tokenFactory.createTokenPair(user); + if (twoFactorAuthService.isTwoFaEnabled(user.getTenantId(), user.getId())) { + logLogInAction(servletRequest, user, null); + return createTokenPair(user); } else { - ThingsboardException error = new ThingsboardException(errorMessage, ThingsboardErrorCode.BAD_REQUEST_PARAMS); - systemSecurityService.logLoginAction(user, new RestAuthenticationDetails(servletRequest), ActionType.LOGIN, error); + IllegalArgumentException error = new IllegalArgumentException("2FA is not configured"); + logLogInAction(servletRequest, user, error); throw error; } } + private JwtPair createTokenPair(SecurityUser user) { + log.debug("[{}][{}] Creating token pair for user", user.getTenantId(), user.getId()); + user = new SecurityUser(userService.findUserById(user.getTenantId(), user.getId()), true, user.getUserPrincipal()); + return tokenFactory.createTokenPair(user); + } + + private void logLogInAction(HttpServletRequest servletRequest, SecurityUser user, Exception error) { + systemSecurityService.logLoginAction(user, new RestAuthenticationDetails(servletRequest), ActionType.LOGIN, error); + } + @Data @AllArgsConstructor @Builder diff --git a/application/src/main/java/org/thingsboard/server/service/security/auth/ForceMfaAuthenticationToken.java b/application/src/main/java/org/thingsboard/server/service/security/auth/MfaConfigurationToken.java similarity index 78% rename from application/src/main/java/org/thingsboard/server/service/security/auth/ForceMfaAuthenticationToken.java rename to application/src/main/java/org/thingsboard/server/service/security/auth/MfaConfigurationToken.java index 5cb0c752ab..b52404bac2 100644 --- a/application/src/main/java/org/thingsboard/server/service/security/auth/ForceMfaAuthenticationToken.java +++ b/application/src/main/java/org/thingsboard/server/service/security/auth/MfaConfigurationToken.java @@ -1,5 +1,5 @@ /** - * Copyright © 2016-2024 The Thingsboard Authors + * 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. @@ -17,8 +17,8 @@ package org.thingsboard.server.service.security.auth; import org.thingsboard.server.service.security.model.SecurityUser; -public class ForceMfaAuthenticationToken extends AbstractJwtAuthenticationToken { - public ForceMfaAuthenticationToken(SecurityUser securityUser) { +public class MfaConfigurationToken extends AbstractJwtAuthenticationToken { + public MfaConfigurationToken(SecurityUser securityUser) { super(securityUser); } } diff --git a/application/src/main/java/org/thingsboard/server/service/security/auth/mfa/DefaultTwoFactorAuthService.java b/application/src/main/java/org/thingsboard/server/service/security/auth/mfa/DefaultTwoFactorAuthService.java index 2b172f3588..c4683f5822 100644 --- a/application/src/main/java/org/thingsboard/server/service/security/auth/mfa/DefaultTwoFactorAuthService.java +++ b/application/src/main/java/org/thingsboard/server/service/security/auth/mfa/DefaultTwoFactorAuthService.java @@ -28,6 +28,7 @@ import org.thingsboard.server.common.data.exception.ThingsboardException; import org.thingsboard.server.common.data.id.TenantId; import org.thingsboard.server.common.data.id.UserId; import org.thingsboard.server.common.data.limit.LimitedApi; +import org.thingsboard.server.common.data.notification.targets.platform.SystemLevelUsersFilter; import org.thingsboard.server.common.data.security.model.mfa.PlatformTwoFaSettings; import org.thingsboard.server.common.data.security.model.mfa.account.TwoFaAccountConfig; import org.thingsboard.server.common.data.security.model.mfa.provider.TwoFaProviderConfig; @@ -68,10 +69,16 @@ public class DefaultTwoFactorAuthService implements TwoFactorAuthService { } @Override - public boolean isEnforceTwoFaEnabled(TenantId tenantId) { - return configManager.getPlatformTwoFaSettings(tenantId, true) - .map(PlatformTwoFaSettings::isEnforceTwoFa) - .orElse(false); + public boolean isEnforceTwoFaEnabled(TenantId tenantId, User user) { + SystemLevelUsersFilter enforcedUsersFilter = configManager.getPlatformTwoFaSettings(TenantId.SYS_TENANT_ID, true) + .filter(PlatformTwoFaSettings::isEnforceTwoFa) + .map(PlatformTwoFaSettings::getEnforcedUsersFilter) + .orElse(null); + if (enforcedUsersFilter == null) { + return false; + } + + return userService.matchesFilter(tenantId, enforcedUsersFilter, user); } @Override diff --git a/application/src/main/java/org/thingsboard/server/service/security/auth/mfa/TwoFactorAuthService.java b/application/src/main/java/org/thingsboard/server/service/security/auth/mfa/TwoFactorAuthService.java index b8cc08bd1e..d77d8d65a0 100644 --- a/application/src/main/java/org/thingsboard/server/service/security/auth/mfa/TwoFactorAuthService.java +++ b/application/src/main/java/org/thingsboard/server/service/security/auth/mfa/TwoFactorAuthService.java @@ -27,7 +27,7 @@ public interface TwoFactorAuthService { boolean isTwoFaEnabled(TenantId tenantId, UserId userId); - boolean isEnforceTwoFaEnabled(TenantId tenantId); + boolean isEnforceTwoFaEnabled(TenantId tenantId, User user); void checkProvider(TenantId tenantId, TwoFaProviderType providerType) throws ThingsboardException; diff --git a/application/src/main/java/org/thingsboard/server/service/security/auth/mfa/config/DefaultTwoFaConfigManager.java b/application/src/main/java/org/thingsboard/server/service/security/auth/mfa/config/DefaultTwoFaConfigManager.java index a9ef1e4a47..f3afc7fdc2 100644 --- a/application/src/main/java/org/thingsboard/server/service/security/auth/mfa/config/DefaultTwoFaConfigManager.java +++ b/application/src/main/java/org/thingsboard/server/service/security/auth/mfa/config/DefaultTwoFaConfigManager.java @@ -167,8 +167,8 @@ public class DefaultTwoFaConfigManager implements TwoFaConfigManager { for (TwoFaProviderConfig providerConfig : twoFactorAuthSettings.getProviders()) { twoFactorAuthService.checkProvider(tenantId, providerConfig.getProviderType()); } - if (twoFactorAuthSettings.isEnforceTwoFa() && twoFactorAuthSettings.getProviders().isEmpty()) { - throw new DataValidationException("At least one 2FA provider is required if enforce enabled!"); + if (tenantId.isSysTenantId() && twoFactorAuthSettings.isEnforceTwoFa() && twoFactorAuthSettings.getProviders().isEmpty()) { + throw new DataValidationException("At least one 2FA provider is required if enforcing is enabled"); } AdminSettings settings = Optional.ofNullable(adminSettingsService.findAdminSettingsByKey(tenantId, TWO_FACTOR_AUTH_SETTINGS_KEY)) .orElseGet(() -> { 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 651067f045..dc753400e4 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 @@ -43,7 +43,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.ForceMfaAuthenticationToken; +import org.thingsboard.server.service.security.auth.MfaConfigurationToken; import org.thingsboard.server.service.security.auth.MfaAuthenticationToken; import org.thingsboard.server.service.security.auth.mfa.TwoFactorAuthService; import org.thingsboard.server.service.security.exception.UserPasswordNotValidException; @@ -83,11 +83,10 @@ public class RestAuthenticationProvider implements AuthenticationProvider { Assert.notNull(authentication, "No authentication data provided"); Object principal = authentication.getPrincipal(); - if (!(principal instanceof UserPrincipal)) { + if (!(principal instanceof UserPrincipal userPrincipal)) { throw new BadCredentialsException("Authentication Failed. Bad user principal."); } - UserPrincipal userPrincipal = (UserPrincipal) principal; SecurityUser securityUser; if (userPrincipal.getType() == UserPrincipal.Type.USER_NAME) { String username = userPrincipal.getValue(); @@ -106,8 +105,8 @@ public class RestAuthenticationProvider implements AuthenticationProvider { securityUser = authenticateByUsernameAndPassword(authentication, userPrincipal, username, password); if (twoFactorAuthService.isTwoFaEnabled(securityUser.getTenantId(), securityUser.getId())) { return new MfaAuthenticationToken(securityUser); - } else if (twoFactorAuthService.isEnforceTwoFaEnabled(securityUser.getTenantId())) { - return new ForceMfaAuthenticationToken(securityUser); + } else if (twoFactorAuthService.isEnforceTwoFaEnabled(securityUser.getTenantId(), securityUser)) { + return new MfaConfigurationToken(securityUser); } else { systemSecurityService.logLoginAction(securityUser, authentication.getDetails(), ActionType.LOGIN, null); } diff --git a/application/src/main/java/org/thingsboard/server/service/security/auth/rest/RestAwareAuthenticationSuccessHandler.java b/application/src/main/java/org/thingsboard/server/service/security/auth/rest/RestAwareAuthenticationSuccessHandler.java index 9b60f29d0b..a21aab3f66 100644 --- a/application/src/main/java/org/thingsboard/server/service/security/auth/rest/RestAwareAuthenticationSuccessHandler.java +++ b/application/src/main/java/org/thingsboard/server/service/security/auth/rest/RestAwareAuthenticationSuccessHandler.java @@ -15,11 +15,11 @@ */ package org.thingsboard.server.service.security.auth.rest; -import jakarta.servlet.ServletException; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; import jakarta.servlet.http.HttpSession; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; import org.springframework.http.HttpStatus; import org.springframework.http.MediaType; import org.springframework.security.core.Authentication; @@ -29,8 +29,8 @@ import org.springframework.stereotype.Component; import org.thingsboard.common.util.JacksonUtil; import org.thingsboard.server.common.data.security.Authority; import org.thingsboard.server.common.data.security.model.JwtPair; -import org.thingsboard.server.service.security.auth.ForceMfaAuthenticationToken; import org.thingsboard.server.service.security.auth.MfaAuthenticationToken; +import org.thingsboard.server.service.security.auth.MfaConfigurationToken; import org.thingsboard.server.service.security.auth.mfa.config.TwoFaConfigManager; import org.thingsboard.server.service.security.model.SecurityUser; import org.thingsboard.server.service.security.model.token.JwtTokenFactory; @@ -39,7 +39,7 @@ import java.io.IOException; import java.util.Optional; import java.util.concurrent.TimeUnit; -@Component(value = "defaultAuthenticationSuccessHandler") +@Slf4j @Component(value = "defaultAuthenticationSuccessHandler") @RequiredArgsConstructor public class RestAwareAuthenticationSuccessHandler implements AuthenticationSuccessHandler { private final JwtTokenFactory tokenFactory; @@ -47,15 +47,14 @@ public class RestAwareAuthenticationSuccessHandler implements AuthenticationSucc @Override public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, - Authentication authentication) throws IOException, ServletException { + Authentication authentication) throws IOException { SecurityUser securityUser = (SecurityUser) authentication.getPrincipal(); JwtPair tokenPair; if (authentication instanceof MfaAuthenticationToken) { tokenPair = createMfaTokenPair(securityUser, Authority.PRE_VERIFICATION_TOKEN); - } - else if (authentication instanceof ForceMfaAuthenticationToken) { - tokenPair = createMfaTokenPair(securityUser, Authority.ENFORCE_MFA_TOKEN); + } else if (authentication instanceof MfaConfigurationToken) { + tokenPair = createMfaTokenPair(securityUser, Authority.MFA_CONFIGURATION_TOKEN); } else { tokenPair = tokenFactory.createTokenPair(securityUser); } @@ -68,6 +67,7 @@ public class RestAwareAuthenticationSuccessHandler implements AuthenticationSucc } public JwtPair createMfaTokenPair(SecurityUser securityUser, Authority scope) { + log.debug("[{}][{}] Creating {} token", securityUser.getTenantId(), securityUser.getId(), scope); JwtPair tokenPair = new JwtPair(); int preVerificationTokenLifetime = twoFaConfigManager.getPlatformTwoFaSettings(securityUser.getTenantId(), true) .flatMap(settings -> Optional.ofNullable(settings.getTotalAllowedTimeForVerification()) diff --git a/application/src/main/java/org/thingsboard/server/service/security/model/token/JwtTokenFactory.java b/application/src/main/java/org/thingsboard/server/service/security/model/token/JwtTokenFactory.java index fb38e251d7..9dac000d4b 100644 --- a/application/src/main/java/org/thingsboard/server/service/security/model/token/JwtTokenFactory.java +++ b/application/src/main/java/org/thingsboard/server/service/security/model/token/JwtTokenFactory.java @@ -136,7 +136,7 @@ public class JwtTokenFactory { } UserPrincipal principal; - if (authority != Authority.PRE_VERIFICATION_TOKEN && authority != Authority.ENFORCE_MFA_TOKEN) { + if (authority != Authority.PRE_VERIFICATION_TOKEN && authority != Authority.MFA_CONFIGURATION_TOKEN) { securityUser.setFirstName(claims.get(FIRST_NAME, String.class)); securityUser.setLastName(claims.get(LAST_NAME, String.class)); securityUser.setEnabled(claims.get(ENABLED, Boolean.class)); 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 b93ff0f623..43cab3ca3f 100644 --- a/application/src/test/java/org/thingsboard/server/controller/AbstractWebTest.java +++ b/application/src/test/java/org/thingsboard/server/controller/AbstractWebTest.java @@ -138,6 +138,7 @@ import org.thingsboard.server.common.data.relation.EntityRelation; import org.thingsboard.server.common.data.security.Authority; import org.thingsboard.server.common.data.security.DeviceCredentials; import org.thingsboard.server.common.data.security.DeviceCredentialsType; +import org.thingsboard.server.common.data.security.model.JwtPair; import org.thingsboard.server.common.data.tenant.profile.DefaultTenantProfileConfiguration; import org.thingsboard.server.common.data.tenant.profile.TenantProfileData; import org.thingsboard.server.common.msg.session.FeatureType; @@ -203,7 +204,7 @@ public abstract class AbstractWebTest extends AbstractInMemoryStorageTest { protected static final String TENANT_ADMIN_PASSWORD = "tenant"; protected static final String DIFFERENT_TENANT_ADMIN_EMAIL = "testdifftenant@thingsboard.org"; - private static final String DIFFERENT_TENANT_ADMIN_PASSWORD = "difftenant"; + protected static final String DIFFERENT_TENANT_ADMIN_PASSWORD = "difftenant"; protected static final String CUSTOMER_USER_EMAIL = "testcustomer@thingsboard.org"; private static final String CUSTOMER_USER_PASSWORD = "customer"; @@ -596,8 +597,13 @@ public abstract class AbstractWebTest extends AbstractInMemoryStorageTest { Assert.assertNotNull(tokenInfo); Assert.assertTrue(tokenInfo.has("token")); Assert.assertTrue(tokenInfo.has("refreshToken")); - String token = tokenInfo.get("token").asText(); - String refreshToken = tokenInfo.get("refreshToken").asText(); + validateAndSetJwtToken(JacksonUtil.treeToValue(tokenInfo, JwtPair.class), username); + } + + protected void validateAndSetJwtToken(JwtPair jwtPair, String username) { + Assert.assertNotNull(jwtPair); + String token = jwtPair.getToken(); + String refreshToken = jwtPair.getRefreshToken(); validateJwtToken(token, username); validateJwtToken(refreshToken, username); this.token = token; diff --git a/application/src/test/java/org/thingsboard/server/controller/TwoFactorAuthTest.java b/application/src/test/java/org/thingsboard/server/controller/TwoFactorAuthTest.java index c5aea9a773..ca578f3830 100644 --- a/application/src/test/java/org/thingsboard/server/controller/TwoFactorAuthTest.java +++ b/application/src/test/java/org/thingsboard/server/controller/TwoFactorAuthTest.java @@ -34,6 +34,7 @@ import org.thingsboard.server.common.data.audit.ActionType; import org.thingsboard.server.common.data.audit.AuditLog; import org.thingsboard.server.common.data.exception.ThingsboardException; import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.common.data.notification.targets.platform.TenantAdministratorsFilter; import org.thingsboard.server.common.data.page.PageLink; import org.thingsboard.server.common.data.page.SortOrder; import org.thingsboard.server.common.data.page.TimePageLink; @@ -59,6 +60,7 @@ import java.time.Duration; import java.util.Arrays; import java.util.List; import java.util.Map; +import java.util.Set; import java.util.concurrent.TimeUnit; import java.util.function.Consumer; import java.util.stream.Collectors; @@ -67,10 +69,6 @@ import java.util.stream.Stream; import static org.assertj.core.api.Assertions.assertThat; import static org.awaitility.Awaitility.await; import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertNotNull; -import static org.junit.jupiter.api.Assertions.assertNull; -import static org.junit.jupiter.api.Assertions.assertTrue; import static org.junit.jupiter.api.Assertions.fail; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.eq; @@ -121,7 +119,7 @@ public class TwoFactorAuthTest extends AbstractControllerTest { public void testTwoFa_totp() throws Exception { TotpTwoFaAccountConfig totpTwoFaAccountConfig = configureTotpTwoFa(); - logInWithPreVerificationToken(username, password); + logInWithMfaToken(username, password, Authority.PRE_VERIFICATION_TOKEN); doPost("/api/auth/2fa/verification/send?providerType=TOTP") .andExpect(status().isOk()); @@ -141,7 +139,7 @@ public class TwoFactorAuthTest extends AbstractControllerTest { public void testTwoFa_sms() throws Exception { configureSmsTwoFa(); - logInWithPreVerificationToken(username, password); + logInWithMfaToken(username, password, Authority.PRE_VERIFICATION_TOKEN); doPost("/api/auth/2fa/verification/send?providerType=SMS") .andExpect(status().isOk()); @@ -165,7 +163,7 @@ public class TwoFactorAuthTest extends AbstractControllerTest { twoFaSettings.setTotalAllowedTimeForVerification(65); }); - logInWithPreVerificationToken(username, password); + logInWithMfaToken(username, password, Authority.PRE_VERIFICATION_TOKEN); await("expiration of the pre-verification token") .atLeast(Duration.ofSeconds(30).plusMillis(500)) @@ -182,7 +180,7 @@ public class TwoFactorAuthTest extends AbstractControllerTest { twoFaSettings.setMaxVerificationFailuresBeforeUserLockout(10); }); - logInWithPreVerificationToken(username, password); + logInWithMfaToken(username, password, Authority.PRE_VERIFICATION_TOKEN); Stream.generate(() -> StringUtils.randomNumeric(6)) .limit(9) @@ -211,7 +209,7 @@ public class TwoFactorAuthTest extends AbstractControllerTest { twoFaSettings.setMinVerificationCodeSendPeriod(10); }); - logInWithPreVerificationToken(username, password); + logInWithMfaToken(username, password, Authority.PRE_VERIFICATION_TOKEN); doPost("/api/auth/2fa/verification/send?providerType=TOTP") .andExpect(status().isOk()); @@ -235,7 +233,7 @@ public class TwoFactorAuthTest extends AbstractControllerTest { twoFaSettings.setVerificationCodeCheckRateLimit("3:10"); }); - logInWithPreVerificationToken(username, password); + logInWithMfaToken(username, password, Authority.PRE_VERIFICATION_TOKEN); for (int i = 0; i < 3; i++) { String incorrectVerificationCodeError = getErrorMessage(doPost("/api/auth/2fa/verification/check?providerType=TOTP&verificationCode=incorrect") @@ -263,7 +261,7 @@ public class TwoFactorAuthTest extends AbstractControllerTest { @Test public void testCheckVerificationCode_invalidVerificationCode() throws Exception { configureTotpTwoFa(); - logInWithPreVerificationToken(username, password); + logInWithMfaToken(username, password, Authority.PRE_VERIFICATION_TOKEN); for (String invalidVerificationCode : new String[]{"1234567", "ab1212", "12311 ", "oewkriwejqf"}) { String errorMessage = getErrorMessage(doPost("/api/auth/2fa/verification/check?providerType=TOTP&verificationCode=" + invalidVerificationCode) @@ -278,7 +276,7 @@ public class TwoFactorAuthTest extends AbstractControllerTest { smsTwoFaProviderConfig.setVerificationCodeLifetime(10); }); - logInWithPreVerificationToken(username, password); + logInWithMfaToken(username, password, Authority.PRE_VERIFICATION_TOKEN); ArgumentCaptor verificationCodeCaptor = ArgumentCaptor.forClass(String.class); doPost("/api/auth/2fa/verification/send?providerType=SMS").andExpect(status().isOk()); @@ -301,7 +299,7 @@ public class TwoFactorAuthTest extends AbstractControllerTest { public void testTwoFa_logLoginAction() throws Exception { TotpTwoFaAccountConfig totpTwoFaAccountConfig = configureTotpTwoFa(); - logInWithPreVerificationToken(username, password); + logInWithMfaToken(username, password, Authority.PRE_VERIFICATION_TOKEN); await("async audit log saving").during(1, TimeUnit.SECONDS); doPost("/api/auth/2fa/verification/check?providerType=TOTP&verificationCode=incorrect") @@ -383,7 +381,7 @@ public class TwoFactorAuthTest extends AbstractControllerTest { emailTwoFaAccountConfig.setEmail(twoFaUser.getEmail()); twoFaConfigManager.saveTwoFaAccountConfig(tenantId, twoFaUser.getId(), emailTwoFaAccountConfig); - logInWithPreVerificationToken(twoFaUser.getEmail(), "12345678"); + logInWithMfaToken(twoFaUser.getEmail(), "12345678", Authority.PRE_VERIFICATION_TOKEN); Map providersInfos = readResponse(doGet("/api/auth/2fa/providers").andExpect(status().isOk()), new TypeReference>() {}).stream() .collect(Collectors.toMap(TwoFactorAuthController.TwoFaProviderInfo::getType, v -> v)); @@ -401,7 +399,7 @@ public class TwoFactorAuthTest extends AbstractControllerTest { } @Test - public void testEnforceTwoFactorSetting() throws Exception { + public void testEnforceTwoFa() throws Exception { TotpTwoFaProviderConfig totpTwoFaProviderConfig = new TotpTwoFaProviderConfig(); totpTwoFaProviderConfig.setIssuerName("tb"); @@ -410,14 +408,13 @@ public class TwoFactorAuthTest extends AbstractControllerTest { twoFaSettings.setMinVerificationCodeSendPeriod(5); twoFaSettings.setTotalAllowedTimeForVerification(100); twoFaSettings.setEnforceTwoFa(true); + TenantAdministratorsFilter enforcedUsersFilter = new TenantAdministratorsFilter(); + enforcedUsersFilter.setTenantsIds(Set.of(tenantId.getId())); + twoFaSettings.setEnforcedUsersFilter(enforcedUsersFilter); twoFaSettings = twoFaConfigManager.savePlatformTwoFaSettings(TenantId.SYS_TENANT_ID, twoFaSettings); - JsonNode node = readResponse(doPost("/api/auth/login", new LoginRequest(username, password)).andExpect(status().isOk()), JsonNode.class); - assertNotNull(node.get("token").asText()); - assertNull(node.get("refreshToken")); - assertEquals(node.get("scope").asText(), Authority.ENFORCE_MFA_TOKEN.name()); + logInWithMfaToken(username, password, Authority.MFA_CONFIGURATION_TOKEN); - this.token = node.get("token").asText(); TotpTwoFaAccountConfig totpTwoFaAccountConfig = (TotpTwoFaAccountConfig) twoFactorAuthService.generateNewAccountConfig(user, totpTwoFaProviderConfig.getProviderType()); String secret = UriComponentsBuilder.fromUriString(totpTwoFaAccountConfig.getAuthUrl()).build() .getQueryParams().getFirst("secret"); @@ -425,24 +422,27 @@ public class TwoFactorAuthTest extends AbstractControllerTest { readResponse(doPost("/api/2fa/account/config?verificationCode=" + verificationCode, totpTwoFaAccountConfig).andExpect(status().isOk()), JsonNode.class); JwtPair tokenPair = readResponse(doPost("/api/auth/2fa/login").andExpect(status().isOk()), JwtPair.class); - assertNotNull(tokenPair); + assertThat(tokenPair.getToken()).isNotEmpty(); + assertThat(tokenPair.getRefreshToken()).isNotEmpty(); + validateAndSetJwtToken(tokenPair, username); - this.token = tokenPair.getToken(); - this.refreshToken = tokenPair.getRefreshToken(); + doGet("/api/user/" + user.getId()).andExpect(status().isOk()); + // verifying enforced users filter + createDifferentTenant(); doGet("/api/user/" + user.getId()).andExpect(status().isOk()); twoFaSettings.setEnforceTwoFa(false); twoFaConfigManager.savePlatformTwoFaSettings(TenantId.SYS_TENANT_ID, twoFaSettings); } - private void logInWithPreVerificationToken(String username, String password) throws Exception { + private void logInWithMfaToken(String username, String password, Authority expectedScope) throws Exception { LoginRequest loginRequest = new LoginRequest(username, password); JwtPair response = readResponse(doPost("/api/auth/login", loginRequest).andExpect(status().isOk()), JwtPair.class); assertThat(response.getToken()).isNotNull(); assertThat(response.getRefreshToken()).isNull(); - assertThat(response.getScope()).isEqualTo(Authority.PRE_VERIFICATION_TOKEN); + assertThat(response.getScope()).isEqualTo(expectedScope); this.token = response.getToken(); } diff --git a/common/dao-api/src/main/java/org/thingsboard/server/dao/tenant/TbTenantProfileCache.java b/common/dao-api/src/main/java/org/thingsboard/server/dao/tenant/TbTenantProfileCache.java index 8acfbca542..273b52810a 100644 --- a/common/dao-api/src/main/java/org/thingsboard/server/dao/tenant/TbTenantProfileCache.java +++ b/common/dao-api/src/main/java/org/thingsboard/server/dao/tenant/TbTenantProfileCache.java @@ -15,7 +15,6 @@ */ package org.thingsboard.server.dao.tenant; -import org.thingsboard.server.common.data.SystemParams; import org.thingsboard.server.common.data.TenantProfile; import org.thingsboard.server.common.data.id.EntityId; import org.thingsboard.server.common.data.id.TenantId; 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..654de3edae 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 @@ -23,6 +23,8 @@ import org.thingsboard.server.common.data.id.TenantProfileId; import org.thingsboard.server.common.data.id.UserCredentialsId; import org.thingsboard.server.common.data.id.UserId; import org.thingsboard.server.common.data.mobile.MobileSessionInfo; +import org.thingsboard.server.common.data.notification.targets.platform.SystemLevelUsersFilter; +import org.thingsboard.server.common.data.notification.targets.platform.UsersFilter; import org.thingsboard.server.common.data.page.PageData; import org.thingsboard.server.common.data.page.PageLink; import org.thingsboard.server.common.data.security.UserCredentials; @@ -109,4 +111,8 @@ public interface UserService extends EntityDaoService { void removeMobileSession(TenantId tenantId, String mobileToken); + PageData findUsersByFilter(TenantId tenantId, UsersFilter filter, PageLink pageLink); + + boolean matchesFilter(TenantId tenantId, SystemLevelUsersFilter filter, User user); + } diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/notification/targets/platform/AllUsersFilter.java b/common/data/src/main/java/org/thingsboard/server/common/data/notification/targets/platform/AllUsersFilter.java index 79c7dafb1d..0d629fe970 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/notification/targets/platform/AllUsersFilter.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/notification/targets/platform/AllUsersFilter.java @@ -18,7 +18,7 @@ package org.thingsboard.server.common.data.notification.targets.platform; import lombok.Data; @Data -public class AllUsersFilter implements UsersFilter { +public class AllUsersFilter implements SystemLevelUsersFilter { @Override public UsersFilterType getType() { diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/notification/targets/platform/SystemAdministratorsFilter.java b/common/data/src/main/java/org/thingsboard/server/common/data/notification/targets/platform/SystemAdministratorsFilter.java index c178ee1159..4d35488ec1 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/notification/targets/platform/SystemAdministratorsFilter.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/notification/targets/platform/SystemAdministratorsFilter.java @@ -18,7 +18,7 @@ package org.thingsboard.server.common.data.notification.targets.platform; import lombok.Data; @Data -public class SystemAdministratorsFilter implements UsersFilter { +public class SystemAdministratorsFilter implements SystemLevelUsersFilter { @Override public UsersFilterType getType() { diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/notification/targets/platform/SystemLevelUsersFilter.java b/common/data/src/main/java/org/thingsboard/server/common/data/notification/targets/platform/SystemLevelUsersFilter.java new file mode 100644 index 0000000000..20233f7e49 --- /dev/null +++ b/common/data/src/main/java/org/thingsboard/server/common/data/notification/targets/platform/SystemLevelUsersFilter.java @@ -0,0 +1,19 @@ +/** + * 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.common.data.notification.targets.platform; + +public interface SystemLevelUsersFilter extends UsersFilter { +} diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/notification/targets/platform/TenantAdministratorsFilter.java b/common/data/src/main/java/org/thingsboard/server/common/data/notification/targets/platform/TenantAdministratorsFilter.java index 1f78790788..2f72e66076 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/notification/targets/platform/TenantAdministratorsFilter.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/notification/targets/platform/TenantAdministratorsFilter.java @@ -21,7 +21,7 @@ import java.util.Set; import java.util.UUID; @Data -public class TenantAdministratorsFilter implements UsersFilter { +public class TenantAdministratorsFilter implements SystemLevelUsersFilter { private Set tenantsIds; private Set tenantProfilesIds; diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/security/Authority.java b/common/data/src/main/java/org/thingsboard/server/common/data/security/Authority.java index 532bf13e55..deaaf263ca 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/security/Authority.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/security/Authority.java @@ -20,9 +20,10 @@ public enum Authority { SYS_ADMIN(0), TENANT_ADMIN(1), CUSTOMER_USER(2), + REFRESH_TOKEN(10), PRE_VERIFICATION_TOKEN(11), - ENFORCE_MFA_TOKEN(12); + MFA_CONFIGURATION_TOKEN(12); private int code; diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/security/model/mfa/PlatformTwoFaSettings.java b/common/data/src/main/java/org/thingsboard/server/common/data/security/model/mfa/PlatformTwoFaSettings.java index 6d9eded2ae..c1c632f016 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/security/model/mfa/PlatformTwoFaSettings.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/security/model/mfa/PlatformTwoFaSettings.java @@ -21,6 +21,7 @@ import jakarta.validation.constraints.Min; import jakarta.validation.constraints.NotNull; import jakarta.validation.constraints.Pattern; import lombok.Data; +import org.thingsboard.server.common.data.notification.targets.platform.SystemLevelUsersFilter; import org.thingsboard.server.common.data.security.model.mfa.provider.TwoFaProviderConfig; import org.thingsboard.server.common.data.security.model.mfa.provider.TwoFaProviderType; @@ -47,6 +48,7 @@ public class PlatformTwoFaSettings { private Integer totalAllowedTimeForVerification; private boolean enforceTwoFa; + private SystemLevelUsersFilter enforcedUsersFilter; public Optional getProviderConfig(TwoFaProviderType providerType) { return Optional.ofNullable(providers) diff --git a/dao/src/main/java/org/thingsboard/server/dao/notification/DefaultNotificationTargetService.java b/dao/src/main/java/org/thingsboard/server/dao/notification/DefaultNotificationTargetService.java index e873b18c76..1c947552ce 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/notification/DefaultNotificationTargetService.java +++ b/dao/src/main/java/org/thingsboard/server/dao/notification/DefaultNotificationTargetService.java @@ -25,17 +25,13 @@ import org.thingsboard.server.common.data.id.EntityId; import org.thingsboard.server.common.data.id.HasId; import org.thingsboard.server.common.data.id.NotificationTargetId; import org.thingsboard.server.common.data.id.TenantId; -import org.thingsboard.server.common.data.id.TenantProfileId; import org.thingsboard.server.common.data.id.UserId; import org.thingsboard.server.common.data.notification.NotificationRequestStatus; import org.thingsboard.server.common.data.notification.NotificationType; import org.thingsboard.server.common.data.notification.info.RuleOriginatedNotificationInfo; import org.thingsboard.server.common.data.notification.targets.NotificationTarget; import org.thingsboard.server.common.data.notification.targets.NotificationTargetConfig; -import org.thingsboard.server.common.data.notification.targets.platform.CustomerUsersFilter; import org.thingsboard.server.common.data.notification.targets.platform.PlatformUsersNotificationTargetConfig; -import org.thingsboard.server.common.data.notification.targets.platform.TenantAdministratorsFilter; -import org.thingsboard.server.common.data.notification.targets.platform.UserListFilter; import org.thingsboard.server.common.data.notification.targets.platform.UsersFilter; import org.thingsboard.server.common.data.notification.targets.platform.UsersFilterType; import org.thingsboard.server.common.data.page.PageData; @@ -50,9 +46,6 @@ import java.util.List; import java.util.Map; import java.util.Objects; import java.util.Optional; -import java.util.stream.Collectors; - -import static org.apache.commons.collections4.CollectionUtils.isNotEmpty; @Service @Slf4j @@ -115,49 +108,7 @@ public class DefaultNotificationTargetService extends AbstractEntityService impl @Override public PageData findRecipientsForNotificationTargetConfig(TenantId tenantId, PlatformUsersNotificationTargetConfig targetConfig, PageLink pageLink) { UsersFilter usersFilter = targetConfig.getUsersFilter(); - switch (usersFilter.getType()) { - case USER_LIST: { - List users = ((UserListFilter) usersFilter).getUsersIds().stream() - .limit(pageLink.getPageSize()) - .map(UserId::new).map(userId -> userService.findUserById(tenantId, userId)) - .filter(Objects::nonNull).collect(Collectors.toList()); - return new PageData<>(users, 1, users.size(), false); - } - case CUSTOMER_USERS: { - if (tenantId.equals(TenantId.SYS_TENANT_ID)) { - throw new IllegalArgumentException("Customer users target is not supported for system administrator"); - } - CustomerUsersFilter filter = (CustomerUsersFilter) usersFilter; - return userService.findCustomerUsers(tenantId, new CustomerId(filter.getCustomerId()), pageLink); - } - case TENANT_ADMINISTRATORS: { - TenantAdministratorsFilter filter = (TenantAdministratorsFilter) usersFilter; - if (!tenantId.equals(TenantId.SYS_TENANT_ID)) { - return userService.findTenantAdmins(tenantId, pageLink); - } else { - if (isNotEmpty(filter.getTenantsIds())) { - return userService.findTenantAdminsByTenantsIds(filter.getTenantsIds().stream() - .map(TenantId::fromUUID).collect(Collectors.toList()), pageLink); - } else if (isNotEmpty(filter.getTenantProfilesIds())) { - return userService.findTenantAdminsByTenantProfilesIds(filter.getTenantProfilesIds().stream() - .map(TenantProfileId::new).collect(Collectors.toList()), pageLink); - } else { - return userService.findAllTenantAdmins(pageLink); - } - } - } - case SYSTEM_ADMINISTRATORS: - return userService.findSysAdmins(pageLink); - case ALL_USERS: { - if (!tenantId.equals(TenantId.SYS_TENANT_ID)) { - return userService.findUsersByTenantId(tenantId, pageLink); - } else { - return userService.findAllUsers(pageLink); - } - } - default: - throw new IllegalArgumentException("Recipient type not supported"); - } + return userService.findUsersByFilter(tenantId, usersFilter, pageLink); } @Override diff --git a/dao/src/main/java/org/thingsboard/server/dao/tenant/TenantServiceImpl.java b/dao/src/main/java/org/thingsboard/server/dao/tenant/TenantServiceImpl.java index b9d09e55ba..338ca9594e 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/tenant/TenantServiceImpl.java +++ b/dao/src/main/java/org/thingsboard/server/dao/tenant/TenantServiceImpl.java @@ -78,6 +78,7 @@ public class TenantServiceImpl extends AbstractCachedEntityService userValidator; private final DataValidator userCredentialsValidator; private final ApplicationEventPublisher eventPublisher; @@ -496,6 +505,77 @@ public class UserServiceImpl extends AbstractCachedEntityService findUsersByFilter(TenantId tenantId, UsersFilter filter, PageLink pageLink) { + switch (filter.getType()) { + case USER_LIST -> { + List users = ((UserListFilter) filter).getUsersIds().stream() + .limit(pageLink.getPageSize()) + .map(UserId::new).map(userId -> findUserById(tenantId, userId)) + .filter(Objects::nonNull).collect(Collectors.toList()); + return new PageData<>(users, 1, users.size(), false); + } + case CUSTOMER_USERS -> { + if (tenantId.equals(TenantId.SYS_TENANT_ID)) { + throw new IllegalArgumentException("Customer users target is not supported for system administrator"); + } + CustomerUsersFilter customerUsersFilter = (CustomerUsersFilter) filter; + return findCustomerUsers(tenantId, new CustomerId(customerUsersFilter.getCustomerId()), pageLink); + } + case TENANT_ADMINISTRATORS -> { + TenantAdministratorsFilter tenantAdministratorsFilter = (TenantAdministratorsFilter) filter; + if (!tenantId.equals(TenantId.SYS_TENANT_ID)) { + return findTenantAdmins(tenantId, pageLink); + } else { + if (isNotEmpty(tenantAdministratorsFilter.getTenantsIds())) { + return findTenantAdminsByTenantsIds(tenantAdministratorsFilter.getTenantsIds().stream() + .map(TenantId::fromUUID).collect(Collectors.toList()), pageLink); + } else if (isNotEmpty(tenantAdministratorsFilter.getTenantProfilesIds())) { + return findTenantAdminsByTenantProfilesIds(tenantAdministratorsFilter.getTenantProfilesIds().stream() + .map(TenantProfileId::new).collect(Collectors.toList()), pageLink); + } else { + return findAllTenantAdmins(pageLink); + } + } + } + case SYSTEM_ADMINISTRATORS -> { + return findSysAdmins(pageLink); + } + case ALL_USERS -> { + if (!tenantId.equals(TenantId.SYS_TENANT_ID)) { + return findUsersByTenantId(tenantId, pageLink); + } else { + return findAllUsers(pageLink); + } + } + default -> throw new IllegalArgumentException("Recipient type not supported"); + } + } + + @Override + public boolean matchesFilter(TenantId tenantId, SystemLevelUsersFilter filter, User user) { + switch (filter.getType()) { + case TENANT_ADMINISTRATORS -> { + TenantAdministratorsFilter tenantAdministratorsFilter = (TenantAdministratorsFilter) filter; + if (isNotEmpty(tenantAdministratorsFilter.getTenantsIds())) { + return tenantAdministratorsFilter.getTenantsIds().contains(user.getTenantId().getId()); + } else if (isNotEmpty(tenantAdministratorsFilter.getTenantProfilesIds())) { + return tenantAdministratorsFilter.getTenantProfilesIds().contains(tenantProfileCache.get(user.getTenantId()).getUuidId()); + } else { + return user.getAuthority() == Authority.TENANT_ADMIN; + } + } + case SYSTEM_ADMINISTRATORS -> { + return user.getAuthority() == Authority.SYS_ADMIN; + } + case ALL_USERS -> { + return true; + } + default -> throw new IllegalArgumentException("Recipient type not supported"); + } + + } + private void updatePasswordHistory(UserCredentials userCredentials) { JsonNode additionalInfo = userCredentials.getAdditionalInfo(); if (!(additionalInfo instanceof ObjectNode)) { From 85804837db28c9432082f0f221e83e6834203184 Mon Sep 17 00:00:00 2001 From: ViacheslavKlimov Date: Tue, 24 Jun 2025 11:57:19 +0300 Subject: [PATCH 005/102] 2FA enforcement: add more validation, fix test --- .../mfa/config/DefaultTwoFaConfigManager.java | 15 +++++++++++++-- .../server/controller/TwoFactorAuthTest.java | 2 +- 2 files changed, 14 insertions(+), 3 deletions(-) diff --git a/application/src/main/java/org/thingsboard/server/service/security/auth/mfa/config/DefaultTwoFaConfigManager.java b/application/src/main/java/org/thingsboard/server/service/security/auth/mfa/config/DefaultTwoFaConfigManager.java index f3afc7fdc2..24b3d59440 100644 --- a/application/src/main/java/org/thingsboard/server/service/security/auth/mfa/config/DefaultTwoFaConfigManager.java +++ b/application/src/main/java/org/thingsboard/server/service/security/auth/mfa/config/DefaultTwoFaConfigManager.java @@ -167,9 +167,20 @@ public class DefaultTwoFaConfigManager implements TwoFaConfigManager { for (TwoFaProviderConfig providerConfig : twoFactorAuthSettings.getProviders()) { twoFactorAuthService.checkProvider(tenantId, providerConfig.getProviderType()); } - if (tenantId.isSysTenantId() && twoFactorAuthSettings.isEnforceTwoFa() && twoFactorAuthSettings.getProviders().isEmpty()) { - throw new DataValidationException("At least one 2FA provider is required if enforcing is enabled"); + if (tenantId.isSysTenantId()) { + if (twoFactorAuthSettings.isEnforceTwoFa()) { + if (twoFactorAuthSettings.getProviders().isEmpty()) { + throw new DataValidationException("At least one 2FA provider is required if enforcing is enabled"); + } + if (twoFactorAuthSettings.getEnforcedUsersFilter() == null) { + throw new DataValidationException("Users filter to enforce 2FA for is required"); + } + } + } else { + twoFactorAuthSettings.setEnforceTwoFa(false); + twoFactorAuthSettings.setEnforcedUsersFilter(null); } + AdminSettings settings = Optional.ofNullable(adminSettingsService.findAdminSettingsByKey(tenantId, TWO_FACTOR_AUTH_SETTINGS_KEY)) .orElseGet(() -> { AdminSettings newSettings = new AdminSettings(); diff --git a/application/src/test/java/org/thingsboard/server/controller/TwoFactorAuthTest.java b/application/src/test/java/org/thingsboard/server/controller/TwoFactorAuthTest.java index ca578f3830..0da218db67 100644 --- a/application/src/test/java/org/thingsboard/server/controller/TwoFactorAuthTest.java +++ b/application/src/test/java/org/thingsboard/server/controller/TwoFactorAuthTest.java @@ -430,7 +430,7 @@ public class TwoFactorAuthTest extends AbstractControllerTest { // verifying enforced users filter createDifferentTenant(); - doGet("/api/user/" + user.getId()).andExpect(status().isOk()); + doGet("/api/user/" + savedDifferentTenantUser.getId()).andExpect(status().isOk()); twoFaSettings.setEnforceTwoFa(false); twoFaConfigManager.savePlatformTwoFaSettings(TenantId.SYS_TENANT_ID, twoFaSettings); From c4c3d255b0e04f2b8eceaa06d26f9ebeb788b35e Mon Sep 17 00:00:00 2001 From: ViacheslavKlimov Date: Tue, 24 Jun 2025 13:08:02 +0300 Subject: [PATCH 006/102] Add validation for 2FA account settings when enforcement is enabled --- .../TwoFactorAuthConfigController.java | 30 ++-- .../controller/TwoFactorAuthController.java | 14 +- .../security/auth/AuthExceptionHandler.java | 13 ++ .../auth/mfa/DefaultTwoFactorAuthService.java | 8 +- .../auth/mfa/TwoFactorAuthService.java | 3 +- .../mfa/config/DefaultTwoFaConfigManager.java | 35 +++-- .../auth/mfa/config/TwoFaConfigManager.java | 10 +- .../impl/BackupCodeTwoFaProvider.java | 2 +- .../auth/rest/RestAuthenticationProvider.java | 4 +- .../controller/TwoFactorAuthConfigTest.java | 135 ++++++++++-------- .../server/controller/TwoFactorAuthTest.java | 15 +- 11 files changed, 153 insertions(+), 116 deletions(-) diff --git a/application/src/main/java/org/thingsboard/server/controller/TwoFactorAuthConfigController.java b/application/src/main/java/org/thingsboard/server/controller/TwoFactorAuthConfigController.java index fd78e5c945..00829df047 100644 --- a/application/src/main/java/org/thingsboard/server/controller/TwoFactorAuthConfigController.java +++ b/application/src/main/java/org/thingsboard/server/controller/TwoFactorAuthConfigController.java @@ -69,7 +69,7 @@ public class TwoFactorAuthConfigController extends BaseController { @PreAuthorize("hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN', 'CUSTOMER_USER')") public AccountTwoFaSettings getAccountTwoFaSettings() throws ThingsboardException { SecurityUser user = getCurrentUser(); - return twoFaConfigManager.getAccountTwoFaSettings(user.getTenantId(), user.getId()).orElse(null); + return twoFaConfigManager.getAccountTwoFaSettings(user.getTenantId(), user).orElse(null); } @ApiOperation(value = "Generate 2FA account config (generateTwoFaAccountConfig)", @@ -141,7 +141,7 @@ public class TwoFactorAuthConfigController extends BaseController { public AccountTwoFaSettings verifyAndSaveTwoFaAccountConfig(@Valid @RequestBody TwoFaAccountConfig accountConfig, @RequestParam(required = false) String verificationCode) throws Exception { SecurityUser user = getCurrentUser(); - if (twoFaConfigManager.getTwoFaAccountConfig(user.getTenantId(), user.getId(), accountConfig.getProviderType()).isPresent()) { + if (twoFaConfigManager.getTwoFaAccountConfig(user.getTenantId(), user, accountConfig.getProviderType()).isPresent()) { throw new IllegalArgumentException("2FA provider is already configured"); } @@ -152,7 +152,7 @@ public class TwoFactorAuthConfigController extends BaseController { verificationSuccess = true; } if (verificationSuccess) { - return twoFaConfigManager.saveTwoFaAccountConfig(user.getTenantId(), user.getId(), accountConfig); + return twoFaConfigManager.saveTwoFaAccountConfig(user.getTenantId(), user, accountConfig); } else { throw new IllegalArgumentException("Verification code is incorrect"); } @@ -160,38 +160,38 @@ public class TwoFactorAuthConfigController extends BaseController { @ApiOperation(value = "Update 2FA account config (updateTwoFaAccountConfig)", notes = "Update config for a given provider type. \n" + - "Update request example:\n" + - "```\n{\n \"useByDefault\": true\n}\n```\n" + - "Returns whole account's 2FA settings object.\n" + - ControllerConstants.AVAILABLE_FOR_ANY_AUTHORIZED_USER) + "Update request example:\n" + + "```\n{\n \"useByDefault\": true\n}\n```\n" + + "Returns whole account's 2FA settings object.\n" + + ControllerConstants.AVAILABLE_FOR_ANY_AUTHORIZED_USER) @PutMapping("/account/config") @PreAuthorize("hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN', 'CUSTOMER_USER')") public AccountTwoFaSettings updateTwoFaAccountConfig(@RequestParam TwoFaProviderType providerType, @RequestBody TwoFaAccountConfigUpdateRequest updateRequest) throws ThingsboardException { SecurityUser user = getCurrentUser(); - TwoFaAccountConfig accountConfig = twoFaConfigManager.getTwoFaAccountConfig(user.getTenantId(), user.getId(), providerType) + TwoFaAccountConfig accountConfig = twoFaConfigManager.getTwoFaAccountConfig(user.getTenantId(), user, providerType) .orElseThrow(() -> new IllegalArgumentException("Config for " + providerType + " 2FA provider not found")); accountConfig.setUseByDefault(updateRequest.isUseByDefault()); - return twoFaConfigManager.saveTwoFaAccountConfig(user.getTenantId(), user.getId(), accountConfig); + return twoFaConfigManager.saveTwoFaAccountConfig(user.getTenantId(), user, accountConfig); } @ApiOperation(value = "Delete 2FA account config (deleteTwoFaAccountConfig)", notes = "Delete 2FA config for a given 2FA provider type. \n" + - "Returns whole account's 2FA settings object.\n" + - ControllerConstants.AVAILABLE_FOR_ANY_AUTHORIZED_USER) + "Returns whole account's 2FA settings object.\n" + + ControllerConstants.AVAILABLE_FOR_ANY_AUTHORIZED_USER) @DeleteMapping("/account/config") @PreAuthorize("hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN', 'CUSTOMER_USER')") public AccountTwoFaSettings deleteTwoFaAccountConfig(@RequestParam TwoFaProviderType providerType) throws ThingsboardException { SecurityUser user = getCurrentUser(); - return twoFaConfigManager.deleteTwoFaAccountConfig(user.getTenantId(), user.getId(), providerType); + return twoFaConfigManager.deleteTwoFaAccountConfig(user.getTenantId(), user, providerType); } @ApiOperation(value = "Get available 2FA providers (getAvailableTwoFaProviders)", notes = "Get the list of provider types available for user to use (the ones configured by tenant or sysadmin).\n" + - "Example of response:\n" + - "```\n[\n \"TOTP\",\n \"EMAIL\",\n \"SMS\"\n]\n```" + - ControllerConstants.AVAILABLE_FOR_ANY_AUTHORIZED_USER + "Example of response:\n" + + "```\n[\n \"TOTP\",\n \"EMAIL\",\n \"SMS\"\n]\n```" + + ControllerConstants.AVAILABLE_FOR_ANY_AUTHORIZED_USER ) @GetMapping("/providers") @PreAuthorize("hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN', 'CUSTOMER_USER', 'MFA_CONFIGURATION_TOKEN')") diff --git a/application/src/main/java/org/thingsboard/server/controller/TwoFactorAuthController.java b/application/src/main/java/org/thingsboard/server/controller/TwoFactorAuthController.java index ec5bf0e054..0d2af1a4f1 100644 --- a/application/src/main/java/org/thingsboard/server/controller/TwoFactorAuthController.java +++ b/application/src/main/java/org/thingsboard/server/controller/TwoFactorAuthController.java @@ -101,17 +101,17 @@ public class TwoFactorAuthController extends BaseController { @ApiOperation(value = "Get available 2FA providers (getAvailableTwoFaProviders)", notes = "Get the list of 2FA provider infos available for user to use. Example:\n" + - "```\n[\n" + - " {\n \"type\": \"EMAIL\",\n \"default\": true,\n \"contact\": \"ab*****ko@gmail.com\"\n },\n" + - " {\n \"type\": \"TOTP\",\n \"default\": false,\n \"contact\": null\n },\n" + - " {\n \"type\": \"SMS\",\n \"default\": false,\n \"contact\": \"+38********12\"\n }\n" + - "]\n```") + "```\n[\n" + + " {\n \"type\": \"EMAIL\",\n \"default\": true,\n \"contact\": \"ab*****ko@gmail.com\"\n },\n" + + " {\n \"type\": \"TOTP\",\n \"default\": false,\n \"contact\": null\n },\n" + + " {\n \"type\": \"SMS\",\n \"default\": false,\n \"contact\": \"+38********12\"\n }\n" + + "]\n```") @GetMapping("/providers") @PreAuthorize("hasAuthority('PRE_VERIFICATION_TOKEN')") public List getAvailableTwoFaProviders() throws ThingsboardException { SecurityUser user = getCurrentUser(); Optional platformTwoFaSettings = twoFaConfigManager.getPlatformTwoFaSettings(user.getTenantId(), true); - return twoFaConfigManager.getAccountTwoFaSettings(user.getTenantId(), user.getId()) + return twoFaConfigManager.getAccountTwoFaSettings(user.getTenantId(), user) .map(settings -> settings.getConfigs().values()).orElse(Collections.emptyList()) .stream().map(config -> { String contact = null; @@ -141,7 +141,7 @@ public class TwoFactorAuthController extends BaseController { @PreAuthorize("hasAuthority('MFA_CONFIGURATION_TOKEN')") public JwtPair authenticateByTwoFaConfigurationToken(HttpServletRequest servletRequest) throws ThingsboardException { SecurityUser user = getCurrentUser(); - if (twoFactorAuthService.isTwoFaEnabled(user.getTenantId(), user.getId())) { + if (twoFactorAuthService.isTwoFaEnabled(user.getTenantId(), user)) { logLogInAction(servletRequest, user, null); return createTokenPair(user); } else { diff --git a/application/src/main/java/org/thingsboard/server/service/security/auth/AuthExceptionHandler.java b/application/src/main/java/org/thingsboard/server/service/security/auth/AuthExceptionHandler.java index 27ed2996d1..ff39038453 100644 --- a/application/src/main/java/org/thingsboard/server/service/security/auth/AuthExceptionHandler.java +++ b/application/src/main/java/org/thingsboard/server/service/security/auth/AuthExceptionHandler.java @@ -18,8 +18,10 @@ package org.thingsboard.server.service.security.auth; import jakarta.servlet.FilterChain; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; +import lombok.Getter; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; import org.springframework.security.core.AuthenticationException; import org.springframework.stereotype.Component; import org.springframework.web.filter.OncePerRequestFilter; @@ -32,6 +34,10 @@ public class AuthExceptionHandler extends OncePerRequestFilter { private final ThingsboardErrorResponseHandler errorResponseHandler; + @Value("${server.log_controller_error_stack_trace}") + @Getter + private boolean logControllerErrorStackTrace; + @Override protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) { try { @@ -39,8 +45,15 @@ public class AuthExceptionHandler extends OncePerRequestFilter { } catch (AuthenticationException e) { throw e; } catch (Exception e) { + log(e); errorResponseHandler.handle(e, response); } } + private void log(Exception e) { + if (logControllerErrorStackTrace) { + log.error("Auth error", e); + } + } + } diff --git a/application/src/main/java/org/thingsboard/server/service/security/auth/mfa/DefaultTwoFactorAuthService.java b/application/src/main/java/org/thingsboard/server/service/security/auth/mfa/DefaultTwoFactorAuthService.java index c4683f5822..325203314f 100644 --- a/application/src/main/java/org/thingsboard/server/service/security/auth/mfa/DefaultTwoFactorAuthService.java +++ b/application/src/main/java/org/thingsboard/server/service/security/auth/mfa/DefaultTwoFactorAuthService.java @@ -62,8 +62,8 @@ public class DefaultTwoFactorAuthService implements TwoFactorAuthService { private static final ThingsboardException TOO_MANY_REQUESTS_ERROR = new ThingsboardException("Too many requests", ThingsboardErrorCode.TOO_MANY_REQUESTS); @Override - public boolean isTwoFaEnabled(TenantId tenantId, UserId userId) { - return configManager.getAccountTwoFaSettings(tenantId, userId) + public boolean isTwoFaEnabled(TenantId tenantId, User user) { + return configManager.getAccountTwoFaSettings(tenantId, user) .map(settings -> !settings.getConfigs().isEmpty()) .orElse(false); } @@ -89,7 +89,7 @@ public class DefaultTwoFactorAuthService implements TwoFactorAuthService { @Override public void prepareVerificationCode(SecurityUser user, TwoFaProviderType providerType, boolean checkLimits) throws Exception { - TwoFaAccountConfig accountConfig = configManager.getTwoFaAccountConfig(user.getTenantId(), user.getId(), providerType) + TwoFaAccountConfig accountConfig = configManager.getTwoFaAccountConfig(user.getTenantId(), user, providerType) .orElseThrow(() -> ACCOUNT_NOT_CONFIGURED_ERROR); prepareVerificationCode(user, accountConfig, checkLimits); } @@ -118,7 +118,7 @@ public class DefaultTwoFactorAuthService implements TwoFactorAuthService { @Override public boolean checkVerificationCode(SecurityUser user, TwoFaProviderType providerType, String verificationCode, boolean checkLimits) throws ThingsboardException { - TwoFaAccountConfig accountConfig = configManager.getTwoFaAccountConfig(user.getTenantId(), user.getId(), providerType) + TwoFaAccountConfig accountConfig = configManager.getTwoFaAccountConfig(user.getTenantId(), user, providerType) .orElseThrow(() -> ACCOUNT_NOT_CONFIGURED_ERROR); return checkVerificationCode(user, verificationCode, accountConfig, checkLimits); } diff --git a/application/src/main/java/org/thingsboard/server/service/security/auth/mfa/TwoFactorAuthService.java b/application/src/main/java/org/thingsboard/server/service/security/auth/mfa/TwoFactorAuthService.java index d77d8d65a0..62fdb973d2 100644 --- a/application/src/main/java/org/thingsboard/server/service/security/auth/mfa/TwoFactorAuthService.java +++ b/application/src/main/java/org/thingsboard/server/service/security/auth/mfa/TwoFactorAuthService.java @@ -18,14 +18,13 @@ package org.thingsboard.server.service.security.auth.mfa; import org.thingsboard.server.common.data.User; import org.thingsboard.server.common.data.exception.ThingsboardException; import org.thingsboard.server.common.data.id.TenantId; -import org.thingsboard.server.common.data.id.UserId; import org.thingsboard.server.common.data.security.model.mfa.account.TwoFaAccountConfig; import org.thingsboard.server.common.data.security.model.mfa.provider.TwoFaProviderType; import org.thingsboard.server.service.security.model.SecurityUser; public interface TwoFactorAuthService { - boolean isTwoFaEnabled(TenantId tenantId, UserId userId); + boolean isTwoFaEnabled(TenantId tenantId, User user); boolean isEnforceTwoFaEnabled(TenantId tenantId, User user); diff --git a/application/src/main/java/org/thingsboard/server/service/security/auth/mfa/config/DefaultTwoFaConfigManager.java b/application/src/main/java/org/thingsboard/server/service/security/auth/mfa/config/DefaultTwoFaConfigManager.java index 24b3d59440..ad04d3e962 100644 --- a/application/src/main/java/org/thingsboard/server/service/security/auth/mfa/config/DefaultTwoFaConfigManager.java +++ b/application/src/main/java/org/thingsboard/server/service/security/auth/mfa/config/DefaultTwoFaConfigManager.java @@ -21,9 +21,9 @@ import org.springframework.context.annotation.Lazy; import org.springframework.stereotype.Service; import org.thingsboard.common.util.JacksonUtil; import org.thingsboard.server.common.data.AdminSettings; +import org.thingsboard.server.common.data.User; import org.thingsboard.server.common.data.exception.ThingsboardException; import org.thingsboard.server.common.data.id.TenantId; -import org.thingsboard.server.common.data.id.UserId; import org.thingsboard.server.common.data.security.UserAuthSettings; import org.thingsboard.server.common.data.security.model.mfa.PlatformTwoFaSettings; import org.thingsboard.server.common.data.security.model.mfa.account.AccountTwoFaSettings; @@ -56,9 +56,9 @@ public class DefaultTwoFaConfigManager implements TwoFaConfigManager { @Override - public Optional getAccountTwoFaSettings(TenantId tenantId, UserId userId) { + public Optional getAccountTwoFaSettings(TenantId tenantId, User user) { PlatformTwoFaSettings platformTwoFaSettings = getPlatformTwoFaSettings(tenantId, true).orElse(null); - return Optional.ofNullable(userAuthSettingsDao.findByUserId(userId)) + return Optional.ofNullable(userAuthSettingsDao.findByUserId(user.getId())) .map(userAuthSettings -> { AccountTwoFaSettings twoFaSettings = userAuthSettings.getTwoFaSettings(); if (twoFaSettings == null) return null; @@ -80,17 +80,22 @@ public class DefaultTwoFaConfigManager implements TwoFaConfigManager { } if (updateNeeded) { - twoFaSettings = saveAccountTwoFaSettings(tenantId, userId, twoFaSettings); + twoFaSettings = saveAccountTwoFaSettings(tenantId, user, twoFaSettings); } return twoFaSettings; }); } - protected AccountTwoFaSettings saveAccountTwoFaSettings(TenantId tenantId, UserId userId, AccountTwoFaSettings settings) { - UserAuthSettings userAuthSettings = Optional.ofNullable(userAuthSettingsDao.findByUserId(userId)) + protected AccountTwoFaSettings saveAccountTwoFaSettings(TenantId tenantId, User user, AccountTwoFaSettings settings) { + if (settings.getConfigs().isEmpty()) { + if (twoFactorAuthService.isEnforceTwoFaEnabled(tenantId, user)) { + throw new DataValidationException("At least one 2FA provider is required"); + } + } + UserAuthSettings userAuthSettings = Optional.ofNullable(userAuthSettingsDao.findByUserId(user.getId())) .orElseGet(() -> { UserAuthSettings newUserAuthSettings = new UserAuthSettings(); - newUserAuthSettings.setUserId(userId); + newUserAuthSettings.setUserId(user.getId()); return newUserAuthSettings; }); userAuthSettings.setTwoFaSettings(settings); @@ -102,18 +107,18 @@ public class DefaultTwoFaConfigManager implements TwoFaConfigManager { @Override - public Optional getTwoFaAccountConfig(TenantId tenantId, UserId userId, TwoFaProviderType providerType) { - return getAccountTwoFaSettings(tenantId, userId) + public Optional getTwoFaAccountConfig(TenantId tenantId, User user, TwoFaProviderType providerType) { + return getAccountTwoFaSettings(tenantId, user) .map(AccountTwoFaSettings::getConfigs) .flatMap(configs -> Optional.ofNullable(configs.get(providerType))); } @Override - public AccountTwoFaSettings saveTwoFaAccountConfig(TenantId tenantId, UserId userId, TwoFaAccountConfig accountConfig) { + public AccountTwoFaSettings saveTwoFaAccountConfig(TenantId tenantId, User user, TwoFaAccountConfig accountConfig) { getTwoFaProviderConfig(tenantId, accountConfig.getProviderType()) .orElseThrow(() -> new IllegalArgumentException("2FA provider is not configured")); - AccountTwoFaSettings settings = getAccountTwoFaSettings(tenantId, userId).orElseGet(() -> { + AccountTwoFaSettings settings = getAccountTwoFaSettings(tenantId, user).orElseGet(() -> { AccountTwoFaSettings newSettings = new AccountTwoFaSettings(); newSettings.setConfigs(new LinkedHashMap<>()); return newSettings; @@ -129,12 +134,12 @@ public class DefaultTwoFaConfigManager implements TwoFaConfigManager { if (configs.values().stream().noneMatch(TwoFaAccountConfig::isUseByDefault)) { configs.values().stream().findFirst().ifPresent(config -> config.setUseByDefault(true)); } - return saveAccountTwoFaSettings(tenantId, userId, settings); + return saveAccountTwoFaSettings(tenantId, user, settings); } @Override - public AccountTwoFaSettings deleteTwoFaAccountConfig(TenantId tenantId, UserId userId, TwoFaProviderType providerType) { - AccountTwoFaSettings settings = getAccountTwoFaSettings(tenantId, userId) + public AccountTwoFaSettings deleteTwoFaAccountConfig(TenantId tenantId, User user, TwoFaProviderType providerType) { + AccountTwoFaSettings settings = getAccountTwoFaSettings(tenantId, user) .orElseThrow(() -> new IllegalArgumentException("2FA not configured")); settings.getConfigs().remove(providerType); if (settings.getConfigs().size() == 1) { @@ -146,7 +151,7 @@ public class DefaultTwoFaConfigManager implements TwoFaConfigManager { .min(Comparator.comparing(TwoFaAccountConfig::getProviderType)) .ifPresent(config -> config.setUseByDefault(true)); } - return saveAccountTwoFaSettings(tenantId, userId, settings); + return saveAccountTwoFaSettings(tenantId, user, settings); } diff --git a/application/src/main/java/org/thingsboard/server/service/security/auth/mfa/config/TwoFaConfigManager.java b/application/src/main/java/org/thingsboard/server/service/security/auth/mfa/config/TwoFaConfigManager.java index 92ac638298..e0ffd6e510 100644 --- a/application/src/main/java/org/thingsboard/server/service/security/auth/mfa/config/TwoFaConfigManager.java +++ b/application/src/main/java/org/thingsboard/server/service/security/auth/mfa/config/TwoFaConfigManager.java @@ -15,9 +15,9 @@ */ package org.thingsboard.server.service.security.auth.mfa.config; +import org.thingsboard.server.common.data.User; import org.thingsboard.server.common.data.exception.ThingsboardException; import org.thingsboard.server.common.data.id.TenantId; -import org.thingsboard.server.common.data.id.UserId; import org.thingsboard.server.common.data.security.model.mfa.PlatformTwoFaSettings; import org.thingsboard.server.common.data.security.model.mfa.account.AccountTwoFaSettings; import org.thingsboard.server.common.data.security.model.mfa.account.TwoFaAccountConfig; @@ -27,14 +27,14 @@ import java.util.Optional; public interface TwoFaConfigManager { - Optional getAccountTwoFaSettings(TenantId tenantId, UserId userId); + Optional getAccountTwoFaSettings(TenantId tenantId, User user); - Optional getTwoFaAccountConfig(TenantId tenantId, UserId userId, TwoFaProviderType providerType); + Optional getTwoFaAccountConfig(TenantId tenantId, User user, TwoFaProviderType providerType); - AccountTwoFaSettings saveTwoFaAccountConfig(TenantId tenantId, UserId userId, TwoFaAccountConfig accountConfig); + AccountTwoFaSettings saveTwoFaAccountConfig(TenantId tenantId, User user, TwoFaAccountConfig accountConfig); - AccountTwoFaSettings deleteTwoFaAccountConfig(TenantId tenantId, UserId userId, TwoFaProviderType providerType); + AccountTwoFaSettings deleteTwoFaAccountConfig(TenantId tenantId, User user, TwoFaProviderType providerType); Optional getPlatformTwoFaSettings(TenantId tenantId, boolean sysadminSettingsAsDefault); diff --git a/application/src/main/java/org/thingsboard/server/service/security/auth/mfa/provider/impl/BackupCodeTwoFaProvider.java b/application/src/main/java/org/thingsboard/server/service/security/auth/mfa/provider/impl/BackupCodeTwoFaProvider.java index 745b651408..672db5dddd 100644 --- a/application/src/main/java/org/thingsboard/server/service/security/auth/mfa/provider/impl/BackupCodeTwoFaProvider.java +++ b/application/src/main/java/org/thingsboard/server/service/security/auth/mfa/provider/impl/BackupCodeTwoFaProvider.java @@ -58,7 +58,7 @@ public class BackupCodeTwoFaProvider implements TwoFaProvider { - UriComponents otpAuthUrl = UriComponentsBuilder.fromUriString(accountConfig.getAuthUrl()).build(); - assertThat(otpAuthUrl.getScheme()).isEqualTo("otpauth"); - assertThat(otpAuthUrl.getHost()).isEqualTo("totp"); - assertThat(otpAuthUrl.getQueryParams().getFirst("issuer")).isEqualTo(totpTwoFaProviderConfig.getIssuerName()); - assertThat(otpAuthUrl.getPath()).isEqualTo("/%s:%s", totpTwoFaProviderConfig.getIssuerName(), TENANT_ADMIN_EMAIL); - assertThat(otpAuthUrl.getQueryParams().getFirst("secret")).satisfies(secretKey -> { - assertDoesNotThrow(() -> Base32.decode(secretKey)); - }); - }); - return (TotpTwoFaAccountConfig) generatedTwoFaAccountConfig; - } - @Test public void testGetTwoFaAccountConfig_whenProviderNotConfigured() throws Exception { testVerifyAndSaveTotpTwoFaAccountConfig(); @@ -434,6 +409,56 @@ public class TwoFactorAuthConfigTest extends AbstractControllerTest { assertThat(accountConfig).isEqualTo(initialSmsTwoFaAccountConfig); } + @Test + public void testIsTwoFaEnabled() throws Exception { + configureSmsTwoFaProvider("${code}"); + SmsTwoFaAccountConfig accountConfig = new SmsTwoFaAccountConfig(); + accountConfig.setPhoneNumber("+38050505050"); + twoFaConfigManager.saveTwoFaAccountConfig(tenantId, tenantAdminUser, accountConfig); + + assertThat(twoFactorAuthService.isTwoFaEnabled(tenantId, tenantAdminUser)).isTrue(); + } + + @Test + public void testDeleteTwoFaAccountConfig() throws Exception { + configureSmsTwoFaProvider("${code}"); + loginTenantAdmin(); + SmsTwoFaAccountConfig accountConfig = new SmsTwoFaAccountConfig(); + accountConfig.setPhoneNumber("+38050505050"); + twoFaConfigManager.saveTwoFaAccountConfig(tenantId, tenantAdminUser, accountConfig); + + AccountTwoFaSettings accountTwoFaSettings = readResponse(doGet("/api/2fa/account/settings").andExpect(status().isOk()), AccountTwoFaSettings.class); + TwoFaAccountConfig savedAccountConfig = accountTwoFaSettings.getConfigs().get(TwoFaProviderType.SMS); + assertThat(savedAccountConfig).isEqualTo(accountConfig); + + PlatformTwoFaSettings twoFaSettings = twoFaConfigManager.getPlatformTwoFaSettings(TenantId.SYS_TENANT_ID, true).get(); + twoFaSettings.setEnforceTwoFa(true); + TenantAdministratorsFilter enforcedUsersFilter = new TenantAdministratorsFilter(); + enforcedUsersFilter.setTenantsIds(Set.of(tenantId.getId())); + twoFaSettings.setEnforcedUsersFilter(enforcedUsersFilter); + twoFaConfigManager.savePlatformTwoFaSettings(TenantId.SYS_TENANT_ID, twoFaSettings); + + String errorMessage = getErrorMessage(doDelete("/api/2fa/account/config?providerType=SMS") + .andExpect(status().isBadRequest())); + assertThat(errorMessage).isEqualTo("At least one 2FA provider is required"); + + twoFaSettings.setEnforceTwoFa(false); + twoFaConfigManager.savePlatformTwoFaSettings(TenantId.SYS_TENANT_ID, twoFaSettings); + + doDelete("/api/2fa/account/config?providerType=SMS").andExpect(status().isOk()); + + assertThat(readResponse(doGet("/api/2fa/account/settings").andExpect(status().isOk()), AccountTwoFaSettings.class).getConfigs()) + .doesNotContainKey(TwoFaProviderType.SMS); + } + + private PlatformTwoFaSettings findTwoFaSettings() throws Exception { + return doGet("/api/2fa/settings", PlatformTwoFaSettings.class); + } + + private void saveTwoFaSettings(PlatformTwoFaSettings twoFaSettings) throws Exception { + doPost("/api/2fa/settings", twoFaSettings).andExpect(status().isOk()); + } + private TotpTwoFaProviderConfig configureTotpTwoFaProvider() throws Exception { TotpTwoFaProviderConfig totpTwoFaProviderConfig = new TotpTwoFaProviderConfig(); totpTwoFaProviderConfig.setIssuerName("tb"); @@ -456,37 +481,35 @@ public class TwoFactorAuthConfigTest extends AbstractControllerTest { twoFaSettings.setProviders(Arrays.stream(providerConfigs).collect(Collectors.toList())); twoFaSettings.setMinVerificationCodeSendPeriod(5); twoFaSettings.setTotalAllowedTimeForVerification(100); - doPost("/api/2fa/settings", twoFaSettings).andExpect(status().isOk()); + saveTwoFaSettings(twoFaSettings); } - @Test - public void testIsTwoFaEnabled() throws Exception { - configureSmsTwoFaProvider("${code}"); - SmsTwoFaAccountConfig accountConfig = new SmsTwoFaAccountConfig(); - accountConfig.setPhoneNumber("+38050505050"); - twoFaConfigManager.saveTwoFaAccountConfig(tenantId, tenantAdminUserId, accountConfig); + private TotpTwoFaAccountConfig generateTotpTwoFaAccountConfig(TotpTwoFaProviderConfig totpTwoFaProviderConfig) throws Exception { + TwoFaAccountConfig generatedTwoFaAccountConfig = readResponse(doPost("/api/2fa/account/config/generate?providerType=TOTP") + .andExpect(status().isOk()), TwoFaAccountConfig.class); + assertThat(generatedTwoFaAccountConfig).isInstanceOf(TotpTwoFaAccountConfig.class); - assertThat(twoFactorAuthService.isTwoFaEnabled(tenantId, tenantAdminUserId)).isTrue(); + assertThat(((TotpTwoFaAccountConfig) generatedTwoFaAccountConfig)).satisfies(accountConfig -> { + UriComponents otpAuthUrl = UriComponentsBuilder.fromUriString(accountConfig.getAuthUrl()).build(); + assertThat(otpAuthUrl.getScheme()).isEqualTo("otpauth"); + assertThat(otpAuthUrl.getHost()).isEqualTo("totp"); + assertThat(otpAuthUrl.getQueryParams().getFirst("issuer")).isEqualTo(totpTwoFaProviderConfig.getIssuerName()); + assertThat(otpAuthUrl.getPath()).isEqualTo("/%s:%s", totpTwoFaProviderConfig.getIssuerName(), TENANT_ADMIN_EMAIL); + assertThat(otpAuthUrl.getQueryParams().getFirst("secret")).satisfies(secretKey -> { + assertDoesNotThrow(() -> Base32.decode(secretKey)); + }); + }); + return (TotpTwoFaAccountConfig) generatedTwoFaAccountConfig; } - @Test - public void testDeleteTwoFaAccountConfig() throws Exception { - configureSmsTwoFaProvider("${code}"); - SmsTwoFaAccountConfig accountConfig = new SmsTwoFaAccountConfig(); - accountConfig.setPhoneNumber("+38050505050"); - - loginTenantAdmin(); - - twoFaConfigManager.saveTwoFaAccountConfig(tenantId, tenantAdminUserId, accountConfig); - - AccountTwoFaSettings accountTwoFaSettings = readResponse(doGet("/api/2fa/account/settings").andExpect(status().isOk()), AccountTwoFaSettings.class); - TwoFaAccountConfig savedAccountConfig = accountTwoFaSettings.getConfigs().get(TwoFaProviderType.SMS); - assertThat(savedAccountConfig).isEqualTo(accountConfig); - - doDelete("/api/2fa/account/config?providerType=SMS").andExpect(status().isOk()); + private String savePlatformTwoFaSettingsAndGetError(TwoFaProviderConfig invalidTwoFaProviderConfig) throws Exception { + PlatformTwoFaSettings twoFaSettings = new PlatformTwoFaSettings(); + twoFaSettings.setProviders(Collections.singletonList(invalidTwoFaProviderConfig)); + twoFaSettings.setMinVerificationCodeSendPeriod(5); + twoFaSettings.setTotalAllowedTimeForVerification(100); - assertThat(readResponse(doGet("/api/2fa/account/settings").andExpect(status().isOk()), AccountTwoFaSettings.class).getConfigs()) - .doesNotContainKey(TwoFaProviderType.SMS); + return getErrorMessage(doPost("/api/2fa/settings", twoFaSettings) + .andExpect(status().isBadRequest())); } } diff --git a/application/src/test/java/org/thingsboard/server/controller/TwoFactorAuthTest.java b/application/src/test/java/org/thingsboard/server/controller/TwoFactorAuthTest.java index 0da218db67..1138846c08 100644 --- a/application/src/test/java/org/thingsboard/server/controller/TwoFactorAuthTest.java +++ b/application/src/test/java/org/thingsboard/server/controller/TwoFactorAuthTest.java @@ -337,7 +337,7 @@ public class TwoFactorAuthTest extends AbstractControllerTest { @Test public void testAuthWithoutTwoFaAccountConfig() throws ThingsboardException { configureTotpTwoFa(); - twoFaConfigManager.deleteTwoFaAccountConfig(tenantId, user.getId(), TwoFaProviderType.TOTP); + twoFaConfigManager.deleteTwoFaAccountConfig(tenantId, user, TwoFaProviderType.TOTP); assertDoesNotThrow(() -> { login(username, password); @@ -371,15 +371,15 @@ public class TwoFactorAuthTest extends AbstractControllerTest { TotpTwoFaAccountConfig totpTwoFaAccountConfig = (TotpTwoFaAccountConfig) twoFactorAuthService.generateNewAccountConfig(twoFaUser, TwoFaProviderType.TOTP); totpTwoFaAccountConfig.setUseByDefault(true); - twoFaConfigManager.saveTwoFaAccountConfig(tenantId, twoFaUser.getId(), totpTwoFaAccountConfig); + twoFaConfigManager.saveTwoFaAccountConfig(tenantId, twoFaUser, totpTwoFaAccountConfig); SmsTwoFaAccountConfig smsTwoFaAccountConfig = new SmsTwoFaAccountConfig(); smsTwoFaAccountConfig.setPhoneNumber("+38012312322"); - twoFaConfigManager.saveTwoFaAccountConfig(tenantId, twoFaUser.getId(), smsTwoFaAccountConfig); + twoFaConfigManager.saveTwoFaAccountConfig(tenantId, twoFaUser, smsTwoFaAccountConfig); EmailTwoFaAccountConfig emailTwoFaAccountConfig = new EmailTwoFaAccountConfig(); emailTwoFaAccountConfig.setEmail(twoFaUser.getEmail()); - twoFaConfigManager.saveTwoFaAccountConfig(tenantId, twoFaUser.getId(), emailTwoFaAccountConfig); + twoFaConfigManager.saveTwoFaAccountConfig(tenantId, twoFaUser, emailTwoFaAccountConfig); logInWithMfaToken(twoFaUser.getEmail(), "12345678", Authority.PRE_VERIFICATION_TOKEN); @@ -431,9 +431,6 @@ public class TwoFactorAuthTest extends AbstractControllerTest { // verifying enforced users filter createDifferentTenant(); doGet("/api/user/" + savedDifferentTenantUser.getId()).andExpect(status().isOk()); - - twoFaSettings.setEnforceTwoFa(false); - twoFaConfigManager.savePlatformTwoFaSettings(TenantId.SYS_TENANT_ID, twoFaSettings); } private void logInWithMfaToken(String username, String password, Authority expectedScope) throws Exception { @@ -459,7 +456,7 @@ public class TwoFactorAuthTest extends AbstractControllerTest { twoFaConfigManager.savePlatformTwoFaSettings(TenantId.SYS_TENANT_ID, twoFaSettings); TotpTwoFaAccountConfig totpTwoFaAccountConfig = (TotpTwoFaAccountConfig) twoFactorAuthService.generateNewAccountConfig(user, TwoFaProviderType.TOTP); - twoFaConfigManager.saveTwoFaAccountConfig(tenantId, user.getId(), totpTwoFaAccountConfig); + twoFaConfigManager.saveTwoFaAccountConfig(tenantId, user, totpTwoFaAccountConfig); return totpTwoFaAccountConfig; } @@ -477,7 +474,7 @@ public class TwoFactorAuthTest extends AbstractControllerTest { SmsTwoFaAccountConfig smsTwoFaAccountConfig = new SmsTwoFaAccountConfig(); smsTwoFaAccountConfig.setPhoneNumber("+38050505050"); - twoFaConfigManager.saveTwoFaAccountConfig(tenantId, user.getId(), smsTwoFaAccountConfig); + twoFaConfigManager.saveTwoFaAccountConfig(tenantId, user, smsTwoFaAccountConfig); return smsTwoFaAccountConfig; } From 1b04ba09440aec801b2ca3e56048227be8889a77 Mon Sep 17 00:00:00 2001 From: ViacheslavKlimov Date: Tue, 24 Jun 2025 13:22:02 +0300 Subject: [PATCH 007/102] Refactoring for token factory --- .../service/security/model/token/JwtTokenFactory.java | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/application/src/main/java/org/thingsboard/server/service/security/model/token/JwtTokenFactory.java b/application/src/main/java/org/thingsboard/server/service/security/model/token/JwtTokenFactory.java index 9dac000d4b..e7699e4950 100644 --- a/application/src/main/java/org/thingsboard/server/service/security/model/token/JwtTokenFactory.java +++ b/application/src/main/java/org/thingsboard/server/service/security/model/token/JwtTokenFactory.java @@ -135,18 +135,15 @@ public class JwtTokenFactory { securityUser.setSessionId(claims.get(SESSION_ID, String.class)); } - UserPrincipal principal; + boolean isPublic = false; if (authority != Authority.PRE_VERIFICATION_TOKEN && authority != Authority.MFA_CONFIGURATION_TOKEN) { securityUser.setFirstName(claims.get(FIRST_NAME, String.class)); securityUser.setLastName(claims.get(LAST_NAME, String.class)); securityUser.setEnabled(claims.get(ENABLED, Boolean.class)); - boolean isPublic = claims.get(IS_PUBLIC, Boolean.class); - principal = new UserPrincipal(isPublic ? UserPrincipal.Type.PUBLIC_ID : UserPrincipal.Type.USER_NAME, subject); - } else { - principal = new UserPrincipal(UserPrincipal.Type.USER_NAME, subject); + isPublic = claims.get(IS_PUBLIC, Boolean.class); } + UserPrincipal principal = new UserPrincipal(isPublic ? UserPrincipal.Type.PUBLIC_ID : UserPrincipal.Type.USER_NAME, subject); securityUser.setUserPrincipal(principal); - return securityUser; } From 3010d99eed73354548a6f3b5d7e491b3d62c081b Mon Sep 17 00:00:00 2001 From: ViacheslavKlimov Date: Tue, 24 Jun 2025 15:08:57 +0300 Subject: [PATCH 008/102] Refactor permissions for MFA configuration --- .../permission/CustomerUserPermissions.java | 2 +- .../DefaultAccessControlService.java | 13 ++++----- .../MfaConfigurationPermissions.java | 28 +++++++++++++++++++ .../permission/SysAdminPermissions.java | 2 +- .../permission/TenantAdminPermissions.java | 2 +- 5 files changed, 37 insertions(+), 10 deletions(-) create mode 100644 application/src/main/java/org/thingsboard/server/service/security/permission/MfaConfigurationPermissions.java diff --git a/application/src/main/java/org/thingsboard/server/service/security/permission/CustomerUserPermissions.java b/application/src/main/java/org/thingsboard/server/service/security/permission/CustomerUserPermissions.java index 8124671cd7..8c71bab9bf 100644 --- a/application/src/main/java/org/thingsboard/server/service/security/permission/CustomerUserPermissions.java +++ b/application/src/main/java/org/thingsboard/server/service/security/permission/CustomerUserPermissions.java @@ -28,7 +28,7 @@ import org.thingsboard.server.common.data.id.UserId; import org.thingsboard.server.common.data.security.Authority; import org.thingsboard.server.service.security.model.SecurityUser; -@Component(value = "customerUserPermissions") +@Component public class CustomerUserPermissions extends AbstractPermissions { public CustomerUserPermissions() { diff --git a/application/src/main/java/org/thingsboard/server/service/security/permission/DefaultAccessControlService.java b/application/src/main/java/org/thingsboard/server/service/security/permission/DefaultAccessControlService.java index a5feb1c502..6c54bbe68f 100644 --- a/application/src/main/java/org/thingsboard/server/service/security/permission/DefaultAccessControlService.java +++ b/application/src/main/java/org/thingsboard/server/service/security/permission/DefaultAccessControlService.java @@ -16,7 +16,6 @@ package org.thingsboard.server.service.security.permission; import lombok.extern.slf4j.Slf4j; -import org.springframework.beans.factory.annotation.Qualifier; import org.springframework.stereotype.Service; import org.thingsboard.server.common.data.HasTenantId; import org.thingsboard.server.common.data.exception.ThingsboardErrorCode; @@ -33,18 +32,18 @@ import java.util.Optional; @Slf4j public class DefaultAccessControlService implements AccessControlService { - private static final String INCORRECT_TENANT_ID = "Incorrect tenantId "; private static final String YOU_DON_T_HAVE_PERMISSION_TO_PERFORM_THIS_OPERATION = "You don't have permission to perform this operation!"; private final Map authorityPermissions = new HashMap<>(); - public DefaultAccessControlService( - @Qualifier("sysAdminPermissions") Permissions sysAdminPermissions, - @Qualifier("tenantAdminPermissions") Permissions tenantAdminPermissions, - @Qualifier("customerUserPermissions") Permissions customerUserPermissions) { + public DefaultAccessControlService(SysAdminPermissions sysAdminPermissions, + TenantAdminPermissions tenantAdminPermissions, + CustomerUserPermissions customerUserPermissions, + MfaConfigurationPermissions mfaConfigurationPermissions) { authorityPermissions.put(Authority.SYS_ADMIN, sysAdminPermissions); authorityPermissions.put(Authority.TENANT_ADMIN, tenantAdminPermissions); authorityPermissions.put(Authority.CUSTOMER_USER, customerUserPermissions); + authorityPermissions.put(Authority.MFA_CONFIGURATION_TOKEN, mfaConfigurationPermissions); } @Override @@ -58,7 +57,7 @@ public class DefaultAccessControlService implements AccessControlService { @Override @SuppressWarnings("unchecked") public void checkPermission(SecurityUser user, Resource resource, - Operation operation, I entityId, T entity) throws ThingsboardException { + Operation operation, I entityId, T entity) throws ThingsboardException { PermissionChecker permissionChecker = getPermissionChecker(user.getAuthority(), resource); if (!permissionChecker.hasPermission(user, operation, entityId, entity)) { permissionDenied(); diff --git a/application/src/main/java/org/thingsboard/server/service/security/permission/MfaConfigurationPermissions.java b/application/src/main/java/org/thingsboard/server/service/security/permission/MfaConfigurationPermissions.java new file mode 100644 index 0000000000..b901030a67 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/security/permission/MfaConfigurationPermissions.java @@ -0,0 +1,28 @@ +/** + * 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.permission; + +import org.springframework.stereotype.Component; + +@Component +public class MfaConfigurationPermissions extends AbstractPermissions { + + public MfaConfigurationPermissions() { + super(); + // for compatibility with PE + } + +} diff --git a/application/src/main/java/org/thingsboard/server/service/security/permission/SysAdminPermissions.java b/application/src/main/java/org/thingsboard/server/service/security/permission/SysAdminPermissions.java index 6bd7aacf54..2593040a12 100644 --- a/application/src/main/java/org/thingsboard/server/service/security/permission/SysAdminPermissions.java +++ b/application/src/main/java/org/thingsboard/server/service/security/permission/SysAdminPermissions.java @@ -23,7 +23,7 @@ import org.thingsboard.server.common.data.id.UserId; import org.thingsboard.server.common.data.security.Authority; import org.thingsboard.server.service.security.model.SecurityUser; -@Component(value = "sysAdminPermissions") +@Component public class SysAdminPermissions extends AbstractPermissions { public SysAdminPermissions() { diff --git a/application/src/main/java/org/thingsboard/server/service/security/permission/TenantAdminPermissions.java b/application/src/main/java/org/thingsboard/server/service/security/permission/TenantAdminPermissions.java index 58023be34d..bbad7b52d4 100644 --- a/application/src/main/java/org/thingsboard/server/service/security/permission/TenantAdminPermissions.java +++ b/application/src/main/java/org/thingsboard/server/service/security/permission/TenantAdminPermissions.java @@ -23,7 +23,7 @@ import org.thingsboard.server.common.data.id.UserId; import org.thingsboard.server.common.data.security.Authority; import org.thingsboard.server.service.security.model.SecurityUser; -@Component(value = "tenantAdminPermissions") +@Component public class TenantAdminPermissions extends AbstractPermissions { public TenantAdminPermissions() { From 7be97f379c69a73ce30d485c6736a2ac62e5e021 Mon Sep 17 00:00:00 2001 From: dashevchenko Date: Tue, 29 Jul 2025 14:30:59 +0300 Subject: [PATCH 009/102] added json timeseries type for bulk import --- .../csv/AbstractBulkImportService.java | 9 ++- .../thingsboard/server/utils/CsvUtils.java | 19 ++++++ .../controller/DeviceControllerTest.java | 60 +++++++++++++++++++ .../server/common/data/util/TypeCastUtil.java | 11 ++++ 4 files changed, 98 insertions(+), 1 deletion(-) diff --git a/application/src/main/java/org/thingsboard/server/service/sync/ie/importing/csv/AbstractBulkImportService.java b/application/src/main/java/org/thingsboard/server/service/sync/ie/importing/csv/AbstractBulkImportService.java index 9850e2d1a1..768adf98ff 100644 --- a/application/src/main/java/org/thingsboard/server/service/sync/ie/importing/csv/AbstractBulkImportService.java +++ b/application/src/main/java/org/thingsboard/server/service/sync/ie/importing/csv/AbstractBulkImportService.java @@ -17,6 +17,7 @@ package org.thingsboard.server.service.sync.ie.importing.csv; import com.fasterxml.jackson.databind.node.ObjectNode; import com.google.common.util.concurrent.FutureCallback; +import com.google.gson.JsonElement; import com.google.gson.JsonObject; import com.google.gson.JsonPrimitive; import jakarta.annotation.Nullable; @@ -183,7 +184,13 @@ public abstract class AbstractBulkImportService dataEntry.getKey().getType() == kvType && StringUtils.isNotEmpty(dataEntry.getKey().getKey())) - .forEach(dataEntry -> kvs.add(dataEntry.getKey().getKey(), dataEntry.getValue().toJsonPrimitive())); + .forEach(dataEntry -> { + ParsedValue value = dataEntry.getValue(); + JsonElement kvValue = (value.getDataType() == DataType.JSON) + ? (JsonElement) value.getValue() + : value.toJsonPrimitive(); + kvs.add(dataEntry.getKey().getKey(), kvValue); + }); return Map.entry(kvType, kvs); }) .filter(kvsEntry -> kvsEntry.getValue().entrySet().size() > 0) diff --git a/application/src/main/java/org/thingsboard/server/utils/CsvUtils.java b/application/src/main/java/org/thingsboard/server/utils/CsvUtils.java index 3f75a4918f..0fde72a3b0 100644 --- a/application/src/main/java/org/thingsboard/server/utils/CsvUtils.java +++ b/application/src/main/java/org/thingsboard/server/utils/CsvUtils.java @@ -17,10 +17,15 @@ package org.thingsboard.server.utils; import lombok.AccessLevel; import lombok.NoArgsConstructor; +import lombok.SneakyThrows; import org.apache.commons.csv.CSVFormat; +import org.apache.commons.csv.CSVPrinter; import org.apache.commons.csv.CSVRecord; import org.apache.commons.io.input.CharSequenceReader; +import java.io.ByteArrayOutputStream; +import java.io.OutputStreamWriter; +import java.nio.charset.StandardCharsets; import java.util.List; import java.util.stream.Collectors; import java.util.stream.Stream; @@ -43,4 +48,18 @@ public class CsvUtils { .collect(Collectors.toList()); } + @SneakyThrows + public static byte[] generateCsv(List> rows) { + ByteArrayOutputStream out = new ByteArrayOutputStream(); + try (OutputStreamWriter writer = new OutputStreamWriter(out, StandardCharsets.UTF_8); + CSVPrinter csvPrinter = new CSVPrinter(writer, CSVFormat.DEFAULT)) { + + for (List row : rows) { + csvPrinter.printRecord(row); + } + csvPrinter.flush(); + } + return out.toByteArray(); + } + } diff --git a/application/src/test/java/org/thingsboard/server/controller/DeviceControllerTest.java b/application/src/test/java/org/thingsboard/server/controller/DeviceControllerTest.java index 36cede9e84..6b31825640 100644 --- a/application/src/test/java/org/thingsboard/server/controller/DeviceControllerTest.java +++ b/application/src/test/java/org/thingsboard/server/controller/DeviceControllerTest.java @@ -77,8 +77,13 @@ import org.thingsboard.server.dao.service.DaoSqlTest; import org.thingsboard.server.gen.transport.TransportProtos; import org.thingsboard.server.service.gateway_device.GatewayNotificationsService; import org.thingsboard.server.service.state.DeviceStateService; +import org.thingsboard.server.utils.CsvUtils; +import java.nio.charset.StandardCharsets; import java.util.ArrayList; +import java.util.Arrays; +import java.util.LinkedHashMap; +import java.util.LinkedList; import java.util.List; import java.util.Map; import java.util.concurrent.TimeUnit; @@ -1586,6 +1591,61 @@ public class DeviceControllerTest extends AbstractControllerTest { Assert.assertEquals(newAttributeValue, actualAttribute.get("value")); } + @Test + public void testBulkImportDeviceWithJsonAttr() throws Exception { + String deviceName = "some_device"; + String deviceType = "some_type"; + JsonNode deviceAttr = JacksonUtil.toJsonNode("{\"threshold\": 45}"); + + List> content = new LinkedList<>(); + content.add(Arrays.asList("NAME", "TYPE", "ATTR")); + content.add(Arrays.asList(deviceName, deviceType, deviceAttr.toString())); + + byte[] bytes = CsvUtils.generateCsv(content); + BulkImportRequest request = new BulkImportRequest(); + request.setFile(new String(bytes, StandardCharsets.UTF_8)); + BulkImportRequest.Mapping mapping = new BulkImportRequest.Mapping(); + BulkImportRequest.ColumnMapping name = new BulkImportRequest.ColumnMapping(); + name.setType(BulkImportColumnType.NAME); + BulkImportRequest.ColumnMapping type = new BulkImportRequest.ColumnMapping(); + type.setType(BulkImportColumnType.TYPE); + BulkImportRequest.ColumnMapping attr = new BulkImportRequest.ColumnMapping(); + attr.setType(BulkImportColumnType.SERVER_ATTRIBUTE); + attr.setKey("attr"); + List columns = new ArrayList<>(); + columns.add(name); + columns.add(type); + columns.add(attr); + + mapping.setColumns(columns); + mapping.setDelimiter(','); + mapping.setUpdate(true); + mapping.setHeader(true); + request.setMapping(mapping); + + BulkImportResult deviceBulkImportResult = doPostWithTypedResponse("/api/device/bulk_import", request, new TypeReference<>() {}); + + Assert.assertEquals(1, deviceBulkImportResult.getCreated().get()); + Assert.assertEquals(0, deviceBulkImportResult.getErrors().get()); + Assert.assertEquals(0, deviceBulkImportResult.getUpdated().get()); + Assert.assertTrue(deviceBulkImportResult.getErrorsList().isEmpty()); + + Device savedDevice = doGet("/api/tenant/devices?deviceName=" + deviceName, Device.class); + + Assert.assertNotNull(savedDevice); + Assert.assertEquals(deviceName, savedDevice.getName()); + Assert.assertEquals(deviceType, savedDevice.getType()); + + //check server attribute value + await().atMost(TIMEOUT, TimeUnit.SECONDS).untilAsserted(() -> { + Map actualAttribute = doGetAsyncTyped("/api/plugins/telemetry/DEVICE/" + savedDevice.getId() + + "/values/attributes/SERVER_SCOPE", new TypeReference>>() {}).stream() + .filter(att -> att.get("key").equals("attr")).findFirst().get(); + LinkedHashMap expected = JacksonUtil.convertValue(deviceAttr, new TypeReference<>() {}); + Assert.assertEquals(expected, actualAttribute.get("value")); + }); + } + @Test public void testSaveDeviceWithOutdatedVersion() throws Exception { Device device = createDevice("Device v1.0"); diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/util/TypeCastUtil.java b/common/data/src/main/java/org/thingsboard/server/common/data/util/TypeCastUtil.java index 85071b3cc9..94a1ee0ef4 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/util/TypeCastUtil.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/util/TypeCastUtil.java @@ -15,6 +15,7 @@ */ package org.thingsboard.server.common.data.util; +import com.google.gson.JsonParser; import org.apache.commons.lang3.math.NumberUtils; import org.apache.commons.lang3.tuple.Pair; import org.thingsboard.server.common.data.kv.DataType; @@ -40,6 +41,11 @@ public class TypeCastUtil { } catch (RuntimeException ignored) {} } else if (value.equalsIgnoreCase("true") || value.equalsIgnoreCase("false")) { return Pair.of(DataType.BOOLEAN, Boolean.parseBoolean(value)); + } else if (looksLikeJson(value)) { + try { + return Pair.of(DataType.JSON, JsonParser.parseString(value)); + } catch (Exception ignored) { + } } return Pair.of(DataType.STRING, value); } @@ -70,4 +76,9 @@ public class TypeCastUtil { return valueAsString.contains(".") && !valueAsString.contains("E") && !valueAsString.contains("e"); } + private static boolean looksLikeJson(String value) { + return (value.startsWith("{") && value.endsWith("}")) || + (value.startsWith("[") && value.endsWith("]")); + } + } From 6ae8cdf62389d7505c79110036558c9a3191c478 Mon Sep 17 00:00:00 2001 From: dashevchenko Date: Wed, 30 Jul 2025 18:38:00 +0300 Subject: [PATCH 010/102] added trim to json parsing, updated test --- .../controller/DeviceControllerTest.java | 19 ++++++++----------- .../server/common/data/util/TypeCastUtil.java | 5 +++-- 2 files changed, 11 insertions(+), 13 deletions(-) diff --git a/application/src/test/java/org/thingsboard/server/controller/DeviceControllerTest.java b/application/src/test/java/org/thingsboard/server/controller/DeviceControllerTest.java index 6b31825640..6ac9e5526a 100644 --- a/application/src/test/java/org/thingsboard/server/controller/DeviceControllerTest.java +++ b/application/src/test/java/org/thingsboard/server/controller/DeviceControllerTest.java @@ -38,6 +38,7 @@ import org.springframework.test.context.ContextConfiguration; import org.testcontainers.shaded.org.awaitility.Awaitility; import org.thingsboard.common.util.JacksonUtil; import org.thingsboard.common.util.ThingsBoardExecutors; +import org.thingsboard.server.common.data.AttributeScope; import org.thingsboard.server.common.data.Customer; import org.thingsboard.server.common.data.Device; import org.thingsboard.server.common.data.DeviceInfo; @@ -59,6 +60,7 @@ import org.thingsboard.server.common.data.id.CustomerId; import org.thingsboard.server.common.data.id.DeviceCredentialsId; import org.thingsboard.server.common.data.id.DeviceId; import org.thingsboard.server.common.data.id.EntityId; +import org.thingsboard.server.common.data.kv.AttributeKvEntry; import org.thingsboard.server.common.data.page.PageData; import org.thingsboard.server.common.data.page.PageLink; import org.thingsboard.server.common.data.relation.EntityRelation; @@ -82,10 +84,10 @@ import org.thingsboard.server.utils.CsvUtils; import java.nio.charset.StandardCharsets; import java.util.ArrayList; import java.util.Arrays; -import java.util.LinkedHashMap; import java.util.LinkedList; import java.util.List; import java.util.Map; +import java.util.Optional; import java.util.concurrent.TimeUnit; import static org.assertj.core.api.Assertions.assertThat; @@ -1595,11 +1597,11 @@ public class DeviceControllerTest extends AbstractControllerTest { public void testBulkImportDeviceWithJsonAttr() throws Exception { String deviceName = "some_device"; String deviceType = "some_type"; - JsonNode deviceAttr = JacksonUtil.toJsonNode("{\"threshold\": 45}"); + String deviceAttr = "{\"threshold\":45}"; List> content = new LinkedList<>(); content.add(Arrays.asList("NAME", "TYPE", "ATTR")); - content.add(Arrays.asList(deviceName, deviceType, deviceAttr.toString())); + content.add(Arrays.asList(deviceName, deviceType, deviceAttr)); byte[] bytes = CsvUtils.generateCsv(content); BulkImportRequest request = new BulkImportRequest(); @@ -1636,14 +1638,9 @@ public class DeviceControllerTest extends AbstractControllerTest { Assert.assertEquals(deviceName, savedDevice.getName()); Assert.assertEquals(deviceType, savedDevice.getType()); - //check server attribute value - await().atMost(TIMEOUT, TimeUnit.SECONDS).untilAsserted(() -> { - Map actualAttribute = doGetAsyncTyped("/api/plugins/telemetry/DEVICE/" + savedDevice.getId() + - "/values/attributes/SERVER_SCOPE", new TypeReference>>() {}).stream() - .filter(att -> att.get("key").equals("attr")).findFirst().get(); - LinkedHashMap expected = JacksonUtil.convertValue(deviceAttr, new TypeReference<>() {}); - Assert.assertEquals(expected, actualAttribute.get("value")); - }); + Optional retrieved = attributesService.find(tenantId, savedDevice.getId(), AttributeScope.SERVER_SCOPE, "attr").get(); + assertThat(retrieved.get().getJsonValue().get()).isEqualTo(deviceAttr); + assertThat(retrieved.get().getStrValue()).isNotPresent(); } @Test diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/util/TypeCastUtil.java b/common/data/src/main/java/org/thingsboard/server/common/data/util/TypeCastUtil.java index 94a1ee0ef4..82de579d3d 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/util/TypeCastUtil.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/util/TypeCastUtil.java @@ -77,8 +77,9 @@ public class TypeCastUtil { } private static boolean looksLikeJson(String value) { - return (value.startsWith("{") && value.endsWith("}")) || - (value.startsWith("[") && value.endsWith("]")); + String trimmed = value.trim(); + return (trimmed.startsWith("{") && trimmed.endsWith("}")) || + (trimmed.startsWith("[") && trimmed.endsWith("]")); } } From 6757f9195cb7b8227c8ff00c73918cb2ebbb5cc3 Mon Sep 17 00:00:00 2001 From: Yevhenii Date: Thu, 11 Sep 2025 15:43:18 +0300 Subject: [PATCH 011/102] Added AiModel sync for Edge --- .../service/edge/EdgeContextComponent.java | 7 + .../service/edge/EdgeMsgConstructorUtils.java | 16 ++ .../service/edge/rpc/EdgeGrpcSession.java | 6 + .../edge/rpc/processor/BaseEdgeProcessor.java | 2 +- .../processor/ai/AiModelEdgeProcessor.java | 168 ++++++++++++++++++ .../rpc/processor/ai/AiModelProcessor.java | 28 +++ .../processor/ai/BaseAiModelProcessor.java | 81 +++++++++ .../server/edge/imitator/EdgeImitator.java | 6 + .../server/dao/ai/AiModelService.java | 4 + .../common/data/edge/EdgeEventType.java | 3 +- .../thingsboard/edge/rpc/EdgeGrpcClient.java | 2 +- common/edge-api/src/main/proto/edge.proto | 9 + .../server/dao/ai/AiModelServiceImpl.java | 14 +- 13 files changed, 342 insertions(+), 4 deletions(-) create mode 100644 application/src/main/java/org/thingsboard/server/service/edge/rpc/processor/ai/AiModelEdgeProcessor.java create mode 100644 application/src/main/java/org/thingsboard/server/service/edge/rpc/processor/ai/AiModelProcessor.java create mode 100644 application/src/main/java/org/thingsboard/server/service/edge/rpc/processor/ai/BaseAiModelProcessor.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..c6bfef4971 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 @@ -24,6 +24,7 @@ import org.thingsboard.server.cache.limits.RateLimitService; import org.thingsboard.server.cluster.TbClusterService; import org.thingsboard.server.common.data.edge.EdgeEventType; import org.thingsboard.server.common.msg.notification.NotificationRuleProcessor; +import org.thingsboard.server.dao.ai.AiModelService; import org.thingsboard.server.dao.alarm.AlarmCommentService; import org.thingsboard.server.dao.alarm.AlarmService; import org.thingsboard.server.dao.asset.AssetProfileService; @@ -59,6 +60,7 @@ import org.thingsboard.server.queue.util.TbCoreComponent; import org.thingsboard.server.service.edge.rpc.EdgeEventStorageSettings; import org.thingsboard.server.service.edge.rpc.EdgeRpcService; import org.thingsboard.server.service.edge.rpc.processor.EdgeProcessor; +import org.thingsboard.server.service.edge.rpc.processor.ai.AiModelProcessor; import org.thingsboard.server.service.edge.rpc.processor.alarm.AlarmProcessor; import org.thingsboard.server.service.edge.rpc.processor.alarm.comment.AlarmCommentProcessor; import org.thingsboard.server.service.edge.rpc.processor.asset.AssetEdgeProcessor; @@ -261,6 +263,11 @@ public class EdgeContextComponent { @Autowired private CalculatedFieldProcessor calculatedFieldProcessor; + @Autowired + private AiModelService aiModelService; + @Autowired + private AiModelProcessor aiModelProcessor; + 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/EdgeMsgConstructorUtils.java b/application/src/main/java/org/thingsboard/server/service/edge/EdgeMsgConstructorUtils.java index 192c56692d..505b03a1cd 100644 --- a/application/src/main/java/org/thingsboard/server/service/edge/EdgeMsgConstructorUtils.java +++ b/application/src/main/java/org/thingsboard/server/service/edge/EdgeMsgConstructorUtils.java @@ -44,6 +44,7 @@ import org.thingsboard.server.common.data.TbResource; import org.thingsboard.server.common.data.Tenant; import org.thingsboard.server.common.data.TenantProfile; import org.thingsboard.server.common.data.User; +import org.thingsboard.server.common.data.ai.AiModel; import org.thingsboard.server.common.data.alarm.Alarm; import org.thingsboard.server.common.data.alarm.AlarmComment; import org.thingsboard.server.common.data.asset.Asset; @@ -52,6 +53,7 @@ import org.thingsboard.server.common.data.cf.CalculatedField; import org.thingsboard.server.common.data.domain.DomainInfo; import org.thingsboard.server.common.data.edge.Edge; import org.thingsboard.server.common.data.edge.EdgeEventActionType; +import org.thingsboard.server.common.data.id.AiModelId; import org.thingsboard.server.common.data.id.AssetId; import org.thingsboard.server.common.data.id.AssetProfileId; import org.thingsboard.server.common.data.id.CalculatedFieldId; @@ -86,6 +88,7 @@ import org.thingsboard.server.common.data.security.DeviceCredentials; import org.thingsboard.server.common.data.security.UserCredentials; import org.thingsboard.server.common.data.widget.WidgetTypeDetails; import org.thingsboard.server.common.data.widget.WidgetsBundle; +import org.thingsboard.server.gen.edge.v1.AiModelUpdateMsg; import org.thingsboard.server.gen.edge.v1.AlarmCommentUpdateMsg; import org.thingsboard.server.gen.edge.v1.AlarmUpdateMsg; import org.thingsboard.server.gen.edge.v1.AssetProfileUpdateMsg; @@ -654,4 +657,17 @@ public class EdgeMsgConstructorUtils { .setIdLSB(calculatedFieldId.getId().getLeastSignificantBits()).build(); } + public static AiModelUpdateMsg constructAiModelUpdatedMsg(UpdateMsgType msgType, AiModel aiModel) { + return AiModelUpdateMsg.newBuilder().setMsgType(msgType).setEntity(JacksonUtil.toString(aiModel)) + .setIdMSB(aiModel.getId().getId().getMostSignificantBits()) + .setIdLSB(aiModel.getId().getId().getLeastSignificantBits()).build(); + } + + public static AiModelUpdateMsg constructAiModelDeleteMsg(AiModelId aiModelId) { + return AiModelUpdateMsg.newBuilder() + .setMsgType(UpdateMsgType.ENTITY_DELETED_RPC_MESSAGE) + .setIdMSB(aiModelId.getId().getMostSignificantBits()) + .setIdLSB(aiModelId.getId().getLeastSignificantBits()).build(); + } + } 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 521730741f..f1ee280ee6 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 @@ -46,6 +46,7 @@ import org.thingsboard.server.common.data.page.PageLink; import org.thingsboard.server.common.data.page.TimePageLink; import org.thingsboard.server.common.msg.edge.EdgeEventUpdateMsg; import org.thingsboard.server.dao.edge.stats.EdgeStatsKey; +import org.thingsboard.server.gen.edge.v1.AiModelUpdateMsg; import org.thingsboard.server.gen.edge.v1.AlarmCommentUpdateMsg; import org.thingsboard.server.gen.edge.v1.AlarmUpdateMsg; import org.thingsboard.server.gen.edge.v1.AssetProfileUpdateMsg; @@ -933,6 +934,11 @@ public abstract class EdgeGrpcSession implements Closeable { result.add(ctx.getCalculatedFieldProcessor().processCalculatedFieldMsgFromEdge(edge.getTenantId(), edge, calculatedFieldUpdateMsg)); } } + if (uplinkMsg.getAiModelUpdateMsgCount() > 0) { + for (AiModelUpdateMsg aiModelUpdateMsg : uplinkMsg.getAiModelUpdateMsgList()) { + result.add(ctx.getAiModelProcessor().processAiModelMsgFromEdge(edge.getTenantId(), edge, aiModelUpdateMsg)); + } + } } 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/BaseEdgeProcessor.java b/application/src/main/java/org/thingsboard/server/service/edge/rpc/processor/BaseEdgeProcessor.java index a2243a88d2..d6bfeae0b7 100644 --- a/application/src/main/java/org/thingsboard/server/service/edge/rpc/processor/BaseEdgeProcessor.java +++ b/application/src/main/java/org/thingsboard/server/service/edge/rpc/processor/BaseEdgeProcessor.java @@ -139,7 +139,7 @@ public abstract class BaseEdgeProcessor implements EdgeProcessor { UPDATED_COMMENT, DELETED -> true; default -> switch (type) { case ALARM, ALARM_COMMENT, RULE_CHAIN, RULE_CHAIN_METADATA, USER, CUSTOMER, TENANT, TENANT_PROFILE, - WIDGETS_BUNDLE, WIDGET_TYPE, ADMIN_SETTINGS, OTA_PACKAGE, QUEUE, RELATION, CALCULATED_FIELD, NOTIFICATION_TEMPLATE, + WIDGETS_BUNDLE, WIDGET_TYPE, ADMIN_SETTINGS, OTA_PACKAGE, QUEUE, RELATION, CALCULATED_FIELD, AI_MODEL, NOTIFICATION_TEMPLATE, NOTIFICATION_TARGET, NOTIFICATION_RULE -> true; default -> false; }; diff --git a/application/src/main/java/org/thingsboard/server/service/edge/rpc/processor/ai/AiModelEdgeProcessor.java b/application/src/main/java/org/thingsboard/server/service/edge/rpc/processor/ai/AiModelEdgeProcessor.java new file mode 100644 index 0000000000..aaa1f05aa7 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/edge/rpc/processor/ai/AiModelEdgeProcessor.java @@ -0,0 +1,168 @@ +/** + * 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.ai; + +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.ai.AiModel; +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.AiModelId; +import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.common.data.msg.TbMsgType; +import org.thingsboard.server.common.msg.TbMsgMetaData; +import org.thingsboard.server.dao.exception.DataValidationException; +import org.thingsboard.server.gen.edge.v1.AiModelUpdateMsg; +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.queue.util.TbCoreComponent; +import org.thingsboard.server.service.edge.EdgeMsgConstructorUtils; + +import java.util.Optional; +import java.util.UUID; + +@Slf4j +@Component +@TbCoreComponent +public class AiModelEdgeProcessor extends BaseAiModelProcessor implements AiModelProcessor { + + @Override + public ListenableFuture processAiModelMsgFromEdge(TenantId tenantId, Edge edge, AiModelUpdateMsg aiModelUpdateMsg) { + AiModelId aiModelId = new AiModelId(new UUID(aiModelUpdateMsg.getIdMSB(), aiModelUpdateMsg.getIdLSB())); + try { + edgeSynchronizationManager.getEdgeId().set(edge.getId()); + + switch (aiModelUpdateMsg.getMsgType()) { + case ENTITY_CREATED_RPC_MESSAGE: + case ENTITY_UPDATED_RPC_MESSAGE: + processAiModel(tenantId, aiModelId, aiModelUpdateMsg, edge); + return Futures.immediateFuture(null); + case ENTITY_DELETED_RPC_MESSAGE: + Optional aiModel = edgeCtx.getAiModelService().findAiModelById(tenantId, aiModelId); + if (aiModel.isPresent()) { + edgeCtx.getAiModelService().deleteByTenantIdAndId(tenantId, aiModelId); + } + return Futures.immediateFuture(null); + case UNRECOGNIZED: + default: + return handleUnsupportedMsgType(aiModelUpdateMsg.getMsgType()); + } + } catch (DataValidationException e) { + if (e.getMessage().contains("limit reached")) { + log.warn("[{}] Number of allowed aiModel violated {}", tenantId, aiModelUpdateMsg, e); + return Futures.immediateFuture(null); + } else { + return Futures.immediateFailedFuture(e); + } + } finally { + edgeSynchronizationManager.getEdgeId().remove(); + } + } + + @Override + public DownlinkMsg convertEdgeEventToDownlink(EdgeEvent edgeEvent, EdgeVersion edgeVersion) { + AiModelId aiModelId = new AiModelId(edgeEvent.getEntityId()); + switch (edgeEvent.getAction()) { + case ADDED, UPDATED -> { + Optional aiModel = edgeCtx.getAiModelService().findAiModelById(edgeEvent.getTenantId(), aiModelId); + if (aiModel.isPresent()) { + UpdateMsgType msgType = getUpdateMsgType(edgeEvent.getAction()); + AiModelUpdateMsg aiModelUpdateMsg = EdgeMsgConstructorUtils.constructAiModelUpdatedMsg(msgType, aiModel.get()); + return DownlinkMsg.newBuilder() + .setDownlinkMsgId(EdgeUtils.nextPositiveInt()) + .addAiModelUpdateMsg(aiModelUpdateMsg) + .build(); + } + } + case DELETED -> { + AiModelUpdateMsg aiModelUpdateMsg = EdgeMsgConstructorUtils.constructAiModelDeleteMsg(aiModelId); + return DownlinkMsg.newBuilder() + .setDownlinkMsgId(EdgeUtils.nextPositiveInt()) + .addAiModelUpdateMsg(aiModelUpdateMsg) + .build(); + } + } + return null; + } + + @Override + public EdgeEventType getEdgeEventType() { + return EdgeEventType.AI_MODEL; + } + +// @Override +// public ListenableFuture processEntityNotification(TenantId tenantId, TransportProtos.EdgeNotificationMsgProto edgeNotificationMsg) { +// EdgeEventType type = EdgeEventType.valueOf(edgeNotificationMsg.getType()); +// EdgeEventActionType actionType = EdgeEventActionType.valueOf(edgeNotificationMsg.getAction()); +// EntityId entityId = EntityIdFactory.getByEdgeEventTypeAndUuid(type, new UUID(edgeNotificationMsg.getEntityIdMSB(), edgeNotificationMsg.getEntityIdLSB())); +// EdgeId originatorEdgeId = safeGetEdgeId(edgeNotificationMsg.getOriginatorEdgeIdMSB(), edgeNotificationMsg.getOriginatorEdgeIdLSB()); +// +// switch (actionType) { +// case UPDATED: +// case ADDED: +// EntityId calculatedFieldOwnerId = JacksonUtil.fromString(edgeNotificationMsg.getBody(), EntityId.class); +// if (calculatedFieldOwnerId != null && +// (EntityType.DEVICE.equals(calculatedFieldOwnerId.getEntityType()) || EntityType.ASSET.equals(calculatedFieldOwnerId.getEntityType()))) { +// JsonNode body = JacksonUtil.toJsonNode(edgeNotificationMsg.getBody()); +// EdgeId edgeId = safeGetEdgeId(edgeNotificationMsg.getEdgeIdMSB(), edgeNotificationMsg.getEdgeIdLSB()); +// +// return edgeId != null ? +// saveEdgeEvent(tenantId, edgeId, type, actionType, entityId, body) : +// processNotificationToRelatedEdges(tenantId, calculatedFieldOwnerId, entityId, type, actionType, originatorEdgeId); +// } else { +// return processActionForAllEdges(tenantId, type, actionType, entityId, null, originatorEdgeId); +// } +// default: +// return super.processEntityNotification(tenantId, edgeNotificationMsg); +// } +// } + + private void processAiModel(TenantId tenantId, AiModelId aiModelId, AiModelUpdateMsg aiModelUpdateMsg, Edge edge) { + Pair resultPair = super.saveOrUpdateAiModel(tenantId, aiModelId, aiModelUpdateMsg); + Boolean wasCreated = resultPair.getFirst(); + if (wasCreated) { + pushAiModelCreatedEventToRuleEngine(tenantId, edge, aiModelId); + } + Boolean nameWasUpdated = resultPair.getSecond(); + if (nameWasUpdated) { + saveEdgeEvent(tenantId, edge.getId(), EdgeEventType.AI_MODEL, EdgeEventActionType.UPDATED, aiModelId, null); + } + } + + private void pushAiModelCreatedEventToRuleEngine(TenantId tenantId, Edge edge, AiModelId aiModelId) { + try { + Optional aiModel = edgeCtx.getAiModelService().findAiModelById(tenantId, aiModelId); + if (aiModel.isPresent()) { + String aiModelAsString = JacksonUtil.toString(aiModel.get()); + TbMsgMetaData msgMetaData = getEdgeActionTbMsgMetaData(edge, edge.getCustomerId()); + pushEntityEventToRuleEngine(tenantId, aiModelId, edge.getCustomerId(), TbMsgType.ENTITY_CREATED, aiModelAsString, msgMetaData); + } else { + log.warn("[{}][{}] Failed to find AiModel", tenantId, aiModelId); + } + } catch (Exception e) { + log.warn("[{}][{}] Failed to push aiModel action to rule engine: {}", tenantId, aiModelId, TbMsgType.ENTITY_CREATED.name(), e); + } + } + +} diff --git a/application/src/main/java/org/thingsboard/server/service/edge/rpc/processor/ai/AiModelProcessor.java b/application/src/main/java/org/thingsboard/server/service/edge/rpc/processor/ai/AiModelProcessor.java new file mode 100644 index 0000000000..f66421167a --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/edge/rpc/processor/ai/AiModelProcessor.java @@ -0,0 +1,28 @@ +/** + * 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.ai; + +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.AiModelUpdateMsg; +import org.thingsboard.server.service.edge.rpc.processor.EdgeProcessor; + +public interface AiModelProcessor extends EdgeProcessor { + + ListenableFuture processAiModelMsgFromEdge(TenantId tenantId, Edge edge, AiModelUpdateMsg aiModelUpdateMsg); + +} diff --git a/application/src/main/java/org/thingsboard/server/service/edge/rpc/processor/ai/BaseAiModelProcessor.java b/application/src/main/java/org/thingsboard/server/service/edge/rpc/processor/ai/BaseAiModelProcessor.java new file mode 100644 index 0000000000..cb1d27e0a1 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/edge/rpc/processor/ai/BaseAiModelProcessor.java @@ -0,0 +1,81 @@ +/** + * 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.ai; + +import com.datastax.oss.driver.api.core.uuid.Uuids; +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.ai.AiModel; +import org.thingsboard.server.common.data.id.AiModelId; +import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.dao.service.DataValidator; +import org.thingsboard.server.gen.edge.v1.AiModelUpdateMsg; +import org.thingsboard.server.service.edge.rpc.processor.BaseEdgeProcessor; + +import java.util.Optional; + +@Slf4j +public abstract class BaseAiModelProcessor extends BaseEdgeProcessor { + + @Autowired + private DataValidator aiModelValidator; + + protected Pair saveOrUpdateAiModel(TenantId tenantId, AiModelId aiModelId, AiModelUpdateMsg aiModelUpdateMsg) { + boolean isCreated = false; + boolean isNameUpdated = false; + try { + AiModel aiModel = JacksonUtil.fromString(aiModelUpdateMsg.getEntity(), AiModel.class, true); + if (aiModel == null) { + throw new RuntimeException("[{" + tenantId + "}] aiModelUpdateMsg {" + aiModelUpdateMsg + " } cannot be converted to aiModel"); + } + + Optional aiModelById = edgeCtx.getAiModelService().findAiModelById(tenantId, aiModelId); + if (aiModelById.isEmpty()) { + aiModel.setCreatedTime(Uuids.unixTimestamp(aiModelId.getId())); + isCreated = true; + aiModel.setId(null); + } else { + aiModel.setId(aiModelId); + } + + String aiModelName = aiModel.getName(); + Optional aiModelByName = edgeCtx.getAiModelService().findAiModelByTenantIdAndName(aiModel.getTenantId(), aiModelName); + if (aiModelByName.isPresent() && !aiModelByName.get().getId().equals(aiModelId)) { + aiModelName = aiModelName + "_" + StringUtils.randomAlphabetic(15); + log.warn("[{}] aiModel with name {} already exists. Renaming aiModel name to {}", + tenantId, aiModel.getName(), aiModelByName.get().getName()); + isNameUpdated = true; + } + aiModel.setName(aiModelName); + + aiModelValidator.validate(aiModel, AiModel::getTenantId); + + if (isCreated) { + aiModel.setId(aiModelId); + } + + edgeCtx.getAiModelService().save(aiModel, false); + } catch (Exception e) { + log.error("[{}] Failed to process aiModel update msg [{}]", tenantId, aiModelUpdateMsg, e); + throw e; + } + return Pair.of(isCreated, isNameUpdated); + } + +} diff --git a/application/src/test/java/org/thingsboard/server/edge/imitator/EdgeImitator.java b/application/src/test/java/org/thingsboard/server/edge/imitator/EdgeImitator.java index 16d10d9b6e..4594435626 100644 --- a/application/src/test/java/org/thingsboard/server/edge/imitator/EdgeImitator.java +++ b/application/src/test/java/org/thingsboard/server/edge/imitator/EdgeImitator.java @@ -29,6 +29,7 @@ import org.thingsboard.edge.rpc.EdgeGrpcClient; import org.thingsboard.edge.rpc.EdgeRpcClient; import org.thingsboard.server.controller.AbstractWebTest; import org.thingsboard.server.gen.edge.v1.AdminSettingsUpdateMsg; +import org.thingsboard.server.gen.edge.v1.AiModelUpdateMsg; import org.thingsboard.server.gen.edge.v1.AlarmCommentUpdateMsg; import org.thingsboard.server.gen.edge.v1.AlarmUpdateMsg; import org.thingsboard.server.gen.edge.v1.AssetProfileUpdateMsg; @@ -358,6 +359,11 @@ public class EdgeImitator { result.add(saveDownlinkMsg(calculatedFieldUpdateMsg)); } } + if (downlinkMsg.getAiModelUpdateMsgCount() > 0) { + for (AiModelUpdateMsg aiModelUpdateMsg : downlinkMsg.getAiModelUpdateMsgList()) { + result.add(saveDownlinkMsg(aiModelUpdateMsg)); + } + } if (downlinkMsg.hasEdgeConfiguration()) { result.add(saveDownlinkMsg(downlinkMsg.getEdgeConfiguration())); } diff --git a/common/dao-api/src/main/java/org/thingsboard/server/dao/ai/AiModelService.java b/common/dao-api/src/main/java/org/thingsboard/server/dao/ai/AiModelService.java index 3ad12048cf..55abc80998 100644 --- a/common/dao-api/src/main/java/org/thingsboard/server/dao/ai/AiModelService.java +++ b/common/dao-api/src/main/java/org/thingsboard/server/dao/ai/AiModelService.java @@ -29,6 +29,8 @@ public interface AiModelService extends EntityDaoService { AiModel save(AiModel model); + AiModel save(AiModel model, boolean doValidate); + Optional findAiModelById(TenantId tenantId, AiModelId modelId); PageData findAiModelsByTenantId(TenantId tenantId, PageLink pageLink); @@ -37,6 +39,8 @@ public interface AiModelService extends EntityDaoService { FluentFuture> findAiModelByTenantIdAndIdAsync(TenantId tenantId, AiModelId modelId); + Optional findAiModelByTenantIdAndName(TenantId tenantId, String name); + boolean deleteByTenantIdAndId(TenantId tenantId, AiModelId modelId); } diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/edge/EdgeEventType.java b/common/data/src/main/java/org/thingsboard/server/common/data/edge/EdgeEventType.java index 0d5c3f34ad..fadb44207a 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/edge/EdgeEventType.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/edge/EdgeEventType.java @@ -47,7 +47,8 @@ public enum EdgeEventType { TB_RESOURCE(true, EntityType.TB_RESOURCE), OAUTH2_CLIENT(true, EntityType.OAUTH2_CLIENT), DOMAIN(true, EntityType.DOMAIN), - CALCULATED_FIELD(false, EntityType.CALCULATED_FIELD); + CALCULATED_FIELD(false, EntityType.CALCULATED_FIELD), + AI_MODEL(true, EntityType.AI_MODEL); private final boolean allEdgesRelated; diff --git a/common/edge-api/src/main/java/org/thingsboard/edge/rpc/EdgeGrpcClient.java b/common/edge-api/src/main/java/org/thingsboard/edge/rpc/EdgeGrpcClient.java index 0eff7ad053..dbaef23431 100644 --- a/common/edge-api/src/main/java/org/thingsboard/edge/rpc/EdgeGrpcClient.java +++ b/common/edge-api/src/main/java/org/thingsboard/edge/rpc/EdgeGrpcClient.java @@ -136,7 +136,7 @@ public class EdgeGrpcClient implements EdgeRpcClient { .setConnectRequestMsg(ConnectRequestMsg.newBuilder() .setEdgeRoutingKey(edgeKey) .setEdgeSecret(edgeSecret) - .setEdgeVersion(EdgeVersion.V_4_2_0) + .setEdgeVersion(EdgeVersion.V_4_3_0) .setMaxInboundMessageSize(maxInboundMessageSize) .build()) .build()); diff --git a/common/edge-api/src/main/proto/edge.proto b/common/edge-api/src/main/proto/edge.proto index dbda462a99..5a0aeac977 100644 --- a/common/edge-api/src/main/proto/edge.proto +++ b/common/edge-api/src/main/proto/edge.proto @@ -44,6 +44,7 @@ enum EdgeVersion { V_4_0_0 = 10; V_4_1_0 = 11; V_4_2_0 = 12; + V_4_3_0 = 13; V_LATEST = 999; } @@ -133,6 +134,12 @@ message CalculatedFieldUpdateMsg{ string entity = 4; } +message AiModelUpdateMsg{ + UpdateMsgType msgType = 1; + int64 idMSB = 2; + int64 idLSB = 3; + string entity = 4; +} message EntityDataProto { int64 entityIdMSB = 1; @@ -441,6 +448,7 @@ message UplinkMsg { repeated RuleChainMetadataUpdateMsg ruleChainMetadataUpdateMsg = 24; repeated CalculatedFieldUpdateMsg calculatedFieldUpdateMsg = 25; repeated CalculatedFieldRequestMsg calculatedFieldRequestMsg = 26; + repeated AiModelUpdateMsg aiModelUpdateMsg = 27; } message UplinkResponseMsg { @@ -491,4 +499,5 @@ message DownlinkMsg { repeated NotificationTemplateUpdateMsg notificationTemplateUpdateMsg = 33; repeated OAuth2DomainUpdateMsg oAuth2DomainUpdateMsg = 34; repeated CalculatedFieldUpdateMsg calculatedFieldUpdateMsg = 35; + repeated AiModelUpdateMsg aiModelUpdateMsg = 36; } diff --git a/dao/src/main/java/org/thingsboard/server/dao/ai/AiModelServiceImpl.java b/dao/src/main/java/org/thingsboard/server/dao/ai/AiModelServiceImpl.java index b091a29247..21720813c7 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/ai/AiModelServiceImpl.java +++ b/dao/src/main/java/org/thingsboard/server/dao/ai/AiModelServiceImpl.java @@ -63,7 +63,14 @@ class AiModelServiceImpl extends CachedVersionedEntityService findAiModelByTenantIdAndId(tenantId, modelId))); } + @Override + public Optional findAiModelByTenantIdAndName(TenantId tenantId, String name) { + return Optional.ofNullable(aiModelDao.findByTenantIdAndName(tenantId.getId(), name)); + } + @Override @Transactional public boolean deleteByTenantIdAndId(TenantId tenantId, AiModelId modelId) { From ec6d28e210845d105c8d7c7ab1248eda034236a3 Mon Sep 17 00:00:00 2001 From: Yevhenii Date: Fri, 12 Sep 2025 14:52:40 +0300 Subject: [PATCH 012/102] Fixed minor issue and added test --- .../edge/EdgeEventSourcingListener.java | 4 +- .../processor/ai/AiModelEdgeProcessor.java | 35 +--- .../server/edge/AiModelEdgeTest.java | 197 ++++++++++++++++++ .../common/data/id/EntityIdFactory.java | 1 + .../server/dao/ai/AiModelServiceImpl.java | 35 +++- 5 files changed, 228 insertions(+), 44 deletions(-) create mode 100644 application/src/test/java/org/thingsboard/server/edge/AiModelEdgeTest.java diff --git a/application/src/main/java/org/thingsboard/server/service/edge/EdgeEventSourcingListener.java b/application/src/main/java/org/thingsboard/server/service/edge/EdgeEventSourcingListener.java index fda9a1d17b..825b911403 100644 --- a/application/src/main/java/org/thingsboard/server/service/edge/EdgeEventSourcingListener.java +++ b/application/src/main/java/org/thingsboard/server/service/edge/EdgeEventSourcingListener.java @@ -113,7 +113,7 @@ public class EdgeEventSourcingListener { return; } try { - if (EntityType.TENANT == entityType || EntityType.EDGE == entityType || EntityType.AI_MODEL == entityType) { + if (EntityType.TENANT == entityType || EntityType.EDGE == entityType) { return; } log.trace("[{}] DeleteEntityEvent called: {}", tenantId, event); @@ -227,7 +227,7 @@ public class EdgeEventSourcingListener { break; case TENANT: return !event.getCreated(); - case API_USAGE_STATE, EDGE, AI_MODEL: + case API_USAGE_STATE, EDGE: return false; case DOMAIN: if (entity instanceof Domain domain) { diff --git a/application/src/main/java/org/thingsboard/server/service/edge/rpc/processor/ai/AiModelEdgeProcessor.java b/application/src/main/java/org/thingsboard/server/service/edge/rpc/processor/ai/AiModelEdgeProcessor.java index aaa1f05aa7..cd932ece5d 100644 --- a/application/src/main/java/org/thingsboard/server/service/edge/rpc/processor/ai/AiModelEdgeProcessor.java +++ b/application/src/main/java/org/thingsboard/server/service/edge/rpc/processor/ai/AiModelEdgeProcessor.java @@ -58,12 +58,6 @@ public class AiModelEdgeProcessor extends BaseAiModelProcessor implements AiMode case ENTITY_UPDATED_RPC_MESSAGE: processAiModel(tenantId, aiModelId, aiModelUpdateMsg, edge); return Futures.immediateFuture(null); - case ENTITY_DELETED_RPC_MESSAGE: - Optional aiModel = edgeCtx.getAiModelService().findAiModelById(tenantId, aiModelId); - if (aiModel.isPresent()) { - edgeCtx.getAiModelService().deleteByTenantIdAndId(tenantId, aiModelId); - } - return Futures.immediateFuture(null); case UNRECOGNIZED: default: return handleUnsupportedMsgType(aiModelUpdateMsg.getMsgType()); @@ -111,33 +105,6 @@ public class AiModelEdgeProcessor extends BaseAiModelProcessor implements AiMode return EdgeEventType.AI_MODEL; } -// @Override -// public ListenableFuture processEntityNotification(TenantId tenantId, TransportProtos.EdgeNotificationMsgProto edgeNotificationMsg) { -// EdgeEventType type = EdgeEventType.valueOf(edgeNotificationMsg.getType()); -// EdgeEventActionType actionType = EdgeEventActionType.valueOf(edgeNotificationMsg.getAction()); -// EntityId entityId = EntityIdFactory.getByEdgeEventTypeAndUuid(type, new UUID(edgeNotificationMsg.getEntityIdMSB(), edgeNotificationMsg.getEntityIdLSB())); -// EdgeId originatorEdgeId = safeGetEdgeId(edgeNotificationMsg.getOriginatorEdgeIdMSB(), edgeNotificationMsg.getOriginatorEdgeIdLSB()); -// -// switch (actionType) { -// case UPDATED: -// case ADDED: -// EntityId calculatedFieldOwnerId = JacksonUtil.fromString(edgeNotificationMsg.getBody(), EntityId.class); -// if (calculatedFieldOwnerId != null && -// (EntityType.DEVICE.equals(calculatedFieldOwnerId.getEntityType()) || EntityType.ASSET.equals(calculatedFieldOwnerId.getEntityType()))) { -// JsonNode body = JacksonUtil.toJsonNode(edgeNotificationMsg.getBody()); -// EdgeId edgeId = safeGetEdgeId(edgeNotificationMsg.getEdgeIdMSB(), edgeNotificationMsg.getEdgeIdLSB()); -// -// return edgeId != null ? -// saveEdgeEvent(tenantId, edgeId, type, actionType, entityId, body) : -// processNotificationToRelatedEdges(tenantId, calculatedFieldOwnerId, entityId, type, actionType, originatorEdgeId); -// } else { -// return processActionForAllEdges(tenantId, type, actionType, entityId, null, originatorEdgeId); -// } -// default: -// return super.processEntityNotification(tenantId, edgeNotificationMsg); -// } -// } - private void processAiModel(TenantId tenantId, AiModelId aiModelId, AiModelUpdateMsg aiModelUpdateMsg, Edge edge) { Pair resultPair = super.saveOrUpdateAiModel(tenantId, aiModelId, aiModelUpdateMsg); Boolean wasCreated = resultPair.getFirst(); @@ -158,7 +125,7 @@ public class AiModelEdgeProcessor extends BaseAiModelProcessor implements AiMode TbMsgMetaData msgMetaData = getEdgeActionTbMsgMetaData(edge, edge.getCustomerId()); pushEntityEventToRuleEngine(tenantId, aiModelId, edge.getCustomerId(), TbMsgType.ENTITY_CREATED, aiModelAsString, msgMetaData); } else { - log.warn("[{}][{}] Failed to find AiModel", tenantId, aiModelId); + log.warn("[{}][{}] Failed to find aiModel", tenantId, aiModelId); } } catch (Exception e) { log.warn("[{}][{}] Failed to push aiModel action to rule engine: {}", tenantId, aiModelId, TbMsgType.ENTITY_CREATED.name(), e); diff --git a/application/src/test/java/org/thingsboard/server/edge/AiModelEdgeTest.java b/application/src/test/java/org/thingsboard/server/edge/AiModelEdgeTest.java new file mode 100644 index 0000000000..66a681e7b7 --- /dev/null +++ b/application/src/test/java/org/thingsboard/server/edge/AiModelEdgeTest.java @@ -0,0 +1,197 @@ +/** + * 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.edge; + +import com.datastax.oss.driver.api.core.uuid.Uuids; +import com.google.protobuf.AbstractMessage; +import com.google.protobuf.InvalidProtocolBufferException; +import org.junit.Assert; +import org.junit.Test; +import org.thingsboard.common.util.JacksonUtil; +import org.thingsboard.server.common.data.ai.AiModel; +import org.thingsboard.server.common.data.ai.model.chat.OpenAiChatModelConfig; +import org.thingsboard.server.common.data.ai.provider.OpenAiProviderConfig; +import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.dao.service.DaoSqlTest; +import org.thingsboard.server.gen.edge.v1.AiModelUpdateMsg; +import org.thingsboard.server.gen.edge.v1.UpdateMsgType; +import org.thingsboard.server.gen.edge.v1.UplinkMsg; +import org.thingsboard.server.gen.edge.v1.UplinkResponseMsg; + +import java.util.Optional; +import java.util.UUID; + +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@DaoSqlTest +public class AiModelEdgeTest extends AbstractEdgeTest { + + private static final String DEFAULT_AI_MODEL_NAME = "Edge Test AiModel"; + private static final String UPDATED_AI_MODEL_NAME = "Updated Edge Test AiModel"; + + @Test + public void testAiModel_create_update_delete() throws Exception { + // create AiModel + AiModel aiModel = createSimpleAiModel(DEFAULT_AI_MODEL_NAME); + + edgeImitator.expectMessageAmount(1); + AiModel savedAiModel = doPost("/api/ai/model", aiModel, AiModel.class); + Assert.assertTrue(edgeImitator.waitForMessages()); + + AbstractMessage latestMessage = edgeImitator.getLatestMessage(); + Assert.assertTrue(latestMessage instanceof AiModelUpdateMsg); + AiModelUpdateMsg aiModelUpdateMsg = (AiModelUpdateMsg) latestMessage; + Assert.assertEquals(UpdateMsgType.ENTITY_CREATED_RPC_MESSAGE, aiModelUpdateMsg.getMsgType()); + Assert.assertEquals(savedAiModel.getUuidId().getMostSignificantBits(), aiModelUpdateMsg.getIdMSB()); + Assert.assertEquals(savedAiModel.getUuidId().getLeastSignificantBits(), aiModelUpdateMsg.getIdLSB()); + AiModel aiModelFromMsg = JacksonUtil.fromString(aiModelUpdateMsg.getEntity(), AiModel.class, true); + Assert.assertNotNull(aiModelFromMsg); + + Assert.assertEquals(DEFAULT_AI_MODEL_NAME, aiModelFromMsg.getName()); + Assert.assertEquals(savedAiModel.getTenantId(), aiModelFromMsg.getTenantId()); + + // update AiModel + edgeImitator.expectMessageAmount(1); + savedAiModel.setName(UPDATED_AI_MODEL_NAME); + savedAiModel = doPost("/api/ai/model", savedAiModel, AiModel.class); + Assert.assertTrue(edgeImitator.waitForMessages()); + + latestMessage = edgeImitator.getLatestMessage(); + Assert.assertTrue(latestMessage instanceof AiModelUpdateMsg); + aiModelUpdateMsg = (AiModelUpdateMsg) latestMessage; + aiModelFromMsg = JacksonUtil.fromString(aiModelUpdateMsg.getEntity(), AiModel.class, true); + Assert.assertNotNull(aiModelFromMsg); + Assert.assertEquals(UpdateMsgType.ENTITY_UPDATED_RPC_MESSAGE, aiModelUpdateMsg.getMsgType()); + Assert.assertEquals(UPDATED_AI_MODEL_NAME, aiModelFromMsg.getName()); + + // delete AiModel + edgeImitator.expectMessageAmount(1); + doDelete("/api/ai/model/" + savedAiModel.getUuidId()) + .andExpect(status().isOk()); + Assert.assertTrue(edgeImitator.waitForMessages()); + + latestMessage = edgeImitator.getLatestMessage(); + Assert.assertTrue(latestMessage instanceof AiModelUpdateMsg); + aiModelUpdateMsg = (AiModelUpdateMsg) latestMessage; + Assert.assertEquals(UpdateMsgType.ENTITY_DELETED_RPC_MESSAGE, aiModelUpdateMsg.getMsgType()); + Assert.assertEquals(savedAiModel.getUuidId().getMostSignificantBits(), aiModelUpdateMsg.getIdMSB()); + Assert.assertEquals(savedAiModel.getUuidId().getLeastSignificantBits(), aiModelUpdateMsg.getIdLSB()); + } + + @Test + public void testSendAiModelToCloud() throws Exception { + AiModel aiModel = createSimpleAiModel(DEFAULT_AI_MODEL_NAME); + UUID uuid = Uuids.timeBased(); + UplinkMsg uplinkMsg = getUplinkMsg(uuid, aiModel, UpdateMsgType.ENTITY_CREATED_RPC_MESSAGE); + + checkAiModelOnCloud(uplinkMsg, uuid, aiModel.getName()); + } + + @Test + public void testUpdateAiModelNameOnCloud() throws Exception { + AiModel aiModel = createSimpleAiModel(DEFAULT_AI_MODEL_NAME); + UUID uuid = Uuids.timeBased(); + UplinkMsg uplinkMsg = getUplinkMsg(uuid, aiModel, UpdateMsgType.ENTITY_CREATED_RPC_MESSAGE); + + checkAiModelOnCloud(uplinkMsg, uuid, aiModel.getName()); + + aiModel.setName(UPDATED_AI_MODEL_NAME); + UplinkMsg updatedUplinkMsg = getUplinkMsg(uuid, aiModel, UpdateMsgType.ENTITY_UPDATED_RPC_MESSAGE); + + checkAiModelOnCloud(updatedUplinkMsg, uuid, aiModel.getName()); + } + + @Test + public void testAiModelToCloudWithNameThatAlreadyExistsOnCloud() throws Exception { + AiModel aiModel = createSimpleAiModel(DEFAULT_AI_MODEL_NAME); + + edgeImitator.expectMessageAmount(1); + AiModel savedAiModel = doPost("/api/ai/model", aiModel, AiModel.class); + Assert.assertTrue(edgeImitator.waitForMessages()); + + UUID uuid = Uuids.timeBased(); + + UplinkMsg uplinkMsg = getUplinkMsg(uuid, aiModel, UpdateMsgType.ENTITY_CREATED_RPC_MESSAGE); + + edgeImitator.expectResponsesAmount(1); + edgeImitator.expectMessageAmount(1); + edgeImitator.sendUplinkMsg(uplinkMsg); + + Assert.assertTrue(edgeImitator.waitForResponses()); + Assert.assertTrue(edgeImitator.waitForMessages()); + + Optional aiModelUpdateMsgOpt = edgeImitator.findMessageByType(AiModelUpdateMsg.class); + Assert.assertTrue(aiModelUpdateMsgOpt.isPresent()); + AiModelUpdateMsg latestAiModelUpdateMsg = aiModelUpdateMsgOpt.get(); + AiModel aiModelFromMsg = JacksonUtil.fromString(latestAiModelUpdateMsg.getEntity(), AiModel.class, true); + Assert.assertNotNull(aiModelFromMsg); + Assert.assertNotEquals(DEFAULT_AI_MODEL_NAME, aiModelFromMsg.getName()); + + Assert.assertNotEquals(savedAiModel.getUuidId(), uuid); + + AiModel aiModelFromCloud = doGet("/api/ai/model/" + uuid, AiModel.class); + Assert.assertNotNull(aiModelFromCloud); + Assert.assertNotEquals(DEFAULT_AI_MODEL_NAME, aiModelFromCloud.getName()); + } + + private AiModel createSimpleAiModel(String name) { + AiModel aiModel = new AiModel(); + aiModel.setTenantId(tenantId); + aiModel.setName(name); + aiModel.setConfiguration(OpenAiChatModelConfig.builder() + .providerConfig(new OpenAiProviderConfig("test-api-key")) + .modelId("gpt-4o") + .temperature(0.5) + .topP(0.3) + .frequencyPenalty(0.1) + .presencePenalty(0.2) + .maxOutputTokens(1000) + .timeoutSeconds(60) + .maxRetries(2) + .build()); + return aiModel; + } + + private UplinkMsg getUplinkMsg(UUID uuid, AiModel aiModel, UpdateMsgType updateMsgType) throws InvalidProtocolBufferException { + UplinkMsg.Builder uplinkMsgBuilder = UplinkMsg.newBuilder(); + AiModelUpdateMsg.Builder aiModelUpdateMsgBuilder = AiModelUpdateMsg.newBuilder(); + aiModelUpdateMsgBuilder.setIdMSB(uuid.getMostSignificantBits()); + aiModelUpdateMsgBuilder.setIdLSB(uuid.getLeastSignificantBits()); + aiModelUpdateMsgBuilder.setEntity(JacksonUtil.toString(aiModel)); + aiModelUpdateMsgBuilder.setMsgType(updateMsgType); + testAutoGeneratedCodeByProtobuf(aiModelUpdateMsgBuilder); + uplinkMsgBuilder.addAiModelUpdateMsg(aiModelUpdateMsgBuilder.build()); + + testAutoGeneratedCodeByProtobuf(uplinkMsgBuilder); + + return uplinkMsgBuilder.build(); + } + + private void checkAiModelOnCloud(UplinkMsg uplinkMsg, UUID uuid, String resourceTitle) throws Exception { + edgeImitator.expectResponsesAmount(1); + edgeImitator.sendUplinkMsg(uplinkMsg); + + Assert.assertTrue(edgeImitator.waitForResponses()); + + UplinkResponseMsg latestResponseMsg = edgeImitator.getLatestResponseMsg(); + Assert.assertTrue(latestResponseMsg.getSuccess()); + + AiModel aiModel = doGet("/api/ai/model/" + uuid, AiModel.class); + Assert.assertNotNull(aiModel); + Assert.assertEquals(resourceTitle, aiModel.getName()); + } + +} diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/id/EntityIdFactory.java b/common/data/src/main/java/org/thingsboard/server/common/data/id/EntityIdFactory.java index fc1c1cd1a9..09d34ff10b 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/id/EntityIdFactory.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/id/EntityIdFactory.java @@ -113,6 +113,7 @@ public class EntityIdFactory { case OAUTH2_CLIENT -> new OAuth2ClientId(uuid); case DOMAIN -> new DomainId(uuid); case CALCULATED_FIELD -> new CalculatedFieldId(uuid); + case AI_MODEL -> new AiModelId(uuid); default -> throw new IllegalArgumentException("EdgeEventType " + edgeEventType + " is not supported!"); }; } diff --git a/dao/src/main/java/org/thingsboard/server/dao/ai/AiModelServiceImpl.java b/dao/src/main/java/org/thingsboard/server/dao/ai/AiModelServiceImpl.java index 21720813c7..f898c7aa4f 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/ai/AiModelServiceImpl.java +++ b/dao/src/main/java/org/thingsboard/server/dao/ai/AiModelServiceImpl.java @@ -29,6 +29,8 @@ import org.thingsboard.server.common.data.id.TenantId; import org.thingsboard.server.common.data.page.PageData; import org.thingsboard.server.common.data.page.PageLink; import org.thingsboard.server.dao.entity.CachedVersionedEntityService; +import org.thingsboard.server.dao.eventsourcing.DeleteEntityEvent; +import org.thingsboard.server.dao.eventsourcing.SaveEntityEvent; import org.thingsboard.server.dao.model.sql.AiModelEntity; import org.thingsboard.server.dao.service.DataValidator; import org.thingsboard.server.dao.sql.JpaExecutorService; @@ -67,14 +69,19 @@ class AiModelServiceImpl extends CachedVersionedEntityService Date: Fri, 12 Sep 2025 15:01:48 +0300 Subject: [PATCH 013/102] AiNode enabled for edge --- .../src/main/java/org/thingsboard/rule/engine/ai/TbAiNode.java | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/ai/TbAiNode.java b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/ai/TbAiNode.java index 3497795771..62a2ca241c 100644 --- a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/ai/TbAiNode.java +++ b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/ai/TbAiNode.java @@ -70,8 +70,7 @@ import static org.thingsboard.server.dao.service.ConstraintValidator.validateFie configClazz = TbAiNodeConfiguration.class, configDirective = "tbExternalNodeAiConfig", iconUrl = "data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iNDkiIGhlaWdodD0iNDgiIHZpZXdCb3g9IjAgMCA0OSA0OCIgZmlsbD0ibm9uZSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj4KPHBhdGggZmlsbC1ydWxlPSJldmVub2RkIiBjbGlwLXJ1bGU9ImV2ZW5vZGQiIGQ9Ik0zOC42MzExIDE3LjA3OTVDNDAuMTcwNSAxNy4wNzk2IDQxLjY1MTggMTcuNjg3MiA0Mi43NDc4IDE4Ljc3NjNDNDMuODQ0OCAxOS44NjYzIDQ0LjQ2NTkgMjEuMzUwMSA0NC40NjU5IDIyLjkwMjlWMzUuNDY1MkM0NC40NjU5IDM2LjM1MDkgNDQuMzU2NyAzNy4wNzY5IDQ0LjA5NzMgMzcuNzUxN0M0My44NDE0IDM4LjQxNjcgNDMuNDY1MSAzOC45NjE0IDQzLjA0NDggMzkuNTAyOEM0Mi40NjY3IDQwLjI0NzIgNDEuNjU2MyA0MC42ODU5IDQwLjg5MTkgNDAuOTM4OEM0MC4xMjExIDQxLjE5MzcgMzkuMzE0MyA0MS4yODg1IDM4LjYzMTEgNDEuMjg4NUgzMS4wMjU5TDIzLjM4MTIgNDUuODQ2NEMyMy4wNDMxIDQ2LjA0NzggMjIuNjI0MSA0Ni4wNTA3IDIyLjI4MzkgNDUuODUyOUMyMS45NDM3IDQ1LjY1NDcgMjEuNzMzOCA0NS4yODU5IDIxLjczMzcgNDQuODg3MlY0MS4yODg1SDE5LjY2NjNDMTguMTI2OSA0MS4yODg0IDE2LjY0NTUgNDAuNjgwOSAxNS41NDk2IDM5LjU5MThDMTQuNDUyNyAzOC41MDE5IDEzLjgzMTUgMzcuMDE3OSAxMy44MzE1IDM1LjQ2NTJWMjIuOTAyOUMxMy44MzE1IDIyLjMyMDIgMTMuOTE4NSAyMS43NDY4IDE0LjA4NTggMjEuMjAwN0wxNi4yODg5IDIxLjgxMDFMMTcuMjA5OSAyNS4yNTAyQzE3Ljk0MTYgMjcuOTg0NSAyMS43NTYyIDI3Ljk4NDQgMjIuNDg4IDI1LjI1MDJMMjMuNDA3OSAyMS44MTAxTDI2Ljc5MTcgMjAuODc0OUMyOC41NzkxIDIwLjM4MDUgMjkuMTc3IDE4LjUwMjYgMjguNTg4OCAxNy4wNzk1SDM4LjYzMTFaTTIyLjU4NDIgMzEuNTM5NUMyMS45OCAzMS41Mzk3IDIxLjQ5MDEgMzIuMDM3NiAyMS40OTAxIDMyLjY1MTlDMjEuNDkwMiAzMy4yNjYgMjEuOTgwMSAzMy43NjQgMjIuNTg0MiAzMy43NjQySDM0LjYxOTFDMzUuMjIzMyAzMy43NjQyIDM1LjcxMzEgMzMuMjY2MSAzNS43MTMyIDMyLjY1MTlDMzUuNzEzMiAzMi4wMzc1IDM1LjIyMzQgMzEuNTM5NSAzNC42MTkxIDMxLjUzOTVIMjIuNTg0MlpNMjQuNzcyMyAyNC44NjU3QzI0LjE2ODIgMjQuODY1OCAyMy42NzgzIDI1LjM2MzggMjMuNjc4MyAyNS45NzhDMjMuNjc4NCAyNi41OTIyIDI0LjE2ODMgMjcuMDkwMiAyNC43NzIzIDI3LjA5MDNIMzcuOTAxNEMzOC41MDU1IDI3LjA5MDMgMzguOTk1MyAyNi41OTIyIDM4Ljk5NTQgMjUuOTc4QzM4Ljk5NTQgMjUuMzYzNyAzOC41MDU2IDI0Ljg2NTcgMzcuOTAxNCAyNC44NjU3SDI0Ljc3MjNaIiBmaWxsPSJibGFjayIgZmlsbC1vcGFjaXR5PSIwLjc2Ii8+CjxwYXRoIGQ9Ik0xOC43ODkxIDExLjI5NzVDMTkuMDY5MSAxMC4xODA4IDIwLjYyOTkgMTAuMTgwOCAyMC45MDk5IDExLjI5NzVMMjEuOTE0MyAxNS4zMDM2QzIyLjAxMTYgMTUuNjkxOCAyMi4zMDY1IDE1Ljk5NzggMjIuNjg2NyAxNi4xMDNMMjYuMzYxMSAxNy4xMTg3QzI3LjQzNyAxNy40MTYyIDI3LjQzNyAxOC45Njc2IDI2LjM2MTEgMTkuMjY1MUwyMi42NzYxIDIwLjI4NEMyMi4zMDE4IDIwLjM4NzQgMjIuMDA4NyAyMC42ODQ1IDIxLjkwNjggMjEuMDY1TDIwLjkwNDYgMjQuODEyNUMyMC42MTE3IDI1LjkwNTggMTkuMDg2MSAyNS45MDU5IDE4Ljc5MzMgMjQuODEyNUwxNy43OTExIDIxLjA2NUMxNy42ODkzIDIwLjY4NDcgMTcuMzk3IDIwLjM4NzUgMTcuMDIyOSAyMC4yODRMMTMuMzM2OCAxOS4yNjUxQzEyLjI2MTQgMTguOTY3MyAxMi4yNjE1IDE3LjQxNjUgMTMuMzM2OCAxNy4xMTg3TDE3LjAxMTIgMTYuMTAzQzE3LjM5MTYgMTUuOTk3OCAxNy42ODc0IDE1LjY5MTkgMTcuNzg0NyAxNS4zMDM2TDE4Ljc4OTEgMTEuMjk3NVoiIGZpbGw9ImJsYWNrIiBmaWxsLW9wYWNpdHk9IjAuNzYiLz4KPHBhdGggZD0iTTEwLjAzNDMgNy4wMjQyNUMxMC4zMDY4IDUuODk0NDQgMTEuODg2OCA1Ljg5NDQ0IDEyLjE1OTQgNy4wMjQyNUwxMi42OTg5IDkuMjYyOThDMTIuNzkyNyA5LjY1MTc0IDEzLjA4NTEgOS45NTg4NyAxMy40NjQgMTAuMDY3OUwxNS41NzczIDEwLjY3NTFDMTYuNjM5MyAxMC45ODAzIDE2LjYzOTMgMTIuNTEwOSAxNS41NzczIDEyLjgxNjFMMTMuNDUzMyAxMy40MjY1QzEzLjA4MDIgMTMuNTMzOCAxMi43OTA4IDEzLjgzMzkgMTIuNjkyNSAxNC4yMTUxTDEyLjE1NTEgMTYuMzA0QzExLjg3IDE3LjQxMTYgMTAuMzIzNiAxNy40MTE2IDEwLjAzODUgMTYuMzA0TDkuNTAwMDMgMTQuMjE1MUM5LjQwMTczIDEzLjgzMzkgOS4xMTIzNSAxMy41MzM3IDguNzM5MyAxMy40MjY1TDYuNjE1MjQgMTIuODE2MUM1LjU1Mzc4IDEyLjUxMDYgNS41NTM2NCAxMC45ODA0IDYuNjE1MjQgMTAuNjc1MUw4LjcyODYyIDEwLjA2NzlDOS4xMDc2IDkuOTU4OTggOS4zOTk3OCA5LjY1MTg0IDkuNDkzNjIgOS4yNjI5OEwxMC4wMzQzIDcuMDI0MjVaIiBmaWxsPSJibGFjayIgZmlsbC1vcGFjaXR5PSIwLjc2Ii8+CjxwYXRoIGQ9Ik0yNS45MDI4IDYuNzMzMTNDMjYuMTg3OCA1LjYyNTQxIDI3LjczNDMgNS42MjU0MSAyOC4wMTkzIDYuNzMzMTNMMjguMjAzMSA3LjQ0Njc5QzI4LjMwMyA3LjgzNDMxIDI4LjYwMDEgOC4xMzcwNSAyOC45ODA5IDguMjM5NzVMMjkuNTM0NCA4LjM4OTY1QzMwLjYxOTIgOC42ODIxMiAzMC42MTkzIDEwLjI0NjkgMjkuNTM0NCAxMC41MzkzTDI4Ljk2OTIgMTAuNjkxNEMyOC41OTQ0IDEwLjc5MjUgMjguMjk5OSAxMS4wODgzIDI4LjE5NTYgMTEuNDY4TDI4LjAxNTEgMTIuMTI4NUMyNy43MTc0IDEzLjIxMjggMjYuMjA0NyAxMy4yMTI4IDI1LjkwNyAxMi4xMjg1TDI1LjcyNTQgMTEuNDY4QzI1LjYyMTEgMTEuMDg4MiAyNS4zMjY4IDEwLjc5MjQgMjQuOTUxOCAxMC42OTE0TDI0LjM4NzcgMTAuNTM5M0MyMy4zMDI2IDEwLjI0NyAyMy4zMDI2IDguNjgxOTggMjQuMzg3NyA4LjM4OTY1TDI0Ljk0MDEgOC4yMzk3NUMyNS4zMjExIDguMTM3MDkgMjUuNjE5MSA3LjgzNDQ2IDI1LjcxOSA3LjQ0Njc5TDI1LjkwMjggNi43MzMxM1oiIGZpbGw9ImJsYWNrIiBmaWxsLW9wYWNpdHk9IjAuNzYiLz4KPC9zdmc+Cg==", - docUrl = "https://thingsboard.io/docs/user-guide/rule-engine-2-0/external-nodes/#ai-request-node", - ruleChainTypes = RuleChainType.CORE + docUrl = "https://thingsboard.io/docs/user-guide/rule-engine-2-0/external-nodes/#ai-request-node" ) public final class TbAiNode extends TbAbstractExternalNode implements TbNode { From d6d9413082e859560e2e7d55e5dee1dd47ebadc5 Mon Sep 17 00:00:00 2001 From: Yevhenii Date: Mon, 15 Sep 2025 11:35:03 +0300 Subject: [PATCH 014/102] Added AiModel methods to RestClient --- .../thingsboard/rest/client/RestClient.java | 29 +++++++++++++++++-- 1 file changed, 27 insertions(+), 2 deletions(-) diff --git a/rest-client/src/main/java/org/thingsboard/rest/client/RestClient.java b/rest-client/src/main/java/org/thingsboard/rest/client/RestClient.java index 0e559efd46..1e03a8b11c 100644 --- a/rest-client/src/main/java/org/thingsboard/rest/client/RestClient.java +++ b/rest-client/src/main/java/org/thingsboard/rest/client/RestClient.java @@ -74,6 +74,7 @@ import org.thingsboard.server.common.data.UpdateMessage; import org.thingsboard.server.common.data.UsageInfo; import org.thingsboard.server.common.data.User; import org.thingsboard.server.common.data.UserEmailInfo; +import org.thingsboard.server.common.data.ai.AiModel; import org.thingsboard.server.common.data.alarm.Alarm; import org.thingsboard.server.common.data.alarm.AlarmComment; import org.thingsboard.server.common.data.alarm.AlarmCommentInfo; @@ -98,6 +99,7 @@ import org.thingsboard.server.common.data.edge.EdgeInfo; import org.thingsboard.server.common.data.edge.EdgeInstructions; import org.thingsboard.server.common.data.edge.EdgeSearchQuery; import org.thingsboard.server.common.data.entityview.EntityViewSearchQuery; +import org.thingsboard.server.common.data.id.AiModelId; import org.thingsboard.server.common.data.id.AlarmCommentId; import org.thingsboard.server.common.data.id.AlarmId; import org.thingsboard.server.common.data.id.AssetId; @@ -3006,7 +3008,7 @@ public class RestClient implements Closeable { addWidgetInfoFiltersToParams(tenantOnly, fullSearch, deprecatedFilter, widgetTypeList, params); return restTemplate.exchange( baseURL + "/api/widgetTypes?" + getUrlParams(pageLink) + - getWidgetTypeInfoPageRequestUrlParams(tenantOnly, fullSearch, deprecatedFilter, widgetTypeList), + getWidgetTypeInfoPageRequestUrlParams(tenantOnly, fullSearch, deprecatedFilter, widgetTypeList), HttpMethod.GET, HttpEntity.EMPTY, new ParameterizedTypeReference>() { @@ -3094,7 +3096,7 @@ public class RestClient implements Closeable { addWidgetInfoFiltersToParams(tenantOnly, fullSearch, deprecatedFilter, widgetTypeList, params); return restTemplate.exchange( baseURL + "/api/widgetTypesInfos?widgetsBundleId={widgetsBundleId}&" + getUrlParams(pageLink) + - getWidgetTypeInfoPageRequestUrlParams(tenantOnly, fullSearch, deprecatedFilter, widgetTypeList), + getWidgetTypeInfoPageRequestUrlParams(tenantOnly, fullSearch, deprecatedFilter, widgetTypeList), HttpMethod.GET, HttpEntity.EMPTY, new ParameterizedTypeReference>() { @@ -4144,6 +4146,29 @@ public class RestClient implements Closeable { } } + public AiModel saveAiModel(AiModel aiModel) { + return restTemplate.postForEntity(baseURL + "/api/ai/model", aiModel, AiModel.class).getBody(); + } + + public Optional getAiModel(AiModelId aiModelId) { + try { + ResponseEntity response = restTemplate.getForEntity( + baseURL + "/api/aiModel/{aiModelId}", AiModel.class, aiModelId.getId()); + return Optional.ofNullable(response.getBody()); + } catch (HttpClientErrorException exception) { + if (exception.getStatusCode() == HttpStatus.NOT_FOUND) { + return Optional.empty(); + } else { + throw exception; + } + } + } + + public void deleteAiModel(AiModelId aiModelId) { + restTemplate.delete(baseURL + "/api/aiModel/{aiModelId}", aiModelId.getId()); + } + + private String getTimeUrlParams(TimePageLink pageLink) { String urlParams = getUrlParams(pageLink); if (pageLink.getStartTime() != null) { From caec9699d47f0dc1e89187f86d118c4c63be4443 Mon Sep 17 00:00:00 2001 From: Yevhenii Date: Tue, 16 Sep 2025 18:19:45 +0300 Subject: [PATCH 015/102] Refactoring --- .../processor/ai/AiModelEdgeProcessor.java | 7 +------ .../server/dao/ai/AiModelServiceImpl.java | 20 ++++++++----------- 2 files changed, 9 insertions(+), 18 deletions(-) diff --git a/application/src/main/java/org/thingsboard/server/service/edge/rpc/processor/ai/AiModelEdgeProcessor.java b/application/src/main/java/org/thingsboard/server/service/edge/rpc/processor/ai/AiModelEdgeProcessor.java index cd932ece5d..74ca7ac27a 100644 --- a/application/src/main/java/org/thingsboard/server/service/edge/rpc/processor/ai/AiModelEdgeProcessor.java +++ b/application/src/main/java/org/thingsboard/server/service/edge/rpc/processor/ai/AiModelEdgeProcessor.java @@ -63,12 +63,7 @@ public class AiModelEdgeProcessor extends BaseAiModelProcessor implements AiMode return handleUnsupportedMsgType(aiModelUpdateMsg.getMsgType()); } } catch (DataValidationException e) { - if (e.getMessage().contains("limit reached")) { - log.warn("[{}] Number of allowed aiModel violated {}", tenantId, aiModelUpdateMsg, e); - return Futures.immediateFuture(null); - } else { - return Futures.immediateFailedFuture(e); - } + return Futures.immediateFailedFuture(e); } finally { edgeSynchronizationManager.getEdgeId().remove(); } diff --git a/dao/src/main/java/org/thingsboard/server/dao/ai/AiModelServiceImpl.java b/dao/src/main/java/org/thingsboard/server/dao/ai/AiModelServiceImpl.java index f898c7aa4f..971eba8921 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/ai/AiModelServiceImpl.java +++ b/dao/src/main/java/org/thingsboard/server/dao/ai/AiModelServiceImpl.java @@ -37,6 +37,7 @@ import org.thingsboard.server.dao.sql.JpaExecutorService; import java.util.Optional; import java.util.Set; +import java.util.UUID; import static org.thingsboard.server.dao.service.Validator.validatePageLink; @@ -125,11 +126,7 @@ class AiModelServiceImpl extends CachedVersionedEntityService Date: Tue, 16 Sep 2025 18:52:21 +0300 Subject: [PATCH 016/102] Refactoring --- .../org/thingsboard/server/dao/ai/AiModelServiceImpl.java | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/dao/src/main/java/org/thingsboard/server/dao/ai/AiModelServiceImpl.java b/dao/src/main/java/org/thingsboard/server/dao/ai/AiModelServiceImpl.java index 971eba8921..7cdf5b027d 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/ai/AiModelServiceImpl.java +++ b/dao/src/main/java/org/thingsboard/server/dao/ai/AiModelServiceImpl.java @@ -146,8 +146,8 @@ class AiModelServiceImpl extends CachedVersionedEntityService Date: Tue, 23 Sep 2025 16:53:19 +0300 Subject: [PATCH 017/102] UI: Enforce 2FA --- ui-ngx/src/app/core/auth/auth.service.ts | 17 +- ui-ngx/src/app/core/guards/auth.guard.ts | 10 + .../two-factor-auth-settings.component.html | 351 ++++++++++-------- .../two-factor-auth-settings.component.scss | 7 - .../two-factor-auth-settings.component.ts | 49 ++- .../app/modules/login/login-routing.module.ts | 11 + ui-ngx/src/app/modules/login/login.module.ts | 4 +- ...force-two-factor-auth-login.component.html | 296 +++++++++++++++ ...force-two-factor-auth-login.component.scss | 110 ++++++ .../force-two-factor-auth-login.component.ts | 300 +++++++++++++++ .../src/app/shared/models/authority.enum.ts | 3 +- .../shared/models/two-factor-auth.models.ts | 68 ++++ .../assets/locale/locale.constant-en_US.json | 35 +- 13 files changed, 1093 insertions(+), 168 deletions(-) create mode 100644 ui-ngx/src/app/modules/login/pages/login/force-two-factor-auth-login.component.html create mode 100644 ui-ngx/src/app/modules/login/pages/login/force-two-factor-auth-login.component.scss create mode 100644 ui-ngx/src/app/modules/login/pages/login/force-two-factor-auth-login.component.ts diff --git a/ui-ngx/src/app/core/auth/auth.service.ts b/ui-ngx/src/app/core/auth/auth.service.ts index f8ffd5e8b6..3b6a15688c 100644 --- a/ui-ngx/src/app/core/auth/auth.service.ts +++ b/ui-ngx/src/app/core/auth/auth.service.ts @@ -68,6 +68,7 @@ export class AuthService { redirectUrl: string; oauth2Clients: Array = null; twoFactorAuthProviders: Array = null; + forceTwoFactorAuthProviders: Array = null; private refreshTokenSubject: ReplaySubject = null; private jwtHelper = new JwtHelperService(); @@ -117,6 +118,9 @@ export class AuthService { if (loginResponse.scope === Authority.PRE_VERIFICATION_TOKEN) { this.router.navigateByUrl(`login/mfa`); } + if (loginResponse.scope === Authority.MFA_CONFIGURATION_TOKEN) { + this.router.navigateByUrl(`login/force-mfa`); + } } )); } @@ -239,6 +243,15 @@ export class AuthService { ); } + public getAvailableTwoFaProviders(): Observable> { + return this.http.get>(`/api/2fa/providers`, defaultHttpOptions()).pipe( + catchError(() => of([])), + tap((providers) => { + this.forceTwoFactorAuthProviders = providers; + }) + ); + } + public forceDefaultPlace(authState?: AuthState, path?: string, params?: any): boolean { if (authState && authState.authUser) { if (authState.authUser.authority === Authority.TENANT_ADMIN || authState.authUser.authority === Authority.CUSTOMER_USER) { @@ -266,6 +279,8 @@ export class AuthService { if (isAuthenticated) { if (authState.authUser.authority === Authority.PRE_VERIFICATION_TOKEN) { result = this.router.parseUrl('login/mfa'); + } else if (authState.authUser.authority === Authority.MFA_CONFIGURATION_TOKEN) { + result = this.router.parseUrl('login/force-mfa'); } else if (!path || path === 'login' || this.forceDefaultPlace(authState, path, params)) { if (this.redirectUrl) { const redirectUrl = this.redirectUrl; @@ -399,7 +414,7 @@ export class AuthService { loadUserSubject.error(err); } ); - } else if (authPayload.authUser?.authority === Authority.PRE_VERIFICATION_TOKEN) { + } else if (authPayload.authUser?.authority === Authority.PRE_VERIFICATION_TOKEN || authPayload.authUser?.authority === Authority.MFA_CONFIGURATION_TOKEN) { loadUserSubject.next(authPayload); loadUserSubject.complete(); } else if (authPayload.authUser?.userId) { diff --git a/ui-ngx/src/app/core/guards/auth.guard.ts b/ui-ngx/src/app/core/guards/auth.guard.ts index f6d8753882..f8bbcc3241 100644 --- a/ui-ngx/src/app/core/guards/auth.guard.ts +++ b/ui-ngx/src/app/core/guards/auth.guard.ts @@ -104,6 +104,16 @@ export class AuthGuard { } this.authService.logout(); return of(this.authService.defaultUrl(false)); + } else if (path === 'login.force-mfa') { + if (authState.authUser?.authority === Authority.MFA_CONFIGURATION_TOKEN) { + return this.authService.getAvailableTwoFaProviders().pipe( + map(() => { + return true; + }) + ); + } + this.authService.logout(); + return of(this.authService.defaultUrl(false)); } else { return of(true); } diff --git a/ui-ngx/src/app/modules/home/pages/admin/two-factor-auth-settings.component.html b/ui-ngx/src/app/modules/home/pages/admin/two-factor-auth-settings.component.html index 8605921040..faf1904427 100644 --- a/ui-ngx/src/app/modules/home/pages/admin/two-factor-auth-settings.component.html +++ b/ui-ngx/src/app/modules/home/pages/admin/two-factor-auth-settings.component.html @@ -30,163 +30,216 @@
-
-
- admin.2fa.available-providers - - - - - - {{ twoFactorAuthProvidersData.get(provider.value.providerType).name | translate }} - - - - - - - - - admin.2fa.issuer-name - - - {{ "admin.2fa.issuer-name-required" | translate }} - - - -
- - admin.2fa.verification-message-template - - - {{ "admin.2fa.verification-message-template-required" | translate }} - - - {{ "admin.2fa.verification-message-template-pattern" | translate }} - - - - - admin.2fa.verification-code-lifetime - - - {{ "admin.2fa.verification-code-lifetime-required" | translate }} - - - {{ "admin.2fa.verification-code-lifetime-pattern" | translate }} - - -
-
- - admin.2fa.verification-code-lifetime - - - {{ "admin.2fa.verification-code-lifetime-required" | translate }} - - - {{ "admin.2fa.verification-code-lifetime-pattern" | translate }} - - -
-
- - admin.2fa.number-of-codes - - - {{ "admin.2fa.number-of-codes-required" | translate }} - - - {{ "admin.2fa.number-of-codes-pattern" | translate }} - - -
-
-
-
- -
-
-
- admin.2fa.verification-limitations -
- - admin.2fa.total-allowed-time-for-verification - - - {{ 'admin.2fa.total-allowed-time-for-verification-required' | translate }} - - - {{ 'admin.2fa.total-allowed-time-for-verification-pattern' | translate }} - - - - admin.2fa.retry-verification-code-period - - - {{ 'admin.2fa.retry-verification-code-period-required' | translate }} - - - {{ 'admin.2fa.retry-verification-code-period-pattern' | translate }} - - - - admin.2fa.max-verification-failures-before-user-lockout - - - {{ 'admin.2fa.max-verification-failures-before-user-lockout-pattern' | translate }} - - -
- - +
+
+ - - + + + {{ 'admin.2fa.force-2fa' | translate }} - {{ 'admin.2fa.verification-code-check-rate-limit' | translate }} -
- - admin.2fa.number-of-checking-attempts - - - {{ 'admin.2fa.number-of-checking-attempts-required' | translate }} - - - {{ 'admin.2fa.number-of-checking-attempts-pattern' | translate }} - +
+ + admin.2fa.enforce-for + + + {{ notificationTargetConfigTypeInfoMap.get(type).name | translate }} + + - - admin.2fa.within-time - - - {{ 'admin.2fa.within-time-required' | translate }} - - - {{ 'admin.2fa.within-time-pattern' | translate }} - - -
+
+
+ + {{ 'tenant.tenant' | translate }} + {{ 'tenant-profile.tenant-profile' | translate }} + +
+ + + + + + + + +
+
-
+
+ +
+
admin.2fa.available-providers
+ +
+ + + + + {{ twoFactorAuthProvidersData.get(provider.value.providerType).name | translate }} + + + + + + + + + admin.2fa.issuer-name + + + {{ "admin.2fa.issuer-name-required" | translate }} + + + +
+ + admin.2fa.verification-message-template + + + {{ "admin.2fa.verification-message-template-required" | translate }} + + + {{ "admin.2fa.verification-message-template-pattern" | translate }} + + + + +
+
+ + +
+
+ + admin.2fa.number-of-codes + + + {{ "admin.2fa.number-of-codes-required" | translate }} + + + {{ "admin.2fa.number-of-codes-pattern" | translate }} + + +
+
+
+
+
+
+
+
+
admin.2fa.verification-limitations
+
+
+ + + + + + admin.2fa.max-verification-failures-before-user-lockout + + + {{ 'admin.2fa.max-verification-failures-before-user-lockout-pattern' | translate }} + + +
+
+ + + + + {{ 'admin.2fa.verification-code-check-rate-limit' | translate }} + + + + +
+ + admin.2fa.number-of-checking-attempts + + + {{ 'admin.2fa.number-of-checking-attempts-required' | translate }} + + + {{ 'admin.2fa.number-of-checking-attempts-pattern' | translate }} + + + + +
+
+
+
+
+
+ {{ (config ? 'login.two-fa' :'login.two-fa-required') | translate }} + + + +
+

{{ (config ? 'login.set-up-verification-method-login' :'login.set-up-verification-method') | translate }}

+ + + + @if (config) { + + } +
+
+ + } + @case (ForceTwoFAState.AUTHENTICATOR_APP) { + @switch (appState()) { + @case (ProvidersState.INPUT) { + + } + @case (ProvidersState.ENTER_CODE) { + + } + @case (ProvidersState.SUCCESS) { + + } + } + } + @case (ForceTwoFAState.SMS) { + @switch (smsState()) { + @case (ProvidersState.INPUT) { + + } + @case (ProvidersState.ENTER_CODE) { + + } + @case (ProvidersState.SUCCESS) { + + } + } + } + @case (ForceTwoFAState.EMAIL) { + @switch (emailState()) { + @case (ProvidersState.INPUT) { + + } + @case (ProvidersState.ENTER_CODE) { + + } + @case (ProvidersState.SUCCESS) { + + } + } + } + @case (ForceTwoFAState.BACKUP_CODE) { + @switch (backupCodeState()) { + @case (BackupCodeState.CODE) { + + } + @case (BackupCodeState.SUCCESS) { + + } + } + } + } +
+ + + + + + + diff --git a/ui-ngx/src/app/modules/login/pages/login/force-two-factor-auth-login.component.scss b/ui-ngx/src/app/modules/login/pages/login/force-two-factor-auth-login.component.scss new file mode 100644 index 0000000000..d62d7f1628 --- /dev/null +++ b/ui-ngx/src/app/modules/login/pages/login/force-two-factor-auth-login.component.scss @@ -0,0 +1,110 @@ +/** + * 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. + */ +@import '../../../../../scss/constants'; + +:host { + display: flex; + flex: 1 1 0; + width: 100%; + height: 100%; + + .tb-two-factor-auth-login-content { + background-color: #eee; + + .tb-two-factor-auth-login-card { + max-height: 100vh; + overflow: auto; + padding: 48px 48px 48px 16px; + + @media #{$mat-xs} { + height: 100%; + } + + @media #{$mat-gt-xs} { + width: 450px !important; + } + + .mat-mdc-card-title { + font: 400 28px / 36px Roboto, "Helvetica Neue", sans-serif; + } + + .mat-mdc-card-header { + padding: 0; + } + + .mat-mdc-card-content { + margin-top: 34px; + margin-left: 40px; + padding: 0; + } + + .mat-body { + letter-spacing: 0.25px; + line-height: 16px; + } + + .backup-code { + p { + text-align: justify; + } + + .container { + border: 1px solid; + border-radius: 4px; + gap: 16px; + display: grid; + grid-template-columns: 1fr 1fr; + justify-items: center; + padding: 16px 0; + margin-bottom: 16px; + + .code { + letter-spacing: 0.25px; + font-family: Roboto Mono, "Helvetica Neue", monospace; + } + } + + .action-buttons { + margin-bottom: 40px; + } + } + } + } + + ::ng-deep { + .tb-two-factor-auth-login-content { + .tb-two-factor-auth-login-card { + button.mat-mdc-icon-button { + .mat-icon { + color: rgba(255, 255, 255, 0.8); + } + } + } + .mat-mdc-form-field .mat-mdc-form-field-hint-wrapper { + color: rgba(255, 255, 255, 0.8); + } + } + + button.provider, button.navigation { + text-align: start; + font-weight: 400; + color: rgba(255, 255, 255, 0.8); + &:not([disabled][disabled]) { + border-color: rgba(255, 255, 255, .8); + } + } + } +} diff --git a/ui-ngx/src/app/modules/login/pages/login/force-two-factor-auth-login.component.ts b/ui-ngx/src/app/modules/login/pages/login/force-two-factor-auth-login.component.ts new file mode 100644 index 0000000000..ef1917ba24 --- /dev/null +++ b/ui-ngx/src/app/modules/login/pages/login/force-two-factor-auth-login.component.ts @@ -0,0 +1,300 @@ +/// +/// 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. +/// + +import { Component, ElementRef, OnDestroy, OnInit, signal, ViewChild } from '@angular/core'; +import { AuthService } from '@core/auth/auth.service'; +import { Store } from '@ngrx/store'; +import { AppState } from '@core/core.state'; +import { PageComponent } from '@shared/components/page.component'; +import { UntypedFormBuilder, UntypedFormGroup, Validators } from '@angular/forms'; +import { TwoFactorAuthenticationService } from '@core/http/two-factor-authentication.service'; +import { + AccountTwoFaSettings, + BackupCodeTwoFactorAuthAccountConfig, + TotpTwoFactorAuthAccountConfig, + TwoFactorAuthAccountConfig, + twoFactorAuthProvidersEnterCodeCardTranslate, + twoFactorAuthProvidersLoginData, + twoFactorAuthProvidersSuccessCardTranslate, + TwoFactorAuthProviderType +} from '@shared/models/two-factor-auth.models'; +import { phoneNumberPattern } from '@shared/models/settings.models'; +import { deepClone, isDefinedAndNotNull, unwrapModule } from '@core/utils'; +import { MatDialog } from '@angular/material/dialog'; +import { DialogService } from '@core/services/dialog.service'; +import { getCurrentAuthUser } from '@core/auth/auth.selectors'; +import printTemplate from '@home/pages/security/authentication-dialog/backup-code-print-template.raw'; +import { ImportExportService } from '@shared/import-export/import-export.service'; +import { mergeMap, tap } from 'rxjs/operators'; + +enum ForceTwoFAState { + SETUP = 'setup', + AUTHENTICATOR_APP = 'authenticatorApp', + SMS = 'sms', + EMAIL = 'email', + BACKUP_CODE = 'backupCode', +} + +enum ProvidersState { + INPUT = 'INPUT', + ENTER_CODE = 'ENTER_CODE', + SUCCESS = 'SUCCESS', +} + +enum BackupCodeState { + CODE = 'CODE', + SUCCESS = 'SUCCESS', +} + +@Component({ + selector: 'tb-force-two-factor-auth-login', + templateUrl: './force-two-factor-auth-login.component.html', + styleUrls: ['./force-two-factor-auth-login.component.scss'] +}) +export class ForceTwoFactorAuthLoginComponent extends PageComponent implements OnInit, OnDestroy { + + TwoFactorAuthProviderType = TwoFactorAuthProviderType; + providersData = twoFactorAuthProvidersLoginData; + allowProviders: TwoFactorAuthProviderType[] = []; + config: AccountTwoFaSettings; + + twoFactorAuthProvidersEnterCodeCardTranslate = twoFactorAuthProvidersEnterCodeCardTranslate; + twoFactorAuthProvidersSuccessCardTranslate = twoFactorAuthProvidersSuccessCardTranslate; + + ForceTwoFAState = ForceTwoFAState; + ProvidersState = ProvidersState; + BackupCodeState = BackupCodeState + + state = signal(ForceTwoFAState.SETUP); + appState = signal(ProvidersState.INPUT); + smsState = signal(ProvidersState.INPUT); + emailState = signal(ProvidersState.INPUT); + backupCodeState = signal(BackupCodeState.CODE); + + totpAuthURL: string; + totpAuthURLSecret: string; + backupCode: BackupCodeTwoFactorAuthAccountConfig; + + configForm: UntypedFormGroup; + smsConfigForm: UntypedFormGroup; + emailConfigForm: UntypedFormGroup; + + private providersInfo: TwoFactorAuthProviderType[]; + private authAccountConfig: TwoFactorAuthAccountConfig; + private useByDefault: boolean = true; + + @ViewChild('canvas', {static: false}) canvasRef: ElementRef; + + constructor(protected store: Store, + private authService: AuthService, + private twoFaService: TwoFactorAuthenticationService, + private importExportService: ImportExportService, + public dialog: MatDialog, + public dialogService: DialogService, + private fb: UntypedFormBuilder) { + super(store); + } + + ngOnInit() { + this.providersInfo = this.authService.forceTwoFactorAuthProviders; + this.allowedProviders(); + this.configForm = this.fb.group({ + verificationCode: ['', [ + Validators.required, + Validators.minLength(6), + Validators.maxLength(6), + Validators.pattern(/^\d*$/) + ]] + }); + + this.smsConfigForm = this.fb.group({ + phone: ['', [Validators.required, Validators.pattern(phoneNumberPattern)]] + }); + + this.emailConfigForm = this.fb.group({ + email: [getCurrentAuthUser(this.store).sub, [Validators.required, Validators.email]] + }); + + this.twoFaService.getAccountTwoFaSettings().subscribe(accountConfig => { + if (accountConfig) { + this.config = accountConfig; + this.useByDefault = false; + } + }); + } + + goBackByType(type: TwoFactorAuthProviderType) { + switch (type) { + case TwoFactorAuthProviderType.TOTP: + this.appState.set(ProvidersState.INPUT); + this.updateQRCode(); + break; + case TwoFactorAuthProviderType.SMS: + this.smsState.set(ProvidersState.INPUT); + break; + case TwoFactorAuthProviderType.EMAIL: + this.emailState.set(ProvidersState.INPUT); + break; + } + } + + get isAnyProviderAvailable() { + return this.config?.configs ? Object.keys(this.config?.configs)?.length < this.allowProviders?.length : true; + } + + private allowedProviders() { + if (isDefinedAndNotNull(this.config)) { + this.allowProviders = this.providersInfo; + } else { + this.allowProviders = this.providersInfo.filter(provider => provider !== TwoFactorAuthProviderType.BACKUP_CODE); + } + } + + updateState(type: TwoFactorAuthProviderType) { + switch (type) { + case TwoFactorAuthProviderType.TOTP: + this.state.set(ForceTwoFAState.AUTHENTICATOR_APP); + this.twoFaService.generateTwoFaAccountConfig(TwoFactorAuthProviderType.TOTP).subscribe(accountConfig => { + this.authAccountConfig = accountConfig as TotpTwoFactorAuthAccountConfig; + this.totpAuthURL = this.authAccountConfig.authUrl; + this.totpAuthURLSecret = new URL(this.totpAuthURL).searchParams.get('secret'); + this.authAccountConfig.useByDefault = this.useByDefault; + this.useByDefault = false; + this.updateQRCode(); + }); + break; + case TwoFactorAuthProviderType.SMS: + this.state.set(ForceTwoFAState.SMS); + break; + case TwoFactorAuthProviderType.EMAIL: + this.state.set(ForceTwoFAState.EMAIL); + break; + case TwoFactorAuthProviderType.BACKUP_CODE: + this.state.set(ForceTwoFAState.BACKUP_CODE); + this.twoFaService.generateTwoFaAccountConfig(TwoFactorAuthProviderType.BACKUP_CODE).pipe( + tap((data: BackupCodeTwoFactorAuthAccountConfig) => this.backupCode = data), + mergeMap(data => this.twoFaService.verifyAndSaveTwoFaAccountConfig(data, null, {ignoreLoading: true})) + ).subscribe((config) => { + this.config = config; + }); + break; + } + } + + sendSmsCode() { + if (this.smsConfigForm.valid) { + this.authAccountConfig = { + providerType: TwoFactorAuthProviderType.SMS, + useByDefault: this.useByDefault, + phoneNumber: this.smsConfigForm.get('phone').value as string + }; + this.useByDefault = false; + this.twoFaService.submitTwoFaAccountConfig(this.authAccountConfig).subscribe(() => this.smsState.set(ProvidersState.ENTER_CODE)); + } + } + + sendEmailCode() { + if (this.emailConfigForm.valid) { + this.authAccountConfig = { + providerType: TwoFactorAuthProviderType.EMAIL, + useByDefault: this.useByDefault, + email: this.emailConfigForm.get('email').value as string + }; + this.useByDefault = false; + this.twoFaService.submitTwoFaAccountConfig(this.authAccountConfig).subscribe(() => this.emailState.set(ProvidersState.ENTER_CODE)); + } + } + + tryAnotherWay(type: TwoFactorAuthProviderType) { + this.state.set(ForceTwoFAState.SETUP); + this.configForm.reset(); + switch (type) { + case TwoFactorAuthProviderType.TOTP: + this.appState.set(ProvidersState.INPUT); + break; + case TwoFactorAuthProviderType.SMS: + this.smsState.set(ProvidersState.INPUT); + this.smsConfigForm.reset(); + break; + case TwoFactorAuthProviderType.EMAIL: + this.emailState.set(ProvidersState.INPUT) + this.emailConfigForm.get('email').reset(getCurrentAuthUser(this.store).sub); + break; + } + } + + saveConfig(type: TwoFactorAuthProviderType) { + if (this.configForm.valid) { + this.twoFaService.verifyAndSaveTwoFaAccountConfig(this.authAccountConfig, + this.configForm.get('verificationCode').value).subscribe((config) => { + switch (type) { + case TwoFactorAuthProviderType.TOTP: + this.appState.set(ProvidersState.SUCCESS); + break; + case TwoFactorAuthProviderType.SMS: + this.smsState.set(ProvidersState.SUCCESS); + break; + case TwoFactorAuthProviderType.EMAIL: + this.emailState.set(ProvidersState.SUCCESS); + break; + } + this.config = config; + this.authAccountConfig = null; + this.allowedProviders(); + }); + } + } + + private updateQRCode() { + import('qrcode').then((QRCode) => { + unwrapModule(QRCode).toCanvas(this.canvasRef.nativeElement, this.totpAuthURL); + this.canvasRef.nativeElement.style.width = 'auto'; + this.canvasRef.nativeElement.style.height = 'auto'; + }); + } + + ngOnDestroy() { + super.ngOnDestroy(); + } + + cancelLogin() { + this.authService.logout(); + } + + downloadFile() { + this.importExportService.exportText(this.backupCode.codes, 'backup-codes'); + } + + printCode() { + const codeTemplate = deepClone(this.backupCode.codes) + .map(code => `
${code}
`).join(''); + const printPage = printTemplate.replace('${codesBlock}', codeTemplate); + const newWindow = window.open('', 'Print backup code'); + + newWindow.document.open(); + newWindow.document.write(printPage); + + setTimeout(() => { + newWindow.print(); + + newWindow.document.close(); + + setTimeout(() => { + newWindow.close(); + }, 10); + }, 0); + } +} diff --git a/ui-ngx/src/app/shared/models/authority.enum.ts b/ui-ngx/src/app/shared/models/authority.enum.ts index 8b18beb887..d91ecf777a 100644 --- a/ui-ngx/src/app/shared/models/authority.enum.ts +++ b/ui-ngx/src/app/shared/models/authority.enum.ts @@ -20,5 +20,6 @@ export enum Authority { CUSTOMER_USER = 'CUSTOMER_USER', REFRESH_TOKEN = 'REFRESH_TOKEN', ANONYMOUS = 'ANONYMOUS', - PRE_VERIFICATION_TOKEN = 'PRE_VERIFICATION_TOKEN' + PRE_VERIFICATION_TOKEN = 'PRE_VERIFICATION_TOKEN', + MFA_CONFIGURATION_TOKEN = 'MFA_CONFIGURATION_TOKEN' } diff --git a/ui-ngx/src/app/shared/models/two-factor-auth.models.ts b/ui-ngx/src/app/shared/models/two-factor-auth.models.ts index f2f392bd04..96a8d8186e 100644 --- a/ui-ngx/src/app/shared/models/two-factor-auth.models.ts +++ b/ui-ngx/src/app/shared/models/two-factor-auth.models.ts @@ -14,7 +14,11 @@ /// limitations under the License. /// +import { UsersFilter } from '@shared/models/notification.models'; + export interface TwoFactorAuthSettings { + enforceTwoFa: boolean; + enforcedUsersFilter: UsersFilter; maxVerificationFailuresBeforeUserLockout: number; providers: Array; totalAllowedTimeForVerification: number; @@ -24,12 +28,18 @@ export interface TwoFactorAuthSettings { } export interface TwoFactorAuthSettingsForm extends TwoFactorAuthSettings{ + enforceTwoFa: boolean; + enforcedUsersFilter: UsersFilterWithFilterByTenant; providers: Array; verificationCodeCheckRateLimitEnable: boolean; verificationCodeCheckRateLimitNumber: number; verificationCodeCheckRateLimitTime: number; } +export interface UsersFilterWithFilterByTenant extends UsersFilter{ + filterByTenants?: boolean; +} + export type TwoFactorAuthProviderConfig = Partial; @@ -183,3 +193,61 @@ export const twoFactorAuthProvidersLoginData = new Map>( + [ + [ + TwoFactorAuthProviderType.TOTP, { + name: 'login.enable-authenticator-app', + description: 'login.enable-authenticator-app-description' + } + ], + [ + TwoFactorAuthProviderType.SMS, { + name: 'login.enable-authenticator-sms', + description: 'login.enable-authenticator-sms-description' + } + ], + [ + TwoFactorAuthProviderType.EMAIL, { + name: 'login.enable-authenticator-email', + description: 'login.enable-authenticator-email-description' + } + ], + [ + TwoFactorAuthProviderType.BACKUP_CODE, { + name: 'security.2fa.provider.backup_code', + description: 'login.backup-code-auth-description' + } + ] + ] +); + +export const twoFactorAuthProvidersSuccessCardTranslate = new Map>( + [ + [ + TwoFactorAuthProviderType.TOTP, { + name: 'login.authenticator-app-success', + description: 'login.authenticator-app-success-description' + } + ], + [ + TwoFactorAuthProviderType.SMS, { + name: 'login.authenticator-sms-success', + description: 'login.authenticator-sms-success-description' + } + ], + [ + TwoFactorAuthProviderType.EMAIL, { + name: 'login.authenticator-email-success', + description: 'login.authenticator-email-success-description' + } + ], + [ + TwoFactorAuthProviderType.BACKUP_CODE, { + name: 'login.authenticator-backup-code-success', + description: 'login.authenticator-backup-code-success-description' + } + ] + ] +); 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 dbacae6ad3..9ff922db75 100644 --- a/ui-ngx/src/assets/locale/locale.constant-en_US.json +++ b/ui-ngx/src/assets/locale/locale.constant-en_US.json @@ -496,15 +496,15 @@ "number-of-codes-pattern": "Number of codes must be a positive integer.", "number-of-codes-required": "Number of codes is required.", "provider": "Provider", - "retry-verification-code-period": "Retry verification code period (sec)", + "retry-verification-code-period": "Retry verification code period", "retry-verification-code-period-pattern": "Minimal period time is 5 sec", "retry-verification-code-period-required": "Retry verification code period is required.", - "total-allowed-time-for-verification": "Total allowed time for verification (sec)", + "total-allowed-time-for-verification": "Total allowed time for verification", "total-allowed-time-for-verification-pattern": "Minimal total allowed time is 60 sec", "total-allowed-time-for-verification-required": "Total allowed time is required.", "use-system-two-factor-auth-settings": "Use system two factor auth settings", "verification-code-check-rate-limit": "Verification code check rate limit", - "verification-code-lifetime": "Verification code lifetime (sec)", + "verification-code-lifetime": "Verification code lifetime", "verification-code-lifetime-pattern": "Verification code lifetime must be a positive integer.", "verification-code-lifetime-required": "Verification code lifetime is required.", "verification-message-template": "Verification message template", @@ -513,7 +513,9 @@ "verification-message-template-required": "Verification message template is required.", "within-time": "Within time (sec)", "within-time-pattern": "Time must be a positive integer.", - "within-time-required": "Time is required." + "within-time-required": "Time is required.", + "force-2fa": "Force two-factor authentication", + "enforce-for": "Enforce for" }, "jwt": { "security-settings": "JWT security settings", @@ -3884,7 +3886,30 @@ "activation-link-expired": "Activation link has expired", "activation-link-expired-message": "The link to activate your profile has expired. You can return to the login page to receive a new email.", "reset-password-link-expired": "Password reset link has expired", - "reset-password-link-expired-message": "The link to reset your password has expired. You can return to the login page to receive a new email." + "reset-password-link-expired-message": "The link to reset your password has expired. You can return to the login page to receive a new email.", + "two-fa": "Two-factor authentication", + "two-fa-required": "Two-factor authentication is required", + "set-up-verification-method": "Set up a verification method to continue", + "set-up-verification-method-login": "Set up a verification method or login", + "enable-authenticator-app": "Enable authenticator app", + "enable-authenticator-app-description": "Please enter the security code from your authenticator app", + "enable-authenticator-sms": "Enable SMS authenticator", + "enable-authenticator-sms-description": "Enter a 6-digit code we just sent to ", + "enable-authenticator-email": "Enable email authenticator", + "enable-authenticator-email-description": "A security code has been sent to your email address at ", + "enter-key-manually": "or enter this 32-digits key manually:", + "continue": "Continue", + "confirm": "Confirm", + "authenticator-app-success": "Authenticator app successfully enabled", + "authenticator-app-success-description": "The next time you log in, you will need to provide a two-factor authentication code", + "authenticator-sms-success": "SMS authenticator successfully enabled", + "authenticator-sms-success-description": "The next time you log in, you will be prompted to enter the security code that will be sent to the phone number", + "authenticator-email-success": "Email authenticator successfully enabled", + "authenticator-email-success-description": "The next time you log in, you will be prompted to enter the security code that will be sent to your email address", + "authenticator-backup-code-success": "Backup code successfully enabled", + "authenticator-backup-code-success-description": "The next time you log in, you will be prompted to enter the security code or use one of backup code.", + "add-verification-method": "Add verification method", + "get-backup-code": "Get backup code" }, "markdown": { "edit": "Edit", From 9ca4ff92f6b54bc8d48ceae2195cfb6e72ba9eb7 Mon Sep 17 00:00:00 2001 From: VIacheslavKlimov Date: Wed, 24 Sep 2025 12:25:02 +0300 Subject: [PATCH 018/102] 2FA: allow MFA_CONFIGURATION_TOKEN for getting and submitting config --- .../server/controller/TwoFactorAuthConfigController.java | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/application/src/main/java/org/thingsboard/server/controller/TwoFactorAuthConfigController.java b/application/src/main/java/org/thingsboard/server/controller/TwoFactorAuthConfigController.java index 00829df047..eef086452f 100644 --- a/application/src/main/java/org/thingsboard/server/controller/TwoFactorAuthConfigController.java +++ b/application/src/main/java/org/thingsboard/server/controller/TwoFactorAuthConfigController.java @@ -66,7 +66,7 @@ public class TwoFactorAuthConfigController extends BaseController { " }\n}\n```" + ControllerConstants.AVAILABLE_FOR_ANY_AUTHORIZED_USER) @GetMapping("/account/settings") - @PreAuthorize("hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN', 'CUSTOMER_USER')") + @PreAuthorize("hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN', 'CUSTOMER_USER', 'MFA_CONFIGURATION_TOKEN')") public AccountTwoFaSettings getAccountTwoFaSettings() throws ThingsboardException { SecurityUser user = getCurrentUser(); return twoFaConfigManager.getAccountTwoFaSettings(user.getTenantId(), user).orElse(null); @@ -125,7 +125,7 @@ public class TwoFactorAuthConfigController extends BaseController { "or if the provider is not configured for usage. " + ControllerConstants.AVAILABLE_FOR_ANY_AUTHORIZED_USER) @PostMapping("/account/config/submit") - @PreAuthorize("hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN', 'CUSTOMER_USER')") + @PreAuthorize("hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN', 'CUSTOMER_USER', 'MFA_CONFIGURATION_TOKEN')") public void submitTwoFaAccountConfig(@Valid @RequestBody TwoFaAccountConfig accountConfig) throws Exception { SecurityUser user = getCurrentUser(); twoFactorAuthService.prepareVerificationCode(user, accountConfig, false); From 4ddc8030ccedbe92a420324b8b46dd9cefa086aa Mon Sep 17 00:00:00 2001 From: ArtemDzhereleiko Date: Wed, 24 Sep 2025 16:35:23 +0300 Subject: [PATCH 019/102] UI: Add secret for totp auth dialog --- .../two-factor-auth-settings.component.ts | 19 +++++++--------- .../totp-auth-dialog.component.html | 13 +++++++++++ .../totp-auth-dialog.component.ts | 2 ++ ...force-two-factor-auth-login.component.html | 22 +++++++++++-------- .../assets/locale/locale.constant-en_US.json | 2 +- 5 files changed, 37 insertions(+), 21 deletions(-) diff --git a/ui-ngx/src/app/modules/home/pages/admin/two-factor-auth-settings.component.ts b/ui-ngx/src/app/modules/home/pages/admin/two-factor-auth-settings.component.ts index bea85a4639..38a6230b6f 100644 --- a/ui-ngx/src/app/modules/home/pages/admin/two-factor-auth-settings.component.ts +++ b/ui-ngx/src/app/modules/home/pages/admin/two-factor-auth-settings.component.ts @@ -14,7 +14,7 @@ /// limitations under the License. /// -import { Component, OnDestroy, OnInit, QueryList, ViewChildren } from '@angular/core'; +import { Component, DestroyRef, OnDestroy, OnInit, QueryList, ViewChildren } from '@angular/core'; import { PageComponent } from '@shared/components/page.component'; import { HasConfirmForm } from '@core/guards/confirm-on-exit.guard'; import { Store } from '@ngrx/store'; @@ -29,11 +29,10 @@ import { TwoFactorAuthSettingsForm } from '@shared/models/two-factor-auth.models'; import { isDefined, isNotEmptyStr } from '@core/utils'; -import { Subject } from 'rxjs'; -import { takeUntil } from 'rxjs/operators'; import { MatExpansionPanel } from '@angular/material/expansion'; import { NotificationTargetConfigType, NotificationTargetConfigTypeInfoMap } from '@shared/models/notification.models'; import { EntityType } from '@shared/models/entity-type.models'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; @Component({ selector: 'tb-2fa-settings', @@ -42,7 +41,6 @@ import { EntityType } from '@shared/models/entity-type.models'; }) export class TwoFactorAuthSettingsComponent extends PageComponent implements OnInit, HasConfirmForm, OnDestroy { - private readonly destroy$ = new Subject(); private readonly posIntValidation = [Validators.required, Validators.min(1), Validators.pattern(/^\d*$/)]; twoFaFormGroup: UntypedFormGroup; @@ -62,7 +60,8 @@ export class TwoFactorAuthSettingsComponent extends PageComponent implements OnI constructor(protected store: Store, private twoFaService: TwoFactorAuthenticationService, - private fb: UntypedFormBuilder) { + private fb: UntypedFormBuilder, + private destroyRef: DestroyRef) { super(store); } @@ -75,8 +74,6 @@ export class TwoFactorAuthSettingsComponent extends PageComponent implements OnI ngOnDestroy() { super.ngOnDestroy(); - this.destroy$.next(); - this.destroy$.complete(); } confirmForm(): UntypedFormGroup { @@ -156,7 +153,7 @@ export class TwoFactorAuthSettingsComponent extends PageComponent implements OnI this.buildProvidersSettingsForm(provider); }); this.twoFaFormGroup.get('verificationCodeCheckRateLimitEnable').valueChanges.pipe( - takeUntil(this.destroy$) + takeUntilDestroyed(this.destroyRef) ).subscribe(value => { if (value) { this.twoFaFormGroup.get('verificationCodeCheckRateLimitNumber').enable({emitEvent: false}); @@ -167,7 +164,7 @@ export class TwoFactorAuthSettingsComponent extends PageComponent implements OnI } }); this.providersForm.valueChanges.pipe( - takeUntil(this.destroy$) + takeUntilDestroyed(this.destroyRef) ).subscribe((value: TwoFactorAuthProviderConfigForm[]) => { const activeProvider = value.filter(provider => provider.enable); const indexBackupCode = Object.values(TwoFactorAuthProviderType).indexOf(TwoFactorAuthProviderType.BACKUP_CODE); @@ -181,7 +178,7 @@ export class TwoFactorAuthSettingsComponent extends PageComponent implements OnI } }); this.twoFaFormGroup.get('enforceTwoFa').valueChanges.pipe( - takeUntil(this.destroy$) + takeUntilDestroyed(this.destroyRef) ).subscribe(value => { if (value) { this.twoFaFormGroup.get('enforcedUsersFilter').enable({emitEvent: false}); @@ -245,7 +242,7 @@ export class TwoFactorAuthSettingsComponent extends PageComponent implements OnI } const newProviders = this.fb.group(formControlConfig); newProviders.get('enable').valueChanges.pipe( - takeUntil(this.destroy$) + takeUntilDestroyed(this.destroyRef) ).subscribe(value => { if (value) { newProviders.enable({emitEvent: false}); diff --git a/ui-ngx/src/app/modules/home/pages/security/authentication-dialog/totp-auth-dialog.component.html b/ui-ngx/src/app/modules/home/pages/security/authentication-dialog/totp-auth-dialog.component.html index 1b33d4df1e..6bf4174268 100644 --- a/ui-ngx/src/app/modules/home/pages/security/authentication-dialog/totp-auth-dialog.component.html +++ b/ui-ngx/src/app/modules/home/pages/security/authentication-dialog/totp-auth-dialog.component.html @@ -52,6 +52,19 @@

security.2fa.dialog.scan-qr-code

+

login.enter-key-manually

+
+ {{ totpAuthURLSecret }} + + +

security.2fa.dialog.enter-verification-code

; @@ -55,6 +56,7 @@ export class TotpAuthDialogComponent extends DialogComponent { this.authAccountConfig = accountConfig as TotpTwoFactorAuthAccountConfig; this.totpAuthURL = this.authAccountConfig.authUrl; + this.totpAuthURLSecret = new URL(this.totpAuthURL).searchParams.get('secret'); this.authAccountConfig.useByDefault = true; import('qrcode').then((QRCode) => { unwrapModule(QRCode).toCanvas(this.canvasRef.nativeElement, this.totpAuthURL); diff --git a/ui-ngx/src/app/modules/login/pages/login/force-two-factor-auth-login.component.html b/ui-ngx/src/app/modules/login/pages/login/force-two-factor-auth-login.component.html index 87c5179a03..e71c04950a 100644 --- a/ui-ngx/src/app/modules/login/pages/login/force-two-factor-auth-login.component.html +++ b/ui-ngx/src/app/modules/login/pages/login/force-two-factor-auth-login.component.html @@ -31,12 +31,12 @@

{{ (config ? 'login.set-up-verification-method-login' :'login.set-up-verification-method') | translate }}

- + @for (provider of allowProviders; track provider) { - + } @if (config) { - {{ 'login.enable-authenticator-sms' | translate }} + {{ 'login.enable-authenticator-email' | translate }}
-

security.2fa.dialog.sms-step-description

+

login.email-description

+ placeholder="{{ 'login.email-label' | translate }}" /> - {{ 'user.email-required' | translate }} + {{ 'login.email-required' | translate }} - {{ 'user.invalid-email-format' | translate }} + {{ 'login.invalid-email-format' | translate }}
-

security.2fa.dialog.backup-code-warn

+

login.backup-code-warn

@@ -257,9 +261,9 @@ maxlength="6" type="text" required inputmode="numeric" pattern="[0-9]*" autocomplete="off" - placeholder="{{ 'security.2fa.dialog.verification-code' | translate }}"> + placeholder="{{ 'login.verification-code' | translate }}"> - {{ 'security.2fa.dialog.verification-code-invalid' | translate }} + {{ 'login.verification-code-invalid' | translate }}
diff --git a/ui-ngx/src/app/modules/login/pages/login/two-factor-auth-login.component.scss b/ui-ngx/src/app/modules/login/pages/login/two-factor-auth-login.component.scss index 68a4dcb4a4..09e3c29c43 100644 --- a/ui-ngx/src/app/modules/login/pages/login/two-factor-auth-login.component.scss +++ b/ui-ngx/src/app/modules/login/pages/login/two-factor-auth-login.component.scss @@ -72,9 +72,19 @@ } ::ng-deep { + .tb-two-factor-auth-login-content { + .tb-two-factor-auth-login-card { + button.mat-mdc-icon-button { + .mat-icon { + color: rgba(255, 255, 255, 0.8); + } + } + } + } button.provider { text-align: start; font-weight: 400; + color: rgba(255, 255, 255, 0.8); &:not([disabled][disabled]) { border-color: rgba(255, 255, 255, .8); } diff --git a/ui-ngx/src/app/shared/components/phone-input.component.html b/ui-ngx/src/app/shared/components/phone-input.component.html index 6bf61ae4b8..1b025e8113 100644 --- a/ui-ngx/src/app/shared/components/phone-input.component.html +++ b/ui-ngx/src/app/shared/components/phone-input.component.html @@ -37,12 +37,12 @@ (focus)="focus()" autocomplete="off" [required]="required"> - + - {{ 'phone-input.phone-input-required' | translate }} + {{ requiredErrorText }} - {{ 'phone-input.phone-input-validation' | translate }} + {{ validationErrorText }}
diff --git a/ui-ngx/src/app/shared/components/phone-input.component.ts b/ui-ngx/src/app/shared/components/phone-input.component.ts index bd1124f97a..5d6d0b21a7 100644 --- a/ui-ngx/src/app/shared/components/phone-input.component.ts +++ b/ui-ngx/src/app/shared/components/phone-input.component.ts @@ -77,6 +77,15 @@ export class PhoneInputComponent implements OnInit, ControlValueAccessor, Valida @Input() label = this.translate.instant('phone-input.phone-input-label'); + @Input() + hint = 'phone-input.phone-input-hint'; + + @Input() + requiredErrorText = this.translate.instant('phone-input.phone-input-required'); + + @Input() + validationErrorText = this.translate.instant('phone-input.phone-input-validation'); + get showFlagSelect(): boolean { return this.enableFlagsSelect && !this.isLegacy; } 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 7c8989fe98..7f14588403 100644 --- a/ui-ngx/src/assets/locale/locale.constant-en_US.json +++ b/ui-ngx/src/assets/locale/locale.constant-en_US.json @@ -3909,7 +3909,25 @@ "authenticator-backup-code-success": "Backup code successfully enabled", "authenticator-backup-code-success-description": "The next time you log in, you will be prompted to enter the security code or use one of backup code.", "add-verification-method": "Add verification method", - "get-backup-code": "Get backup code" + "get-backup-code": "Get backup code", + "copy-key": "Copy key", + "send-code": "Send code", + "email-label": "Email", + "sms-description": "Enter a phone number to use as your authenticator.", + "backup-code-description": "Print out the codes so you have them handy when you need to use them to log in to your account. You can use each backup code once.", + "backup-code-warn": "Once you leave this page, these codes cannot be shown again. Store them safely using the options below.", + "download-txt": "Download (txt)", + "print": "Print", + "verification-code": "6-digit code", + "verification-code-invalid": "Invalid verification code format", + "scan-qr-code": "Scan this QR code with your verification app", + "phone-input": { + "phone-input-label": "Phone number", + "phone-input-required": "Phone number is required", + "phone-input-validation": "Phone number is invalid or not possible", + "phone-input-pattern": "Invalid phone number. Should be in E.164 format, ex. {{phoneNumber}}", + "phone-input-hint": "Phone Number in E.164 format, ex. {{phoneNumber}}" + } }, "markdown": { "edit": "Edit", From fc9c692e0b3ca39fc439513c1a5d9232ab1b867c Mon Sep 17 00:00:00 2001 From: Vladyslav_Prykhodko Date: Fri, 26 Sep 2025 11:37:15 +0300 Subject: [PATCH 023/102] UI: Add support UTF-8 symbols in export file name --- .../import-export/import-export.service.ts | 95 +++++++------------ 1 file changed, 36 insertions(+), 59 deletions(-) diff --git a/ui-ngx/src/app/shared/import-export/import-export.service.ts b/ui-ngx/src/app/shared/import-export/import-export.service.ts index 94960cb838..d7c6d5880a 100644 --- a/ui-ngx/src/app/shared/import-export/import-export.service.ts +++ b/ui-ngx/src/app/shared/import-export/import-export.service.ts @@ -22,7 +22,6 @@ import { AppState } from '@core/core.state'; import { ActionNotificationShow } from '@core/notification/notification.actions'; import { BreakpointId, Dashboard, DashboardLayoutId } from '@shared/models/dashboard.models'; import { deepClone, guid, isDefined, isNotEmptyStr, isObject, isString, isUndefined } from '@core/utils'; -import { WINDOW } from '@core/services/window.service'; import { DOCUMENT } from '@angular/common'; import { AliasesInfo, @@ -100,8 +99,7 @@ type SupportEntityResources = 'includeResourcesInExportWidgetTypes' | 'includeRe @Injectable() export class ImportExportService { - constructor(@Inject(WINDOW) private window: Window, - @Inject(DOCUMENT) private document: Document, + constructor(@Inject(DOCUMENT) private document: Document, private store: Store, private translate: TranslateService, private dashboardService: DashboardService, @@ -177,9 +175,7 @@ export class ImportExportService { public exportCalculatedField(calculatedFieldId: string): void { this.calculatedFieldsService.getCalculatedFieldById(calculatedFieldId).subscribe({ next: (calculatedField) => { - let name = calculatedField.name; - name = name.toLowerCase().replace(/\W/g, '_'); - this.exportToPc(this.prepareCalculatedFieldExport(calculatedField), name); + this.exportToPc(this.prepareCalculatedFieldExport(calculatedField), calculatedField.name, true); }, error: (e) => { this.handleExportError(e, 'calculated-fields.export-failed-error'); @@ -200,9 +196,7 @@ export class ImportExportService { this.updateUserSettingsIncludeResourcesIfNeeded(includeResources, result.include, 'includeResourcesInExportDashboard'); this.dashboardService.exportDashboard(dashboardId, result.include).subscribe({ next: (dashboard) => { - let name = dashboard.title; - name = name.toLowerCase().replace(/\W/g, '_'); - this.exportToPc(this.prepareDashboardExport(dashboard), name); + this.exportToPc(this.prepareDashboardExport(dashboard), dashboard.title, true); }, error: (e) => { this.handleExportError(e, 'dashboard.export-failed-error'); @@ -261,9 +255,8 @@ export class ImportExportService { widgetTitle: string, breakpoint: BreakpointId) { const widgetItem = this.itembuffer.prepareWidgetItem(dashboard, sourceState, sourceLayout, widget, breakpoint); const widgetDefaultName = this.widgetService.getWidgetInfoFromCache(widget.typeFullFqn).widgetName; - let fileName = widgetDefaultName + (isNotEmptyStr(widgetTitle) ? `_${widgetTitle}` : ''); - fileName = fileName.toLowerCase().replace(/\W/g, '_'); - this.exportToPc(this.prepareExport(widgetItem), fileName); + const fileName = widgetDefaultName + (isNotEmptyStr(widgetTitle) ? `_${widgetTitle}` : ''); + this.exportToPc(this.prepareExport(widgetItem), fileName, true); } public importWidget(dashboard: Dashboard, targetState: string, @@ -360,9 +353,7 @@ export class ImportExportService { this.updateUserSettingsIncludeResourcesIfNeeded(includeResources, result.include, 'includeResourcesInExportWidgetTypes'); this.widgetService.exportWidgetType(widgetTypeId, result.include).subscribe({ next: (widgetTypeDetails) => { - let name = widgetTypeDetails.name; - name = name.toLowerCase().replace(/\W/g, '_'); - this.exportToPc(this.prepareExport(widgetTypeDetails), name); + this.exportToPc(this.prepareExport(widgetTypeDetails), widgetTypeDetails.name, true); }, error: (e) => { this.handleExportError(e, 'widget-type.export-failed-error'); @@ -440,7 +431,7 @@ export class ImportExportService { public exportEntity(entityData: VersionedEntity): void { const id = (entityData as EntityInfoData).id ?? (entityData as RuleChainMetaData).ruleChainId; let fileName = (entityData as EntityInfoData).name; - let preparedData; + let preparedData: any; switch (id.entityType) { case EntityType.DEVICE_PROFILE: case EntityType.ASSET_PROFILE: @@ -511,9 +502,7 @@ export class ImportExportService { for (const widgetTypeDetails of widgetTypesDetails) { widgetsBundleItem.widgetTypes.push(this.prepareExport(widgetTypeDetails)); } - let name = widgetsBundle.title; - name = name.toLowerCase().replace(/\W/g, '_'); - this.exportToPc(widgetsBundleItem, name); + this.exportToPc(widgetsBundleItem, widgetsBundle.title, true); }, error: (e) => { this.handleExportError(e, 'widgets-bundle.export-failed-error'); @@ -528,9 +517,7 @@ export class ImportExportService { widgetsBundle: this.prepareExport(widgetsBundle), widgetTypeFqns }; - let name = widgetsBundle.title; - name = name.toLowerCase().replace(/\W/g, '_'); - this.exportToPc(widgetsBundleItem, name); + this.exportToPc(widgetsBundleItem, widgetsBundle.title, true); }, error: (e) => { this.handleExportError(e, 'widgets-bundle.export-failed-error'); @@ -662,11 +649,9 @@ export class ImportExportService { private onRuleChainExported() { return { next: (ruleChainExport: RuleChainImport) => { - let name = ruleChainExport.ruleChain.name; - name = name.toLowerCase().replace(/\W/g, '_'); - this.exportToPc(ruleChainExport, name); + this.exportToPc(ruleChainExport, ruleChainExport.ruleChain.name, true); }, - error: (e) => { + error: (e: any) => { this.handleExportError(e, 'rulechain.export-failed-error'); } }; @@ -747,9 +732,7 @@ export class ImportExportService { public exportDeviceProfile(deviceProfileId: string) { this.deviceProfileService.exportDeviceProfile(deviceProfileId).subscribe({ next: (deviceProfile) => { - let name = deviceProfile.name; - name = name.toLowerCase().replace(/\W/g, '_'); - this.exportToPc(this.prepareProfileExport(deviceProfile), name); + this.exportToPc(this.prepareProfileExport(deviceProfile), deviceProfile.name, true); }, error: (e) => { this.handleExportError(e, 'device-profile.export-failed-error'); @@ -776,9 +759,7 @@ export class ImportExportService { public exportAssetProfile(assetProfileId: string) { this.assetProfileService.exportAssetProfile(assetProfileId).subscribe({ next: (assetProfile) => { - let name = assetProfile.name; - name = name.toLowerCase().replace(/\W/g, '_'); - this.exportToPc(this.prepareProfileExport(assetProfile), name); + this.exportToPc(this.prepareProfileExport(assetProfile), assetProfile.name, true); }, error: (e) => { this.handleExportError(e, 'asset-profile.export-failed-error'); @@ -805,9 +786,7 @@ export class ImportExportService { public exportTenantProfile(tenantProfileId: string) { this.tenantProfileService.getTenantProfile(tenantProfileId).subscribe({ next: (tenantProfile) => { - let name = tenantProfile.name; - name = name.toLowerCase().replace(/\W/g, '_'); - this.exportToPc(this.prepareProfileExport(tenantProfile), name); + this.exportToPc(this.prepareProfileExport(tenantProfile), tenantProfile.name, true); }, error: (e) => { this.handleExportError(e, 'tenant-profile.export-failed-error'); @@ -882,7 +861,7 @@ export class ImportExportService { jsZip.generateAsync({type: 'blob'}).then(content => { this.downloadFile(content, filename, ZIP_TYPE); exportJsSubjectSubject.next(null); - }).catch(e => { + }).catch((e: any) => { exportJsSubjectSubject.error(e); }); } catch (e) { @@ -1180,42 +1159,40 @@ export class ImportExportService { )); } - private exportToPc(data: any, filename: string) { + private exportToPc(data: any, filename: string, normalizeFileName = false) { if (!data) { console.error('No data'); return; } - this.exportJson(data, filename); + this.exportJson(data, filename, normalizeFileName); } - public exportJson(data: any, filename: string) { + public exportJson(data: any, filename: string, normalizeFileName = false) { if (isObject(data)) { data = JSON.stringify(data, null, 2); } - this.downloadFile(data, filename, JSON_TYPE); + this.downloadFile(data, filename, JSON_TYPE, normalizeFileName); } - private downloadFile(data: any, filename: string, fileType: FileType) { - if (!filename) { - filename = 'download'; + private prepareFilename(filename: string, extension: string, normalizeFileName: boolean): string { + if (normalizeFileName) { + filename = filename.toLowerCase().replace(/\s/g, '_'); } - filename += '.' + fileType.extension; + filename = filename.replace(/[\\/<>:"|?*\s]/g, '_'); + return `${filename}.${extension}`; + } + + private downloadFile(data: any, filename = 'download', fileType: FileType, normalizeFileName = false) { + filename = this.prepareFilename(filename, fileType.extension, normalizeFileName); const blob = new Blob([data], {type: fileType.mimeType}); - // @ts-ignore - if (this.window.navigator && this.window.navigator.msSaveOrOpenBlob) { - // @ts-ignore - this.window.navigator.msSaveOrOpenBlob(blob, filename); - } else { - const e = this.document.createEvent('MouseEvents'); - const a = this.document.createElement('a'); - a.download = filename; - a.href = URL.createObjectURL(blob); - a.dataset.downloadurl = [fileType.mimeType, a.download, a.href].join(':'); - // @ts-ignore - e.initEvent('click', true, false, this.window, - 0, 0, 0, 0, 0, false, false, false, false, 0, null); - a.dispatchEvent(e); - } + const url = URL.createObjectURL(blob); + + const a = this.document.createElement('a'); + a.href = url; + a.download = filename; + a.dataset.downloadurl = [fileType.mimeType, a.download, a.href].join(':'); + a.click(); + setTimeout(() => URL.revokeObjectURL(url), 0); } private prepareDashboardExport(dashboard: Dashboard): Dashboard { From cdcb2c4fd68b395ea899277c73e70afa347a9e09 Mon Sep 17 00:00:00 2001 From: Vladyslav_Prykhodko Date: Fri, 26 Sep 2025 11:59:10 +0300 Subject: [PATCH 024/102] UI: updated public api in import-export service --- .../import-export/import-export.service.ts | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/ui-ngx/src/app/shared/import-export/import-export.service.ts b/ui-ngx/src/app/shared/import-export/import-export.service.ts index d7c6d5880a..34e584a527 100644 --- a/ui-ngx/src/app/shared/import-export/import-export.service.ts +++ b/ui-ngx/src/app/shared/import-export/import-export.service.ts @@ -821,7 +821,7 @@ export class ImportExportService { return cellData; } - public exportCsv(data: {[key: string]: any}[], filename: string) { + public exportCsv(data: {[key: string]: any}[], filename: string, normalizeFileName = false) { let colsHead: string; let colsData: string; if (data && data.length) { @@ -836,18 +836,18 @@ export class ImportExportService { colsData = ''; } const csvData = `${colsHead}\n${colsData}`; - this.downloadFile(csvData, filename, CSV_TYPE); + this.downloadFile(csvData, filename, CSV_TYPE, normalizeFileName); } - public exportText(data: string | Array, filename: string) { + public exportText(data: string | Array, filename: string, normalizeFileName = false) { let content = data; if (Array.isArray(data)) { content = data.join('\n'); } - this.downloadFile(content, filename, TEXT_TYPE); + this.downloadFile(content, filename, TEXT_TYPE, normalizeFileName); } - public exportJSZip(data: object, filename: string): Observable { + public exportJSZip(data: object, filename: string, normalizeFileName = false): Observable { const exportJsSubjectSubject = new Subject(); import('jszip').then((JSZip) => { try { @@ -859,7 +859,7 @@ export class ImportExportService { } } jsZip.generateAsync({type: 'blob'}).then(content => { - this.downloadFile(content, filename, ZIP_TYPE); + this.downloadFile(content, filename, ZIP_TYPE, normalizeFileName); exportJsSubjectSubject.next(null); }).catch((e: any) => { exportJsSubjectSubject.error(e); @@ -1174,7 +1174,7 @@ export class ImportExportService { this.downloadFile(data, filename, JSON_TYPE, normalizeFileName); } - private prepareFilename(filename: string, extension: string, normalizeFileName: boolean): string { + private prepareFilename(filename: string, extension: string, normalizeFileName = false): string { if (normalizeFileName) { filename = filename.toLowerCase().replace(/\s/g, '_'); } @@ -1182,7 +1182,7 @@ export class ImportExportService { return `${filename}.${extension}`; } - private downloadFile(data: any, filename = 'download', fileType: FileType, normalizeFileName = false) { + private downloadFile(data: any, filename = 'download', fileType: FileType, normalizeFileName: boolean) { filename = this.prepareFilename(filename, fileType.extension, normalizeFileName); const blob = new Blob([data], {type: fileType.mimeType}); const url = URL.createObjectURL(blob); From ec30bb0578c8186b3d83a848d72add37a22b6845 Mon Sep 17 00:00:00 2001 From: VIacheslavKlimov Date: Fri, 26 Sep 2025 13:16:31 +0300 Subject: [PATCH 025/102] AI Request Node: added ability to attach files (#13910) --- .../server/actors/ActorSystemContext.java | 5 + .../actors/ruleChain/DefaultTbContext.java | 21 +- .../controller/TbResourceController.java | 17 ++ ...faultTbCalculatedFieldConsumerService.java | 4 +- .../queue/DefaultTbClusterService.java | 9 +- .../queue/DefaultTbCoreConsumerService.java | 4 +- .../queue/DefaultTbEdgeConsumerService.java | 2 +- .../DefaultTbRuleEngineConsumerService.java | 4 +- .../processing/AbstractConsumerService.java | 5 + ...AbstractPartitionBasedConsumerService.java | 4 +- .../resource/DefaultTbResourceService.java | 1 - .../src/main/resources/thingsboard.yml | 3 + .../controller/TbResourceControllerTest.java | 23 +- .../DefaultResourceDataCacheTest.java | 83 +++++++ .../sql/BaseTbResourceServiceTest.java | 121 +++++++++- .../server/dao/resource/ResourceService.java | 5 + .../dao/resource/TbResourceDataCache.java | 28 +++ .../common/data/GeneralFileDescriptor.java | 29 +++ .../server/common/data/ResourceType.java | 3 +- .../server/common/data/TbResource.java | 6 + .../common/data/TbResourceDataInfo.java | 31 +++ .../common/data/TbResourceDeleteResult.java | 3 +- .../thingsboard/common/util/DonAsynchron.java | 15 ++ .../server/dao/ResourceContainerDao.java | 5 +- .../server/dao/resource/BaseImageService.java | 5 +- .../dao/resource/BaseResourceService.java | 72 ++++-- .../resource/DefaultTbResourceDataCache.java | 72 ++++++ .../server/dao/resource/TbResourceDao.java | 2 + .../dao/resource/TbResourceInfoDao.java | 2 + .../server/dao/rule/RuleChainDao.java | 4 +- .../dashboard/DashboardInfoRepository.java | 16 +- .../sql/dashboard/JpaDashboardInfoDao.java | 10 +- .../dao/sql/resource/JpaTbResourceDao.java | 6 + .../sql/resource/JpaTbResourceInfoDao.java | 8 + .../resource/TbResourceInfoRepository.java | 6 + .../sql/resource/TbResourceRepository.java | 3 + .../server/dao/sql/rule/JpaRuleChainDao.java | 12 + .../dao/sql/rule/RuleChainRepository.java | 15 ++ .../dao/sql/widget/JpaWidgetTypeDao.java | 10 +- .../sql/widget/WidgetTypeInfoRepository.java | 15 +- .../dao/sql/rule/JpaRuleNodeDaoTest.java | 1 + .../rule/engine/api/TbContext.java | 7 + .../thingsboard/rule/engine/ai/TbAiNode.java | 121 +++++++++- .../rule/engine/ai/TbAiNodeConfiguration.java | 6 +- .../rule/engine/ai/TbAiNodeTest.java | 223 ++++++++++++++++++ 45 files changed, 965 insertions(+), 82 deletions(-) create mode 100644 application/src/test/java/org/thingsboard/server/service/resource/DefaultResourceDataCacheTest.java create mode 100644 common/dao-api/src/main/java/org/thingsboard/server/dao/resource/TbResourceDataCache.java create mode 100644 common/data/src/main/java/org/thingsboard/server/common/data/GeneralFileDescriptor.java create mode 100644 common/data/src/main/java/org/thingsboard/server/common/data/TbResourceDataInfo.java create mode 100644 dao/src/main/java/org/thingsboard/server/dao/resource/DefaultTbResourceDataCache.java 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 ea46ce86eb..b23015a1fe 100644 --- a/application/src/main/java/org/thingsboard/server/actors/ActorSystemContext.java +++ b/application/src/main/java/org/thingsboard/server/actors/ActorSystemContext.java @@ -97,6 +97,7 @@ import org.thingsboard.server.dao.ota.OtaPackageService; import org.thingsboard.server.dao.queue.QueueService; import org.thingsboard.server.dao.queue.QueueStatsService; import org.thingsboard.server.dao.relation.RelationService; +import org.thingsboard.server.dao.resource.TbResourceDataCache; import org.thingsboard.server.dao.resource.ResourceService; import org.thingsboard.server.dao.rule.RuleChainService; import org.thingsboard.server.dao.rule.RuleNodeStateService; @@ -511,6 +512,10 @@ public class ActorSystemContext { @Getter private ResourceService resourceService; + @Autowired + @Getter + private TbResourceDataCache resourceDataCache; + @Lazy @Autowired(required = false) @Getter diff --git a/application/src/main/java/org/thingsboard/server/actors/ruleChain/DefaultTbContext.java b/application/src/main/java/org/thingsboard/server/actors/ruleChain/DefaultTbContext.java index 6374e4016d..88b04c7613 100644 --- a/application/src/main/java/org/thingsboard/server/actors/ruleChain/DefaultTbContext.java +++ b/application/src/main/java/org/thingsboard/server/actors/ruleChain/DefaultTbContext.java @@ -51,6 +51,7 @@ import org.thingsboard.server.common.data.Device; import org.thingsboard.server.common.data.DeviceProfile; import org.thingsboard.server.common.data.EntityType; import org.thingsboard.server.common.data.HasRuleEngineProfile; +import org.thingsboard.server.common.data.HasTenantId; import org.thingsboard.server.common.data.StringUtils; import org.thingsboard.server.common.data.TenantProfile; import org.thingsboard.server.common.data.alarm.Alarm; @@ -60,6 +61,7 @@ import org.thingsboard.server.common.data.id.AssetId; import org.thingsboard.server.common.data.id.CustomerId; import org.thingsboard.server.common.data.id.DeviceId; import org.thingsboard.server.common.data.id.EntityId; +import org.thingsboard.server.common.data.id.HasId; import org.thingsboard.server.common.data.id.RuleChainId; import org.thingsboard.server.common.data.id.RuleNodeId; import org.thingsboard.server.common.data.id.TenantId; @@ -110,6 +112,7 @@ import org.thingsboard.server.dao.ota.OtaPackageService; import org.thingsboard.server.dao.queue.QueueService; import org.thingsboard.server.dao.queue.QueueStatsService; import org.thingsboard.server.dao.relation.RelationService; +import org.thingsboard.server.dao.resource.TbResourceDataCache; import org.thingsboard.server.dao.resource.ResourceService; import org.thingsboard.server.dao.rule.RuleChainService; import org.thingsboard.server.dao.tenant.TenantService; @@ -770,6 +773,11 @@ public class DefaultTbContext implements TbContext { return mainCtx.getResourceService(); } + @Override + public TbResourceDataCache getTbResourceDataCache() { + return mainCtx.getResourceDataCache(); + } + @Override public OtaPackageService getOtaPackageService() { return mainCtx.getOtaPackageService(); @@ -1054,7 +1062,18 @@ public class DefaultTbContext implements TbContext { @Override public void checkTenantEntity(EntityId entityId) throws TbNodeException { - if (!this.getTenantId().equals(TenantIdLoader.findTenantId(this, entityId))) { + TenantId actualTenantId = TenantIdLoader.findTenantId(this, entityId); + assertSameTenantId(actualTenantId, entityId); + } + + @Override + public & HasTenantId, I extends EntityId> void checkTenantEntity(E entity) throws TbNodeException { + TenantId actualTenantId = entity.getTenantId(); + assertSameTenantId(actualTenantId, entity.getId()); + } + + private void assertSameTenantId(TenantId tenantId, EntityId entityId) throws TbNodeException { + if (!getTenantId().equals(tenantId)) { throw new TbNodeException("Entity with id: '" + entityId + "' specified in the configuration doesn't belong to the current tenant.", true); } } diff --git a/application/src/main/java/org/thingsboard/server/controller/TbResourceController.java b/application/src/main/java/org/thingsboard/server/controller/TbResourceController.java index b23603f6a2..54d2494679 100644 --- a/application/src/main/java/org/thingsboard/server/controller/TbResourceController.java +++ b/application/src/main/java/org/thingsboard/server/controller/TbResourceController.java @@ -55,14 +55,17 @@ import org.thingsboard.server.common.data.util.ThrowingSupplier; import org.thingsboard.server.config.annotations.ApiOperation; import org.thingsboard.server.queue.util.TbCoreComponent; import org.thingsboard.server.service.resource.TbResourceService; +import org.thingsboard.server.service.security.model.SecurityUser; import org.thingsboard.server.service.security.permission.Operation; import org.thingsboard.server.service.security.permission.Resource; +import java.util.ArrayList; import java.util.Collections; import java.util.EnumSet; import java.util.HashSet; import java.util.List; import java.util.Set; +import java.util.UUID; import static org.thingsboard.server.controller.ControllerConstants.AVAILABLE_FOR_ANY_AUTHORIZED_USER; import static org.thingsboard.server.controller.ControllerConstants.LWM2M_OBJECT_DESCRIPTION; @@ -263,6 +266,20 @@ public class TbResourceController extends BaseController { } } + @ApiOperation(value = "Get Resource Infos by ids (getSystemOrTenantResourcesByIds)") + @PreAuthorize("hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN')") + @GetMapping(value = "/resource", params = {"resourceIds"}) + public List getSystemOrTenantResourcesByIds( + @Parameter(description = "A list of resource ids, separated by comma ','", array = @ArraySchema(schema = @Schema(type = "string"))) + @RequestParam("resourceIds") Set resourceUuids) throws ThingsboardException { + SecurityUser user = getCurrentUser(); + List resourceIds = new ArrayList<>(); + for (UUID resourceId : resourceUuids) { + resourceIds.add(new TbResourceId(resourceId)); + } + return resourceService.findSystemOrTenantResourcesByIds(user.getTenantId(), resourceIds); + } + @ApiOperation(value = "Get All Resource Infos (getAllResources)", notes = "Returns a page of Resource Info objects owned by tenant. " + PAGE_DATA_PARAMETERS + RESOURCE_INFO_DESCRIPTION + TENANT_AUTHORITY_PARAGRAPH) diff --git a/application/src/main/java/org/thingsboard/server/service/queue/DefaultTbCalculatedFieldConsumerService.java b/application/src/main/java/org/thingsboard/server/service/queue/DefaultTbCalculatedFieldConsumerService.java index 8d4ab25578..acb36449e8 100644 --- a/application/src/main/java/org/thingsboard/server/service/queue/DefaultTbCalculatedFieldConsumerService.java +++ b/application/src/main/java/org/thingsboard/server/service/queue/DefaultTbCalculatedFieldConsumerService.java @@ -35,6 +35,7 @@ import org.thingsboard.server.common.msg.plugin.ComponentLifecycleMsg; import org.thingsboard.server.common.msg.queue.ServiceType; import org.thingsboard.server.common.msg.queue.TbCallback; import org.thingsboard.server.common.msg.queue.TopicPartitionInfo; +import org.thingsboard.server.dao.resource.TbResourceDataCache; import org.thingsboard.server.dao.tenant.TbTenantProfileCache; import org.thingsboard.server.gen.transport.TransportProtos.CalculatedFieldLinkedTelemetryMsgProto; import org.thingsboard.server.gen.transport.TransportProtos.CalculatedFieldTelemetryMsgProto; @@ -83,6 +84,7 @@ public class DefaultTbCalculatedFieldConsumerService extends AbstractPartitionBa ActorSystemContext actorContext, TbDeviceProfileCache deviceProfileCache, TbAssetProfileCache assetProfileCache, + TbResourceDataCache tbResourceDataCache, TbTenantProfileCache tenantProfileCache, TbApiUsageStateService apiUsageStateService, PartitionService partitionService, @@ -90,7 +92,7 @@ public class DefaultTbCalculatedFieldConsumerService extends AbstractPartitionBa JwtSettingsService jwtSettingsService, CalculatedFieldCache calculatedFieldCache, CalculatedFieldStateService stateService) { - super(actorContext, tenantProfileCache, deviceProfileCache, assetProfileCache, calculatedFieldCache, apiUsageStateService, partitionService, + super(actorContext, tenantProfileCache, deviceProfileCache, assetProfileCache, tbResourceDataCache, calculatedFieldCache, apiUsageStateService, partitionService, eventPublisher, jwtSettingsService); this.queueFactory = tbQueueFactory; this.stateService = stateService; diff --git a/application/src/main/java/org/thingsboard/server/service/queue/DefaultTbClusterService.java b/application/src/main/java/org/thingsboard/server/service/queue/DefaultTbClusterService.java index 265f14c4e2..86d00c77ca 100644 --- a/application/src/main/java/org/thingsboard/server/service/queue/DefaultTbClusterService.java +++ b/application/src/main/java/org/thingsboard/server/service/queue/DefaultTbClusterService.java @@ -48,6 +48,7 @@ import org.thingsboard.server.common.data.id.DeviceProfileId; import org.thingsboard.server.common.data.id.EdgeId; import org.thingsboard.server.common.data.id.EntityId; import org.thingsboard.server.common.data.id.RuleChainId; +import org.thingsboard.server.common.data.id.TbResourceId; import org.thingsboard.server.common.data.id.TenantId; import org.thingsboard.server.common.data.msg.TbMsgType; import org.thingsboard.server.common.data.page.PageData; @@ -435,8 +436,9 @@ public class DefaultTbClusterService implements TbClusterService { @Override public void onResourceChange(TbResourceInfo resource, TbQueueCallback callback) { + TenantId tenantId = resource.getTenantId(); + TbResourceId resourceId = resource.getId(); if (resource.getResourceType() == ResourceType.LWM2M_MODEL) { - TenantId tenantId = resource.getTenantId(); log.trace("[{}][{}][{}] Processing change resource", tenantId, resource.getResourceType(), resource.getResourceKey()); ResourceUpdateMsg resourceUpdateMsg = ResourceUpdateMsg.newBuilder() .setTenantIdMSB(tenantId.getId().getMostSignificantBits()) @@ -447,6 +449,7 @@ public class DefaultTbClusterService implements TbClusterService { ToTransportMsg transportMsg = ToTransportMsg.newBuilder().setResourceUpdateMsg(resourceUpdateMsg).build(); broadcast(transportMsg, DataConstants.LWM2M_TRANSPORT_NAME, callback); } + broadcastEntityStateChangeEvent(tenantId, resourceId, ComponentLifecycleEvent.UPDATED); } @Override @@ -462,6 +465,7 @@ public class DefaultTbClusterService implements TbClusterService { ToTransportMsg transportMsg = ToTransportMsg.newBuilder().setResourceDeleteMsg(resourceDeleteMsg).build(); broadcast(transportMsg, DataConstants.LWM2M_TRANSPORT_NAME, callback); } + broadcastEntityStateChangeEvent(resource.getTenantId(), resource.getId(), ComponentLifecycleEvent.DELETED); } private void broadcastEntityChangeToTransport(TenantId tenantId, EntityId entityid, T entity, TbQueueCallback callback) { @@ -592,7 +596,8 @@ public class DefaultTbClusterService implements TbClusterService { EntityType.TENANT_PROFILE, EntityType.DEVICE_PROFILE, EntityType.ASSET_PROFILE, - EntityType.JOB) + EntityType.JOB, + EntityType.TB_RESOURCE) || (entityType == EntityType.ASSET && msg.getEvent() == ComponentLifecycleEvent.UPDATED) || (entityType == EntityType.DEVICE && msg.getEvent() == ComponentLifecycleEvent.UPDATED) ) { diff --git a/application/src/main/java/org/thingsboard/server/service/queue/DefaultTbCoreConsumerService.java b/application/src/main/java/org/thingsboard/server/service/queue/DefaultTbCoreConsumerService.java index f0b1a4d7d2..9ab5a062eb 100644 --- a/application/src/main/java/org/thingsboard/server/service/queue/DefaultTbCoreConsumerService.java +++ b/application/src/main/java/org/thingsboard/server/service/queue/DefaultTbCoreConsumerService.java @@ -55,6 +55,7 @@ import org.thingsboard.server.common.stats.StatsFactory; import org.thingsboard.server.common.util.KvProtoUtil; import org.thingsboard.server.common.util.ProtoUtils; import org.thingsboard.server.dao.resource.ImageCacheKey; +import org.thingsboard.server.dao.resource.TbResourceDataCache; import org.thingsboard.server.dao.tenant.TbTenantProfileCache; import org.thingsboard.server.gen.transport.TransportProtos; import org.thingsboard.server.gen.transport.TransportProtos.DeviceStateServiceMsgProto; @@ -176,10 +177,11 @@ public class DefaultTbCoreConsumerService extends AbstractConsumerService>>() { + var widgetTypeInfos = JacksonUtil.readValue(referenceValues.toString(), new TypeReference>>() { }); Assert.assertNotNull(widgetTypeInfos); Assert.assertFalse(widgetTypeInfos.isEmpty()); Assert.assertEquals(1, widgetTypeInfos.size()); - var dashboardInfo = widgetTypeInfos.get(EntityType.WIDGET_TYPE.name()).get(0); - Assert.assertNotNull(dashboardInfo); - - WidgetTypeInfo foundedWidgetType = doGet("/api/widgetTypeInfo/" + savedWidgetType.getId().getId().toString(), WidgetTypeInfo.class); - Assert.assertNotNull(foundedWidgetType); - Assert.assertEquals(foundedWidgetType, dashboardInfo); + var widgetTypeInfo = widgetTypeInfos.get(EntityType.WIDGET_TYPE.name()).get(0); + Assert.assertNotNull(widgetTypeInfo); + Assert.assertEquals(new EntityInfo(savedWidgetType.getId(), savedWidgetType.getName()), widgetTypeInfo); } @Test @@ -372,7 +370,7 @@ public class TbResourceControllerTest extends AbstractControllerTest { Assert.assertTrue(isSuccess); var referenceValues = JacksonUtil.toJsonNode(deleteResponse).get("references"); - var widgetTypeInfos = JacksonUtil.readValue(referenceValues.toString(), new TypeReference>>() { + var widgetTypeInfos = JacksonUtil.readValue(referenceValues.toString(), new TypeReference>>() { }); Assert.assertNull(widgetTypeInfos); } @@ -417,7 +415,7 @@ public class TbResourceControllerTest extends AbstractControllerTest { var referenceValues = JacksonUtil.toJsonNode(deleteResponse).get("references"); Assert.assertNotNull(referenceValues); - var dashboardInfos = JacksonUtil.readValue(referenceValues.toString(), new TypeReference>>() { + var dashboardInfos = JacksonUtil.readValue(referenceValues.toString(), new TypeReference>>() { }); Assert.assertNotNull(dashboardInfos); Assert.assertFalse(dashboardInfos.isEmpty()); @@ -425,10 +423,7 @@ public class TbResourceControllerTest extends AbstractControllerTest { var dashboardInfo = dashboardInfos.get(EntityType.DASHBOARD.name()).get(0); Assert.assertNotNull(dashboardInfo); - - DashboardInfo foundDashboard = doGet("/api/dashboard/info/" + savedDashboard.getId().getId().toString(), DashboardInfo.class); - Assert.assertNotNull(foundDashboard); - Assert.assertEquals(foundDashboard, dashboardInfo); + Assert.assertEquals(new EntityInfo(savedDashboard.getId(), savedDashboard.getName()), dashboardInfo); } @Test @@ -469,7 +464,7 @@ public class TbResourceControllerTest extends AbstractControllerTest { Assert.assertTrue(isSuccess); var referenceValues = JacksonUtil.toJsonNode(deleteResponse).get("references"); - var dashboardInfos = JacksonUtil.readValue(referenceValues.toString(), new TypeReference>>() { + var dashboardInfos = JacksonUtil.readValue(referenceValues.toString(), new TypeReference>>() { }); Assert.assertNull(dashboardInfos); } diff --git a/application/src/test/java/org/thingsboard/server/service/resource/DefaultResourceDataCacheTest.java b/application/src/test/java/org/thingsboard/server/service/resource/DefaultResourceDataCacheTest.java new file mode 100644 index 0000000000..f12a8d3c5d --- /dev/null +++ b/application/src/test/java/org/thingsboard/server/service/resource/DefaultResourceDataCacheTest.java @@ -0,0 +1,83 @@ +/** + * 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.resource; + +import org.junit.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.test.context.bean.override.mockito.MockitoSpyBean; +import org.thingsboard.common.util.JacksonUtil; +import org.thingsboard.server.common.data.GeneralFileDescriptor; +import org.thingsboard.server.common.data.ResourceType; +import org.thingsboard.server.common.data.TbResource; +import org.thingsboard.server.common.data.TbResourceDataInfo; +import org.thingsboard.server.common.data.TbResourceInfo; +import org.thingsboard.server.controller.AbstractControllerTest; +import org.thingsboard.server.dao.resource.ResourceService; +import org.thingsboard.server.dao.resource.TbResourceDataCache; +import org.thingsboard.server.dao.service.DaoSqlTest; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.clearInvocations; +import static org.mockito.Mockito.timeout; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoMoreInteractions; + +@DaoSqlTest +public class DefaultResourceDataCacheTest extends AbstractControllerTest { + + @MockitoSpyBean + private ResourceService resourceService; + @Autowired + private TbResourceService tbResourceService; + @MockitoSpyBean + private TbResourceDataCache resourceDataCache; + + @Test + public void testGetCachedResourceData() throws Exception { + loginTenantAdmin(); + + TbResource resource = new TbResource(); + resource.setTenantId(tenantId); + resource.setTitle("File for AI request"); + resource.setResourceType(ResourceType.GENERAL); + resource.setFileName("myTestJson.json"); + GeneralFileDescriptor descriptor = new GeneralFileDescriptor("application/json"); + resource.setDescriptorValue(descriptor); + byte[] data = "This is a test prompt for AI request.".getBytes(); + resource.setData(data); + TbResourceInfo savedResource = tbResourceService.save(resource); + verify(resourceDataCache, timeout(2000).times(1)).evictResourceData(tenantId, savedResource.getId()); + + TbResourceDataInfo cachedData = resourceDataCache.getResourceDataInfoAsync(tenantId, savedResource.getId()).get(); + assertThat(cachedData.getData()).isEqualTo(data); + assertThat(JacksonUtil.treeToValue(cachedData.getDescriptor(), GeneralFileDescriptor.class)).isEqualTo(descriptor); + verify(resourceService).getResourceDataInfo(tenantId, savedResource.getId()); + + // retrieve resource data second time + clearInvocations(resourceService); + TbResourceDataInfo cachedData2 = resourceDataCache.getResourceDataInfoAsync(tenantId, savedResource.getId()).get(); + assertThat(cachedData2.getData()).isEqualTo(data); + verifyNoMoreInteractions(resourceService); + + // delete resource, check cache + TbResource resourceById = resourceService.findResourceById(tenantId, savedResource.getId()); + tbResourceService.delete(resourceById, true, null); + verify(resourceDataCache, timeout(2000).times(2)).evictResourceData(tenantId, savedResource.getId()); + TbResourceDataInfo cachedDataAfterDeletion = resourceDataCache.getResourceDataInfoAsync(tenantId, savedResource.getId()).get(); + assertThat(cachedDataAfterDeletion).isEqualTo(null); + } + +} diff --git a/application/src/test/java/org/thingsboard/server/service/resource/sql/BaseTbResourceServiceTest.java b/application/src/test/java/org/thingsboard/server/service/resource/sql/BaseTbResourceServiceTest.java index fe416eacd5..da20b9489c 100644 --- a/application/src/test/java/org/thingsboard/server/service/resource/sql/BaseTbResourceServiceTest.java +++ b/application/src/test/java/org/thingsboard/server/service/resource/sql/BaseTbResourceServiceTest.java @@ -17,6 +17,7 @@ package org.thingsboard.server.service.resource.sql; import com.datastax.oss.driver.api.core.uuid.Uuids; import com.fasterxml.jackson.databind.node.ObjectNode; +import com.fasterxml.jackson.databind.node.TextNode; import org.junit.After; import org.junit.Assert; import org.junit.Before; @@ -24,8 +25,10 @@ import org.junit.Test; import org.junit.jupiter.api.Assertions; import org.springframework.beans.factory.annotation.Autowired; import org.thingsboard.common.util.JacksonUtil; +import org.thingsboard.rule.engine.ai.TbAiNode; +import org.thingsboard.rule.engine.ai.TbAiNodeConfiguration; +import org.thingsboard.rule.engine.ai.TbResponseFormat; import org.thingsboard.server.common.data.Dashboard; -import org.thingsboard.server.common.data.DashboardInfo; import org.thingsboard.server.common.data.DataConstants; import org.thingsboard.server.common.data.EntityInfo; import org.thingsboard.server.common.data.EntityType; @@ -37,26 +40,40 @@ import org.thingsboard.server.common.data.TbResourceInfoFilter; import org.thingsboard.server.common.data.Tenant; import org.thingsboard.server.common.data.TenantProfile; import org.thingsboard.server.common.data.User; +import org.thingsboard.server.common.data.ai.AiModel; +import org.thingsboard.server.common.data.ai.model.chat.OpenAiChatModelConfig; +import org.thingsboard.server.common.data.ai.provider.OpenAiProviderConfig; +import org.thingsboard.server.common.data.debug.DebugSettings; import org.thingsboard.server.common.data.exception.ThingsboardException; +import org.thingsboard.server.common.data.id.RuleChainId; +import org.thingsboard.server.common.data.id.TbResourceId; import org.thingsboard.server.common.data.id.TenantId; import org.thingsboard.server.common.data.page.PageData; import org.thingsboard.server.common.data.page.PageLink; +import org.thingsboard.server.common.data.rule.RuleChain; +import org.thingsboard.server.common.data.rule.RuleChainMetaData; +import org.thingsboard.server.common.data.rule.RuleChainType; +import org.thingsboard.server.common.data.rule.RuleNode; import org.thingsboard.server.common.data.security.Authority; import org.thingsboard.server.common.data.tenant.profile.DefaultTenantProfileConfiguration; import org.thingsboard.server.common.data.widget.WidgetTypeDetails; -import org.thingsboard.server.common.data.widget.WidgetTypeInfo; import org.thingsboard.server.controller.AbstractControllerTest; +import org.thingsboard.server.dao.ai.AiModelService; import org.thingsboard.server.dao.dashboard.DashboardService; import org.thingsboard.server.dao.exception.DataValidationException; import org.thingsboard.server.dao.resource.ResourceService; +import org.thingsboard.server.dao.rule.RuleChainService; import org.thingsboard.server.dao.service.DaoSqlTest; import org.thingsboard.server.dao.widget.WidgetTypeService; import org.thingsboard.server.service.resource.TbResourceService; import java.util.ArrayList; +import java.util.Arrays; import java.util.Base64; import java.util.Collections; import java.util.List; +import java.util.Set; +import java.util.function.Function; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; @@ -135,6 +152,10 @@ public class BaseTbResourceServiceTest extends AbstractControllerTest { private WidgetTypeService widgetTypeService; @Autowired private DashboardService dashboardService; + @Autowired + private RuleChainService ruleChainService; + @Autowired + private AiModelService aiModelService; private Tenant savedTenant; private User tenantAdmin; @@ -453,11 +474,9 @@ public class BaseTbResourceServiceTest extends AbstractControllerTest { Assert.assertFalse(result.getReferences().isEmpty()); Assert.assertEquals(1, result.getReferences().size()); - WidgetTypeInfo widgetTypeInfo = (WidgetTypeInfo) result.getReferences().get(EntityType.WIDGET_TYPE.name()).get(0); - WidgetTypeInfo foundWidgetTypeInfo = new WidgetTypeInfo(foundWidgetType); + EntityInfo widgetTypeInfo = (EntityInfo) result.getReferences().get(EntityType.WIDGET_TYPE.name()).get(0); Assert.assertNotNull(widgetTypeInfo); - Assert.assertNotNull(foundWidgetTypeInfo); - Assert.assertEquals(widgetTypeInfo, foundWidgetTypeInfo); + Assert.assertEquals(widgetTypeInfo, new EntityInfo(foundWidgetType.getId(), foundWidgetType.getName())); TbResourceInfo foundResourceInfo = resourceService.findResourceInfoById(savedTenant.getId(), savedResource.getId()); Assert.assertNotNull(foundResource); @@ -546,11 +565,9 @@ public class BaseTbResourceServiceTest extends AbstractControllerTest { Assert.assertNotNull(result.getReferences()); Assert.assertEquals(1, result.getReferences().size()); - DashboardInfo dashboardInfo = (DashboardInfo) result.getReferences().get(EntityType.DASHBOARD.name()).get(0); - DashboardInfo foundDashboardInfo = dashboardService.findDashboardInfoById(savedTenant.getId(), savedDashboard.getId()); + EntityInfo dashboardInfo = (EntityInfo) result.getReferences().get(EntityType.DASHBOARD.name()).get(0); Assert.assertNotNull(dashboardInfo); - Assert.assertNotNull(foundDashboardInfo); - Assert.assertEquals(foundDashboardInfo, dashboardInfo); + Assert.assertEquals(new EntityInfo(savedDashboard.getId(), savedDashboard.getName()), dashboardInfo); foundResource = resourceService.findResourceById(savedTenant.getId(), savedResource.getId()); Assert.assertNotNull(foundResource); @@ -598,6 +615,90 @@ public class BaseTbResourceServiceTest extends AbstractControllerTest { Assert.assertNull(foundResource); } + @Test + public void testShouldNotDeleteResourceIfUsedInAiNode() throws Exception { + TbResource resource = new TbResource(); + resource.setResourceType(ResourceType.GENERAL); + resource.setTitle("My resource"); + resource.setFileName("test.json"); + resource.setTenantId(savedTenant.getId()); + resource.setData("".getBytes()); + TbResourceInfo savedResource = tbResourceService.save(resource); + RuleChainMetaData ruleChain = createRuleChainReferringResource(savedResource.getId()); + + TbResourceDeleteResult result = tbResourceService.delete(savedResource, false, null); + assertThat(result).isNotNull(); + assertThat(result.isSuccess()).isFalse(); + assertThat(result.getReferences()).isNotEmpty().hasSize(1); + EntityInfo entityInfo = (EntityInfo) result.getReferences().get(EntityType.RULE_CHAIN.name()).get(0); + assertThat(entityInfo).isEqualTo(new EntityInfo(ruleChain.getRuleChainId(), "Test")); + + TbResource foundResource = resourceService.findResourceById(savedTenant.getId(), savedResource.getId()); + assertThat(foundResource).isNotNull(); + + // force delete + TbResourceDeleteResult deleteResult = tbResourceService.delete(savedResource, true, null); + assertThat(deleteResult).isNotNull(); + assertThat(deleteResult.isSuccess()).isTrue(); + + TbResource resourceAfterDeletion = resourceService.findResourceById(savedTenant.getId(), savedResource.getId()); + assertThat(resourceAfterDeletion).isNull(); + } + + private RuleChainMetaData createRuleChainReferringResource(TbResourceId resourceId) { + AiModel model = constructValidOpenAiModel("Test model"); + AiModel saved = aiModelService.save(model); + + RuleChain ruleChain = new RuleChain(); + ruleChain.setTenantId(tenantId); + ruleChain.setName("Test"); + ruleChain.setType(RuleChainType.CORE); + ruleChain.setDebugMode(true); + ruleChain.setConfiguration(JacksonUtil.newObjectNode().set("a", new TextNode("b"))); + ruleChain = ruleChainService.saveRuleChain(ruleChain); + RuleChainId ruleChainId = ruleChain.getId(); + + RuleChainMetaData metaData = new RuleChainMetaData(); + metaData.setRuleChainId(ruleChainId); + + RuleNode aiNode = new RuleNode(); + aiNode.setName("Ai request"); + aiNode.setType(org.thingsboard.rule.engine.ai.TbAiNode.class.getName()); + aiNode.setConfigurationVersion(TbAiNode.class.getAnnotation(org.thingsboard.rule.engine.api.RuleNode.class).version()); + aiNode.setDebugSettings(DebugSettings.all()); + TbAiNodeConfiguration configuration = new TbAiNodeConfiguration(); + configuration.setResourceIds(Set.of(resourceId.getId())); + configuration.setModelId(saved.getId()); + configuration.setResponseFormat(new TbResponseFormat.TbJsonResponseFormat()); + configuration.setTimeoutSeconds(1); + configuration.setUserPrompt("What is temp"); + aiNode.setConfiguration(JacksonUtil.valueToTree(configuration)); + + metaData.setNodes(Arrays.asList(aiNode)); + metaData.setFirstNodeIndex(0); + ruleChainService.saveRuleChainMetaData(tenantId, metaData, Function.identity()); + return ruleChainService.loadRuleChainMetaData(tenantId, ruleChainId); + } + + private AiModel constructValidOpenAiModel(String name) { + var modelConfig = OpenAiChatModelConfig.builder() + .providerConfig(new OpenAiProviderConfig("test-api-key")) + .modelId("gpt-4o") + .temperature(0.5) + .topP(0.3) + .frequencyPenalty(0.1) + .presencePenalty(0.2) + .maxOutputTokens(1000) + .timeoutSeconds(60) + .maxRetries(2) + .build(); + + return AiModel.builder() + .tenantId(tenantId) + .name(name) + .configuration(modelConfig) + .build(); + } @Test public void testFindTenantResourcesByTenantId() throws Exception { loginSysAdmin(); diff --git a/common/dao-api/src/main/java/org/thingsboard/server/dao/resource/ResourceService.java b/common/dao-api/src/main/java/org/thingsboard/server/dao/resource/ResourceService.java index ee187db46b..65211ec17a 100644 --- a/common/dao-api/src/main/java/org/thingsboard/server/dao/resource/ResourceService.java +++ b/common/dao-api/src/main/java/org/thingsboard/server/dao/resource/ResourceService.java @@ -21,6 +21,7 @@ import org.thingsboard.server.common.data.ResourceExportData; import org.thingsboard.server.common.data.ResourceSubType; import org.thingsboard.server.common.data.ResourceType; import org.thingsboard.server.common.data.TbResource; +import org.thingsboard.server.common.data.TbResourceDataInfo; import org.thingsboard.server.common.data.TbResourceDeleteResult; import org.thingsboard.server.common.data.TbResourceInfo; import org.thingsboard.server.common.data.TbResourceInfoFilter; @@ -46,6 +47,8 @@ public interface ResourceService extends EntityDaoService { byte[] getResourceData(TenantId tenantId, TbResourceId resourceId); + TbResourceDataInfo getResourceDataInfo(TenantId tenantId, TbResourceId resourceId); + ResourceExportData exportResource(TbResourceInfo resourceInfo); List exportResources(TenantId tenantId, Collection resources); @@ -90,4 +93,6 @@ public interface ResourceService extends EntityDaoService { TbResource createOrUpdateSystemResource(ResourceType resourceType, ResourceSubType resourceSubType, String resourceKey, byte[] data); + List findSystemOrTenantResourcesByIds(TenantId tenantId, List resourceIds); + } diff --git a/common/dao-api/src/main/java/org/thingsboard/server/dao/resource/TbResourceDataCache.java b/common/dao-api/src/main/java/org/thingsboard/server/dao/resource/TbResourceDataCache.java new file mode 100644 index 0000000000..23485684af --- /dev/null +++ b/common/dao-api/src/main/java/org/thingsboard/server/dao/resource/TbResourceDataCache.java @@ -0,0 +1,28 @@ +/** + * 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.dao.resource; + +import com.google.common.util.concurrent.FluentFuture; +import org.thingsboard.server.common.data.TbResourceDataInfo; +import org.thingsboard.server.common.data.id.TbResourceId; +import org.thingsboard.server.common.data.id.TenantId; + +public interface TbResourceDataCache { + + FluentFuture getResourceDataInfoAsync(TenantId tenantId, TbResourceId resourceId); + + void evictResourceData(TenantId tenantId, TbResourceId resourceId); +} diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/GeneralFileDescriptor.java b/common/data/src/main/java/org/thingsboard/server/common/data/GeneralFileDescriptor.java new file mode 100644 index 0000000000..94edd4fa01 --- /dev/null +++ b/common/data/src/main/java/org/thingsboard/server/common/data/GeneralFileDescriptor.java @@ -0,0 +1,29 @@ +/** + * 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.common.data; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.EqualsAndHashCode; +import lombok.NoArgsConstructor; + +@Data +@EqualsAndHashCode +@AllArgsConstructor +@NoArgsConstructor +public class GeneralFileDescriptor { + private String mediaType; +} diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/ResourceType.java b/common/data/src/main/java/org/thingsboard/server/common/data/ResourceType.java index 77b17198e9..f7579b6878 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/ResourceType.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/ResourceType.java @@ -25,7 +25,8 @@ public enum ResourceType { PKCS_12("application/x-pkcs12", false, false), JS_MODULE("application/javascript", true, true), IMAGE(null, true, true), - DASHBOARD("application/json", true, true); + DASHBOARD("application/json", true, true), + GENERAL(null, false, true); @Getter private final String mediaType; diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/TbResource.java b/common/data/src/main/java/org/thingsboard/server/common/data/TbResource.java index ba37067106..457d30e263 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/TbResource.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/TbResource.java @@ -16,6 +16,7 @@ package org.thingsboard.server.common.data; import com.fasterxml.jackson.annotation.JsonGetter; +import com.fasterxml.jackson.annotation.JsonIgnore; import com.fasterxml.jackson.annotation.JsonSetter; import io.swagger.v3.oas.annotations.media.Schema; import lombok.Data; @@ -86,6 +87,11 @@ public class TbResource extends TbResourceInfo { .orElse(null); } + @JsonIgnore + public TbResourceDataInfo toResourceDataInfo() { + return new TbResourceDataInfo(data, getDescriptor()); + } + @Override public String toString() { return super.toString(); diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/TbResourceDataInfo.java b/common/data/src/main/java/org/thingsboard/server/common/data/TbResourceDataInfo.java new file mode 100644 index 0000000000..039478470d --- /dev/null +++ b/common/data/src/main/java/org/thingsboard/server/common/data/TbResourceDataInfo.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.common.data; + +import com.fasterxml.jackson.databind.JsonNode; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@AllArgsConstructor +@NoArgsConstructor +public class TbResourceDataInfo { + + private byte[] data; + private JsonNode descriptor; + +} diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/TbResourceDeleteResult.java b/common/data/src/main/java/org/thingsboard/server/common/data/TbResourceDeleteResult.java index edc5a2f539..76945a97ed 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/TbResourceDeleteResult.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/TbResourceDeleteResult.java @@ -17,7 +17,6 @@ package org.thingsboard.server.common.data; import lombok.Builder; import lombok.Data; -import org.thingsboard.server.common.data.id.HasId; import java.util.List; import java.util.Map; @@ -27,6 +26,6 @@ import java.util.Map; public class TbResourceDeleteResult { private boolean success; - private Map>> references; + private Map> references; } diff --git a/common/util/src/main/java/org/thingsboard/common/util/DonAsynchron.java b/common/util/src/main/java/org/thingsboard/common/util/DonAsynchron.java index 0f1a56cb17..79b8181548 100644 --- a/common/util/src/main/java/org/thingsboard/common/util/DonAsynchron.java +++ b/common/util/src/main/java/org/thingsboard/common/util/DonAsynchron.java @@ -15,12 +15,15 @@ */ package org.thingsboard.common.util; +import com.google.common.util.concurrent.FluentFuture; import com.google.common.util.concurrent.FutureCallback; import com.google.common.util.concurrent.Futures; import com.google.common.util.concurrent.ListenableFuture; import com.google.common.util.concurrent.MoreExecutors; +import com.google.common.util.concurrent.SettableFuture; import java.util.concurrent.Callable; +import java.util.concurrent.CompletableFuture; import java.util.concurrent.Executor; import java.util.function.Consumer; @@ -71,4 +74,16 @@ public class DonAsynchron { return future; } + public static FluentFuture toFluentFuture(CompletableFuture completable) { + SettableFuture future = SettableFuture.create(); + completable.whenComplete((result, exception) -> { + if (exception != null) { + future.setException(exception); + } else { + future.set(result); + } + }); + return FluentFuture.from(future); + } + } diff --git a/dao/src/main/java/org/thingsboard/server/dao/ResourceContainerDao.java b/dao/src/main/java/org/thingsboard/server/dao/ResourceContainerDao.java index 6a952fd501..93cc64b2db 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/ResourceContainerDao.java +++ b/dao/src/main/java/org/thingsboard/server/dao/ResourceContainerDao.java @@ -15,6 +15,7 @@ */ package org.thingsboard.server.dao; +import org.thingsboard.server.common.data.EntityInfo; import org.thingsboard.server.common.data.id.HasId; import org.thingsboard.server.common.data.id.TenantId; @@ -22,8 +23,8 @@ import java.util.List; public interface ResourceContainerDao> { - List findByTenantIdAndResourceLink(TenantId tenantId, String link, int limit); + List findByTenantIdAndResource(TenantId tenantId, String reference, int limit); - List findByResourceLink(String link, int limit); + List findByResource(String reference, int limit); } diff --git a/dao/src/main/java/org/thingsboard/server/dao/resource/BaseImageService.java b/dao/src/main/java/org/thingsboard/server/dao/resource/BaseImageService.java index 3be7f5b91c..c16e37d30f 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/resource/BaseImageService.java +++ b/dao/src/main/java/org/thingsboard/server/dao/resource/BaseImageService.java @@ -50,6 +50,7 @@ import org.thingsboard.server.dao.ImageContainerDao; import org.thingsboard.server.dao.asset.AssetProfileDao; import org.thingsboard.server.dao.dashboard.DashboardInfoDao; import org.thingsboard.server.dao.device.DeviceProfileDao; +import org.thingsboard.server.dao.rule.RuleChainDao; import org.thingsboard.server.dao.service.Validator; import org.thingsboard.server.dao.service.validator.ResourceDataValidator; import org.thingsboard.server.dao.util.ImageUtils; @@ -109,8 +110,8 @@ public class BaseImageService extends BaseResourceService implements ImageServic public BaseImageService(TbResourceDao resourceDao, TbResourceInfoDao resourceInfoDao, ResourceDataValidator resourceValidator, AssetProfileDao assetProfileDao, DeviceProfileDao deviceProfileDao, WidgetsBundleDao widgetsBundleDao, - WidgetTypeDao widgetTypeDao, DashboardInfoDao dashboardInfoDao) { - super(resourceDao, resourceInfoDao, resourceValidator, widgetTypeDao, dashboardInfoDao); + WidgetTypeDao widgetTypeDao, DashboardInfoDao dashboardInfoDao, RuleChainDao ruleChainDao) { + super(resourceDao, resourceInfoDao, resourceValidator, widgetTypeDao, dashboardInfoDao, ruleChainDao); this.assetProfileDao = assetProfileDao; this.deviceProfileDao = deviceProfileDao; this.widgetsBundleDao = widgetsBundleDao; diff --git a/dao/src/main/java/org/thingsboard/server/dao/resource/BaseResourceService.java b/dao/src/main/java/org/thingsboard/server/dao/resource/BaseResourceService.java index bf941256f5..7f0dd4a6bf 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/resource/BaseResourceService.java +++ b/dao/src/main/java/org/thingsboard/server/dao/resource/BaseResourceService.java @@ -35,11 +35,13 @@ import org.thingsboard.server.cache.resourceInfo.ResourceInfoCacheKey; import org.thingsboard.server.cache.resourceInfo.ResourceInfoEvictEvent; import org.thingsboard.server.common.data.Dashboard; import org.thingsboard.server.common.data.DataConstants; +import org.thingsboard.server.common.data.EntityInfo; import org.thingsboard.server.common.data.EntityType; import org.thingsboard.server.common.data.ResourceExportData; import org.thingsboard.server.common.data.ResourceSubType; import org.thingsboard.server.common.data.ResourceType; import org.thingsboard.server.common.data.TbResource; +import org.thingsboard.server.common.data.TbResourceDataInfo; import org.thingsboard.server.common.data.TbResourceDeleteResult; import org.thingsboard.server.common.data.TbResourceInfo; import org.thingsboard.server.common.data.TbResourceInfoFilter; @@ -56,6 +58,7 @@ import org.thingsboard.server.dao.entity.AbstractCachedEntityService; import org.thingsboard.server.dao.eventsourcing.DeleteEntityEvent; import org.thingsboard.server.dao.eventsourcing.SaveEntityEvent; import org.thingsboard.server.dao.exception.DataValidationException; +import org.thingsboard.server.dao.rule.RuleChainDao; import org.thingsboard.server.dao.service.PaginatedRemover; import org.thingsboard.server.dao.service.Validator; import org.thingsboard.server.dao.service.validator.ResourceDataValidator; @@ -92,13 +95,16 @@ public class BaseResourceService extends AbstractCachedEntityService> resourceContainerDaoMap = new HashMap<>(); + protected final RuleChainDao ruleChainDao; + private final Map> resourceLinkContainerDaoMap = new HashMap<>(); + private final Map> generalResourceContainerDaoMap = new HashMap<>(); protected static final int MAX_ENTITIES_TO_FIND = 10; @PostConstruct public void init() { - resourceContainerDaoMap.put(EntityType.WIDGET_TYPE, widgetTypeDao); - resourceContainerDaoMap.put(EntityType.DASHBOARD, dashboardInfoDao); + resourceLinkContainerDaoMap.put(EntityType.WIDGET_TYPE, widgetTypeDao); + resourceLinkContainerDaoMap.put(EntityType.DASHBOARD, dashboardInfoDao); + generalResourceContainerDaoMap.put(EntityType.RULE_CHAIN, ruleChainDao); } @Autowired @Lazy @@ -206,6 +212,12 @@ public class BaseResourceService extends AbstractCachedEntityService>> affectedEntities = new HashMap<>(); - - resourceContainerDaoMap.forEach((entityType, resourceContainerDao) -> { - var entities = tenantId.isSysTenantId() ? resourceContainerDao.findByResourceLink(link, MAX_ENTITIES_TO_FIND) : - resourceContainerDao.findByTenantIdAndResourceLink(tenantId, link, MAX_ENTITIES_TO_FIND); - if (!entities.isEmpty()) { - affectedEntities.put(entityType.name(), entities); - } - }); - - if (!affectedEntities.isEmpty()) { - success = false; - result.references(affectedEntities); - } + Map> references = findResourceReferences(tenantId, resource); + if (!references.isEmpty()) { + success = false; + result.references(references); } } if (success) { resourceDao.removeById(tenantId, resourceId.getId()); + publishEvictEvent(new ResourceInfoEvictEvent(tenantId, resourceId)); eventPublisher.publishEvent(DeleteEntityEvent.builder().tenantId(tenantId).entity(resource).entityId(resourceId).build()); } return result.success(success).build(); } + private Map> findResourceReferences(TenantId tenantId, TbResourceInfo resource) { + Map> references = new HashMap<>(); + + if (resource.getResourceType() == ResourceType.JS_MODULE) { + var ref = resource.getLink(); + findReferences(tenantId, references, ref, resourceLinkContainerDaoMap); + } + + if (resource.getResourceType() == ResourceType.GENERAL) { + var ref = resource.getId().getId().toString(); + findReferences(tenantId, references, ref, generalResourceContainerDaoMap); + } + + return references; + } + + private void findReferences(TenantId tenantId, Map> references, String ref, Map> resourceLinkContainerDaoMap) { + resourceLinkContainerDaoMap.forEach((entityType, dao) -> { + List entities = tenantId.isSysTenantId() + ? dao.findByResource(ref, MAX_ENTITIES_TO_FIND) + : dao.findByTenantIdAndResource(tenantId, ref, MAX_ENTITIES_TO_FIND); + if (!entities.isEmpty()) { + references.put(entityType.name(), entities); + } + }); + } + @Override public void deleteEntity(TenantId tenantId, EntityId id, boolean force) { deleteResource(tenantId, (TbResourceId) id, force); @@ -663,6 +691,12 @@ public class BaseResourceService extends AbstractCachedEntityService findSystemOrTenantResourcesByIds(TenantId tenantId, List resourceIds) { + log.trace("Executing findSystemOrTenantResourcesByIds, tenantId [{}], resourceIds [{}]", tenantId, resourceIds); + return resourceInfoDao.findSystemOrTenantResourcesByIds(tenantId, resourceIds); + } + @Override public String calculateEtag(byte[] data) { return Hashing.sha256().hashBytes(data).toString(); diff --git a/dao/src/main/java/org/thingsboard/server/dao/resource/DefaultTbResourceDataCache.java b/dao/src/main/java/org/thingsboard/server/dao/resource/DefaultTbResourceDataCache.java new file mode 100644 index 0000000000..452f86b1a6 --- /dev/null +++ b/dao/src/main/java/org/thingsboard/server/dao/resource/DefaultTbResourceDataCache.java @@ -0,0 +1,72 @@ +/** + * 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.dao.resource; + +import com.github.benmanes.caffeine.cache.AsyncLoadingCache; +import com.github.benmanes.caffeine.cache.Caffeine; +import com.google.common.util.concurrent.FluentFuture; +import jakarta.annotation.PostConstruct; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; +import org.thingsboard.common.util.DonAsynchron; +import org.thingsboard.server.common.data.TbResourceDataInfo; +import org.thingsboard.server.common.data.id.TbResourceId; +import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.dao.sql.JpaExecutorService; + +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.TimeUnit; + +@Service +@RequiredArgsConstructor +@Slf4j +public class DefaultTbResourceDataCache implements TbResourceDataCache { + + private final ResourceService resourceService; + private final JpaExecutorService executorService; + + @Value("${cache.tbResourceData.maxSize:100000}") + private int cacheMaxSize; + @Value("${cache.tbResourceData.timeToLiveInMinutes:44640}") + private int cacheValueTtl; + private AsyncLoadingCache cache; + + @PostConstruct + private void init() { + cache = Caffeine.newBuilder() + .maximumSize(cacheMaxSize) + .expireAfterAccess(cacheValueTtl, TimeUnit.MINUTES) + .executor(executorService) + .buildAsync((key, executor) -> CompletableFuture.supplyAsync(() -> resourceService.getResourceDataInfo(key.tenantId(), key.resourceId()), executor)); + } + + @Override + public FluentFuture getResourceDataInfoAsync(TenantId tenantId, TbResourceId resourceId) { + log.trace("Retrieving resource data info by id [{}], tenant id [{}] from cache", resourceId, tenantId); + return DonAsynchron.toFluentFuture(cache.get(new ResourceDataKey(tenantId, resourceId))); + } + + @Override + public void evictResourceData(TenantId tenantId, TbResourceId resourceId) { + cache.asMap().remove(new ResourceDataKey(tenantId, resourceId)); + log.trace("Evicted resource data info with id [{}], tenant id [{}]", resourceId, tenantId); + } + + record ResourceDataKey (TenantId tenantId, TbResourceId resourceId) {} + +} diff --git a/dao/src/main/java/org/thingsboard/server/dao/resource/TbResourceDao.java b/dao/src/main/java/org/thingsboard/server/dao/resource/TbResourceDao.java index 23b59b5658..1b9f250521 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/resource/TbResourceDao.java +++ b/dao/src/main/java/org/thingsboard/server/dao/resource/TbResourceDao.java @@ -18,6 +18,7 @@ package org.thingsboard.server.dao.resource; import org.thingsboard.server.common.data.ResourceSubType; import org.thingsboard.server.common.data.ResourceType; import org.thingsboard.server.common.data.TbResource; +import org.thingsboard.server.common.data.TbResourceDataInfo; import org.thingsboard.server.common.data.id.TbResourceId; import org.thingsboard.server.common.data.id.TenantId; import org.thingsboard.server.common.data.page.PageData; @@ -51,4 +52,5 @@ public interface TbResourceDao extends Dao, TenantEntityWithDataDao, long getResourceSize(TenantId tenantId, TbResourceId resourceId); + TbResourceDataInfo getResourceDataInfo(TenantId tenantId, TbResourceId resourceId); } diff --git a/dao/src/main/java/org/thingsboard/server/dao/resource/TbResourceInfoDao.java b/dao/src/main/java/org/thingsboard/server/dao/resource/TbResourceInfoDao.java index 8e97738501..f4fe02843d 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/resource/TbResourceInfoDao.java +++ b/dao/src/main/java/org/thingsboard/server/dao/resource/TbResourceInfoDao.java @@ -18,6 +18,7 @@ package org.thingsboard.server.dao.resource; import org.thingsboard.server.common.data.ResourceType; import org.thingsboard.server.common.data.TbResourceInfo; import org.thingsboard.server.common.data.TbResourceInfoFilter; +import org.thingsboard.server.common.data.id.TbResourceId; import org.thingsboard.server.common.data.id.TenantId; import org.thingsboard.server.common.data.page.PageData; import org.thingsboard.server.common.data.page.PageLink; @@ -46,4 +47,5 @@ public interface TbResourceInfoDao extends Dao { TbResourceInfo findPublicResourceByKey(ResourceType resourceType, String publicResourceKey); + List findSystemOrTenantResourcesByIds(TenantId tenantId, List resourceIds); } diff --git a/dao/src/main/java/org/thingsboard/server/dao/rule/RuleChainDao.java b/dao/src/main/java/org/thingsboard/server/dao/rule/RuleChainDao.java index 5b09eec42a..ac716bb4fc 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/rule/RuleChainDao.java +++ b/dao/src/main/java/org/thingsboard/server/dao/rule/RuleChainDao.java @@ -21,8 +21,10 @@ import org.thingsboard.server.common.data.page.PageData; import org.thingsboard.server.common.data.page.PageLink; import org.thingsboard.server.common.data.rule.RuleChain; import org.thingsboard.server.common.data.rule.RuleChainType; +import org.thingsboard.server.common.data.rule.RuleNode; import org.thingsboard.server.dao.Dao; import org.thingsboard.server.dao.ExportableEntityDao; +import org.thingsboard.server.dao.ResourceContainerDao; import org.thingsboard.server.dao.TenantEntityDao; import java.util.Collection; @@ -31,7 +33,7 @@ import java.util.UUID; /** * Created by igor on 3/12/18. */ -public interface RuleChainDao extends Dao, TenantEntityDao, ExportableEntityDao { +public interface RuleChainDao extends Dao, TenantEntityDao, ExportableEntityDao, ResourceContainerDao { /** * Find rule chains by tenantId and page link. diff --git a/dao/src/main/java/org/thingsboard/server/dao/sql/dashboard/DashboardInfoRepository.java b/dao/src/main/java/org/thingsboard/server/dao/sql/dashboard/DashboardInfoRepository.java index 7624ddc738..32ac596562 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/sql/dashboard/DashboardInfoRepository.java +++ b/dao/src/main/java/org/thingsboard/server/dao/sql/dashboard/DashboardInfoRepository.java @@ -20,6 +20,7 @@ import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; +import org.thingsboard.server.common.data.EntityInfo; import org.thingsboard.server.dao.model.sql.DashboardInfoEntity; import java.util.List; @@ -87,12 +88,15 @@ public interface DashboardInfoRepository extends JpaRepository findByImageLink(@Param("imageLink") String imageLink, @Param("limit") int limit); - @Query(value = "SELECT * FROM dashboard d WHERE d.tenant_id = :tenantId and d.configuration ILIKE CONCAT('%', :link, '%') limit :limit", - nativeQuery = true) - List findDashboardInfosByTenantIdAndResourceLink(@Param("tenantId") UUID tenantId, @Param("link") String link, @Param("limit") int limit); + @Query("SELECT new org.thingsboard.server.common.data.EntityInfo(d.id, 'DASHBOARD', d.title) " + + "FROM DashboardEntity d WHERE d.tenantId = :tenantId AND ilike(cast(d.configuration as string), CONCAT('%', :link, '%')) = true") + List findDashboardInfosByTenantIdAndResourceLink(@Param("tenantId") UUID tenantId, + @Param("link") String link, + Pageable pageable); - @Query(value = "SELECT * FROM dashboard d WHERE d.configuration ILIKE CONCAT('%', :link, '%') limit :limit", - nativeQuery = true) - List findDashboardInfosByResourceLink(@Param("link") String link, @Param("limit") int limit); + @Query("SELECT new org.thingsboard.server.common.data.EntityInfo(d.id, 'DASHBOARD', d.title) " + + "FROM DashboardEntity d WHERE ilike(cast(d.configuration as string), CONCAT('%', :link, '%')) = true") + List findDashboardInfosByResourceLink(@Param("link") String link, + Pageable pageable); } diff --git a/dao/src/main/java/org/thingsboard/server/dao/sql/dashboard/JpaDashboardInfoDao.java b/dao/src/main/java/org/thingsboard/server/dao/sql/dashboard/JpaDashboardInfoDao.java index bc07139725..0e04c94a46 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/sql/dashboard/JpaDashboardInfoDao.java +++ b/dao/src/main/java/org/thingsboard/server/dao/sql/dashboard/JpaDashboardInfoDao.java @@ -17,9 +17,11 @@ package org.thingsboard.server.dao.sql.dashboard; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.data.domain.PageRequest; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.stereotype.Component; import org.thingsboard.server.common.data.DashboardInfo; +import org.thingsboard.server.common.data.EntityInfo; import org.thingsboard.server.common.data.id.TenantId; import org.thingsboard.server.common.data.page.PageData; import org.thingsboard.server.common.data.page.PageLink; @@ -135,13 +137,13 @@ public class JpaDashboardInfoDao extends JpaAbstractDao findByTenantIdAndResourceLink(TenantId tenantId, String url, int limit) { - return DaoUtil.convertDataList(dashboardInfoRepository.findDashboardInfosByTenantIdAndResourceLink(tenantId.getId(), url, limit)); + public List findByTenantIdAndResource(TenantId tenantId, String reference, int limit) { + return dashboardInfoRepository.findDashboardInfosByTenantIdAndResourceLink(tenantId.getId(), reference, PageRequest.of(0, limit)); } @Override - public List findByResourceLink(String link, int limit) { - return DaoUtil.convertDataList(dashboardInfoRepository.findDashboardInfosByResourceLink(link, limit)); + public List findByResource(String reference, int limit) { + return dashboardInfoRepository.findDashboardInfosByResourceLink(reference, PageRequest.of(0, limit)); } } diff --git a/dao/src/main/java/org/thingsboard/server/dao/sql/resource/JpaTbResourceDao.java b/dao/src/main/java/org/thingsboard/server/dao/sql/resource/JpaTbResourceDao.java index 6cce9d76c2..48e41c8553 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/sql/resource/JpaTbResourceDao.java +++ b/dao/src/main/java/org/thingsboard/server/dao/sql/resource/JpaTbResourceDao.java @@ -22,6 +22,7 @@ import org.thingsboard.server.common.data.EntityType; import org.thingsboard.server.common.data.ResourceSubType; import org.thingsboard.server.common.data.ResourceType; import org.thingsboard.server.common.data.TbResource; +import org.thingsboard.server.common.data.TbResourceDataInfo; import org.thingsboard.server.common.data.id.TbResourceId; import org.thingsboard.server.common.data.id.TenantId; import org.thingsboard.server.common.data.page.PageData; @@ -115,6 +116,11 @@ public class JpaTbResourceDao extends JpaAbstractDao findSystemOrTenantResourcesByIds(TenantId tenantId, List resourceIds) { + return DaoUtil.convertDataList(resourceInfoRepository.findSystemOrTenantResourcesByIdIn(tenantId.getId(), TenantId.NULL_UUID, toUUIDs(resourceIds))); + } } diff --git a/dao/src/main/java/org/thingsboard/server/dao/sql/resource/TbResourceInfoRepository.java b/dao/src/main/java/org/thingsboard/server/dao/sql/resource/TbResourceInfoRepository.java index 6eea20a287..97b1e56527 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/sql/resource/TbResourceInfoRepository.java +++ b/dao/src/main/java/org/thingsboard/server/dao/sql/resource/TbResourceInfoRepository.java @@ -79,4 +79,10 @@ public interface TbResourceInfoRepository extends JpaRepository findSystemOrTenantResourcesByIdIn(@Param("tenantId") UUID tenantId, + @Param("systemTenantId") UUID systemTenantId, + @Param("resourceIds") List resourceIds); + } diff --git a/dao/src/main/java/org/thingsboard/server/dao/sql/resource/TbResourceRepository.java b/dao/src/main/java/org/thingsboard/server/dao/sql/resource/TbResourceRepository.java index 1c642d2069..4aa699174f 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/sql/resource/TbResourceRepository.java +++ b/dao/src/main/java/org/thingsboard/server/dao/sql/resource/TbResourceRepository.java @@ -20,6 +20,7 @@ import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; +import org.thingsboard.server.common.data.TbResourceDataInfo; import org.thingsboard.server.dao.ExportableEntityRepository; import org.thingsboard.server.dao.model.sql.TbResourceEntity; @@ -101,4 +102,6 @@ public interface TbResourceRepository extends JpaRepository findIdsByTenantId(@Param("tenantId") UUID tenantId, Pageable pageable); + @Query("SELECT new org.thingsboard.server.common.data.TbResourceDataInfo(r.data, r.descriptor) FROM TbResourceEntity r WHERE r.id = :id") + TbResourceDataInfo getDataInfoById(UUID id); } diff --git a/dao/src/main/java/org/thingsboard/server/dao/sql/rule/JpaRuleChainDao.java b/dao/src/main/java/org/thingsboard/server/dao/sql/rule/JpaRuleChainDao.java index 77044d41dc..4a6427a7e5 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/sql/rule/JpaRuleChainDao.java +++ b/dao/src/main/java/org/thingsboard/server/dao/sql/rule/JpaRuleChainDao.java @@ -18,8 +18,10 @@ package org.thingsboard.server.dao.sql.rule; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.data.domain.Limit; +import org.springframework.data.domain.PageRequest; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.stereotype.Component; +import org.thingsboard.server.common.data.EntityInfo; import org.thingsboard.server.common.data.EntityType; import org.thingsboard.server.common.data.edqs.fields.RuleChainFields; import org.thingsboard.server.common.data.id.RuleChainId; @@ -141,6 +143,16 @@ public class JpaRuleChainDao extends JpaAbstractDao return findRuleChainsByTenantId(tenantId.getId(), pageLink); } + @Override + public List findByTenantIdAndResource(TenantId tenantId, String reference, int limit) { + return ruleChainRepository.findRuleChainsByTenantIdAndResource(tenantId.getId(), reference, PageRequest.of(0, limit)); + } + + @Override + public List findByResource(String reference, int limit) { + return ruleChainRepository.findRuleChainsByResource(reference, PageRequest.of(0, limit)); + } + @Override public List findNextBatch(UUID id, int batchSize) { return ruleChainRepository.findNextBatch(id, Limit.of(batchSize)); diff --git a/dao/src/main/java/org/thingsboard/server/dao/sql/rule/RuleChainRepository.java b/dao/src/main/java/org/thingsboard/server/dao/sql/rule/RuleChainRepository.java index cfa06caf14..4bf648cbbd 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/sql/rule/RuleChainRepository.java +++ b/dao/src/main/java/org/thingsboard/server/dao/sql/rule/RuleChainRepository.java @@ -17,10 +17,12 @@ package org.thingsboard.server.dao.sql.rule; import org.springframework.data.domain.Limit; import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; +import org.thingsboard.server.common.data.EntityInfo; import org.thingsboard.server.common.data.edqs.fields.RuleChainFields; import org.thingsboard.server.common.data.rule.RuleChainType; import org.thingsboard.server.dao.ExportableEntityRepository; @@ -72,6 +74,19 @@ public interface RuleChainRepository extends JpaRepository findRuleChainsByTenantIdAndResource(@Param("tenantId") UUID tenantId, + @Param("resourceId") String resourceId, + PageRequest of); + + @Query("SELECT new org.thingsboard.server.common.data.EntityInfo(rc.id, 'RULE_CHAIN', rc.name) " + + "FROM RuleChainEntity rc WHERE EXISTS " + + "(SELECT 1 FROM RuleNodeEntity rn WHERE rn.ruleChainId = rc.id AND cast(rn.configuration as string) LIKE CONCAT('%', :resourceId, '%'))") + List findRuleChainsByResource(@Param("resourceId") String resourceId, + Pageable pageable); + @Query("SELECT new org.thingsboard.server.common.data.edqs.fields.RuleChainFields(r.id, r.createdTime, r.tenantId," + "r.name, r.version, r.additionalInfo) FROM RuleChainEntity r WHERE r.id > :id ORDER BY r.id") List findNextBatch(@Param("id") UUID id, Limit limit); diff --git a/dao/src/main/java/org/thingsboard/server/dao/sql/widget/JpaWidgetTypeDao.java b/dao/src/main/java/org/thingsboard/server/dao/sql/widget/JpaWidgetTypeDao.java index c728f5d006..18fb544dcb 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/sql/widget/JpaWidgetTypeDao.java +++ b/dao/src/main/java/org/thingsboard/server/dao/sql/widget/JpaWidgetTypeDao.java @@ -17,8 +17,10 @@ package org.thingsboard.server.dao.sql.widget; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.data.domain.Limit; +import org.springframework.data.domain.PageRequest; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.stereotype.Component; +import org.thingsboard.server.common.data.EntityInfo; import org.thingsboard.server.common.data.EntityType; import org.thingsboard.server.common.data.edqs.fields.WidgetTypeFields; import org.thingsboard.server.common.data.id.TenantId; @@ -269,13 +271,13 @@ public class JpaWidgetTypeDao extends JpaAbstractDao findByTenantIdAndResourceLink(TenantId tenantId, String link, int limit) { - return DaoUtil.convertDataList(widgetTypeInfoRepository.findWidgetTypeInfosByTenantIdAndResourceLink(tenantId.getId(), link, limit)); + public List findByTenantIdAndResource(TenantId tenantId, String reference, int limit) { + return widgetTypeInfoRepository.findWidgetTypeInfosByTenantIdAndResourceLink(tenantId.getId(), reference, PageRequest.of(0, limit)); } @Override - public List findByResourceLink(String link, int limit) { - return DaoUtil.convertDataList(widgetTypeInfoRepository.findWidgetTypeInfosByResourceLink(link, limit)); + public List findByResource(String reference, int limit) { + return widgetTypeInfoRepository.findWidgetTypeInfosByResourceLink(reference, PageRequest.of(0, limit)); } @Override diff --git a/dao/src/main/java/org/thingsboard/server/dao/sql/widget/WidgetTypeInfoRepository.java b/dao/src/main/java/org/thingsboard/server/dao/sql/widget/WidgetTypeInfoRepository.java index dc79280bcf..b97b42a6b9 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/sql/widget/WidgetTypeInfoRepository.java +++ b/dao/src/main/java/org/thingsboard/server/dao/sql/widget/WidgetTypeInfoRepository.java @@ -20,6 +20,7 @@ import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; +import org.thingsboard.server.common.data.EntityInfo; import org.thingsboard.server.dao.model.sql.WidgetTypeInfoEntity; import java.util.List; @@ -214,10 +215,14 @@ public interface WidgetTypeInfoRepository extends JpaRepository findByImageUrl(@Param("imageLink") String imageLink, @Param("limit") int limit); - @Query(value = "SELECT * FROM widget_type_info_view w WHERE w.tenant_id = :tenantId AND w.descriptor ILIKE CONCAT('%', :link, '%') LIMIT :limit ", nativeQuery = true) - List findWidgetTypeInfosByTenantIdAndResourceLink(@Param("tenantId") UUID tenantId, @Param("link") String link, @Param("limit") int limit); - - @Query(value = "SELECT * FROM widget_type_info_view w WHERE w.descriptor ILIKE CONCAT('%', :link, '%') LIMIT :limit ", nativeQuery = true) - List findWidgetTypeInfosByResourceLink(@Param("link") String link, @Param("limit") int limit); + @Query("SELECT new org.thingsboard.server.common.data.EntityInfo(w.id, 'WIDGET_TYPE', w.name) " + + "FROM WidgetTypeEntity w WHERE w.tenantId = :tenantId AND ilike(cast(w.descriptor as string), CONCAT('%', :link, '%')) = true") + List findWidgetTypeInfosByTenantIdAndResourceLink(@Param("tenantId") UUID tenantId, + @Param("link") String link, + Pageable pageable); + @Query("SELECT new org.thingsboard.server.common.data.EntityInfo(w.id, 'WIDGET_TYPE', w.name) " + + "FROM WidgetTypeEntity w WHERE ilike(cast(w.descriptor as string), CONCAT('%', :link, '%')) = true") + List findWidgetTypeInfosByResourceLink(@Param("link") String link, + Pageable pageable); } diff --git a/dao/src/test/java/org/thingsboard/server/dao/sql/rule/JpaRuleNodeDaoTest.java b/dao/src/test/java/org/thingsboard/server/dao/sql/rule/JpaRuleNodeDaoTest.java index f5aa8a3af5..59c0db9eee 100644 --- a/dao/src/test/java/org/thingsboard/server/dao/sql/rule/JpaRuleNodeDaoTest.java +++ b/dao/src/test/java/org/thingsboard/server/dao/sql/rule/JpaRuleNodeDaoTest.java @@ -24,6 +24,7 @@ import org.springframework.beans.factory.annotation.Autowired; import org.thingsboard.common.util.JacksonUtil; import org.thingsboard.server.common.data.id.RuleChainId; import org.thingsboard.server.common.data.id.RuleNodeId; +import org.thingsboard.server.common.data.id.TbResourceId; import org.thingsboard.server.common.data.id.TenantId; import org.thingsboard.server.common.data.page.PageData; import org.thingsboard.server.common.data.page.PageLink; diff --git a/rule-engine/rule-engine-api/src/main/java/org/thingsboard/rule/engine/api/TbContext.java b/rule-engine/rule-engine-api/src/main/java/org/thingsboard/rule/engine/api/TbContext.java index d2687a1b10..920f00ed27 100644 --- a/rule-engine/rule-engine-api/src/main/java/org/thingsboard/rule/engine/api/TbContext.java +++ b/rule-engine/rule-engine-api/src/main/java/org/thingsboard/rule/engine/api/TbContext.java @@ -24,6 +24,7 @@ import org.thingsboard.server.cluster.TbClusterService; import org.thingsboard.server.common.data.Customer; import org.thingsboard.server.common.data.Device; import org.thingsboard.server.common.data.DeviceProfile; +import org.thingsboard.server.common.data.HasTenantId; import org.thingsboard.server.common.data.TenantProfile; import org.thingsboard.server.common.data.alarm.Alarm; import org.thingsboard.server.common.data.asset.Asset; @@ -32,6 +33,7 @@ import org.thingsboard.server.common.data.id.AssetId; import org.thingsboard.server.common.data.id.CustomerId; import org.thingsboard.server.common.data.id.DeviceId; import org.thingsboard.server.common.data.id.EntityId; +import org.thingsboard.server.common.data.id.HasId; import org.thingsboard.server.common.data.id.RuleChainId; import org.thingsboard.server.common.data.id.RuleNodeId; import org.thingsboard.server.common.data.id.TenantId; @@ -78,6 +80,7 @@ import org.thingsboard.server.dao.queue.QueueService; import org.thingsboard.server.dao.queue.QueueStatsService; import org.thingsboard.server.dao.relation.RelationService; import org.thingsboard.server.dao.resource.ResourceService; +import org.thingsboard.server.dao.resource.TbResourceDataCache; import org.thingsboard.server.dao.rule.RuleChainService; import org.thingsboard.server.dao.tenant.TenantService; import org.thingsboard.server.dao.timeseries.TimeseriesService; @@ -252,6 +255,8 @@ public interface TbContext { void checkTenantEntity(EntityId entityId) throws TbNodeException; + & HasTenantId, I extends EntityId> void checkTenantEntity(E entity) throws TbNodeException; + boolean isLocalEntity(EntityId entityId); RuleNodeId getSelfId(); @@ -308,6 +313,8 @@ public interface TbContext { ResourceService getResourceService(); + TbResourceDataCache getTbResourceDataCache(); + OtaPackageService getOtaPackageService(); RuleEngineDeviceProfileCache getDeviceProfileCache(); diff --git a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/ai/TbAiNode.java b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/ai/TbAiNode.java index 3497795771..bd02089204 100644 --- a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/ai/TbAiNode.java +++ b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/ai/TbAiNode.java @@ -18,12 +18,20 @@ package org.thingsboard.rule.engine.ai; import com.fasterxml.jackson.databind.JsonNode; import com.google.common.util.concurrent.FluentFuture; import com.google.common.util.concurrent.FutureCallback; +import com.google.common.util.concurrent.Futures; +import com.google.common.util.concurrent.ListenableFuture; +import com.google.common.util.concurrent.MoreExecutors; import dev.langchain4j.data.message.ChatMessage; +import dev.langchain4j.data.message.Content; +import dev.langchain4j.data.message.ImageContent; +import dev.langchain4j.data.message.PdfFileContent; import dev.langchain4j.data.message.SystemMessage; +import dev.langchain4j.data.message.TextContent; import dev.langchain4j.data.message.UserMessage; import dev.langchain4j.model.chat.request.ChatRequest; import dev.langchain4j.model.chat.request.ResponseFormat; import dev.langchain4j.model.chat.response.ChatResponse; +import lombok.extern.slf4j.Slf4j; import org.checkerframework.checker.nullness.qual.NonNull; import org.thingsboard.common.util.JacksonUtil; import org.thingsboard.rule.engine.api.RuleNode; @@ -33,24 +41,38 @@ import org.thingsboard.rule.engine.api.TbNodeConfiguration; import org.thingsboard.rule.engine.api.TbNodeException; import org.thingsboard.rule.engine.api.util.TbNodeUtils; import org.thingsboard.rule.engine.external.TbAbstractExternalNode; +import org.thingsboard.server.common.data.GeneralFileDescriptor; +import org.thingsboard.server.common.data.ResourceType; +import org.thingsboard.server.common.data.TbResourceDataInfo; +import org.thingsboard.server.common.data.TbResourceInfo; import org.thingsboard.server.common.data.ai.AiModel; import org.thingsboard.server.common.data.ai.model.AiModelType; import org.thingsboard.server.common.data.ai.model.chat.AiChatModelConfig; import org.thingsboard.server.common.data.id.AiModelId; +import org.thingsboard.server.common.data.id.TbResourceId; +import org.thingsboard.server.common.data.id.TenantId; import org.thingsboard.server.common.data.plugin.ComponentType; import org.thingsboard.server.common.data.rule.RuleChainType; import org.thingsboard.server.common.msg.TbMsg; import org.thingsboard.server.dao.exception.DataValidationException; +import org.thingsboard.server.dao.resource.TbResourceDataCache; +import java.nio.charset.StandardCharsets; import java.util.ArrayList; +import java.util.Base64; +import java.util.HashSet; import java.util.List; import java.util.NoSuchElementException; +import java.util.Objects; import java.util.Optional; +import java.util.Set; +import java.util.UUID; import static com.google.common.util.concurrent.MoreExecutors.directExecutor; import static org.thingsboard.rule.engine.ai.TbResponseFormat.TbResponseFormatType; import static org.thingsboard.server.dao.service.ConstraintValidator.validateFields; +@Slf4j @RuleNode( type = ComponentType.EXTERNAL, name = "AI request", @@ -77,6 +99,7 @@ public final class TbAiNode extends TbAbstractExternalNode implements TbNode { private String systemPrompt; private String userPrompt; + private Set resourceIds; private ResponseFormat responseFormat; private int timeoutSeconds; private AiModelId modelId; @@ -111,6 +134,14 @@ public final class TbAiNode extends TbAbstractExternalNode implements TbNode { // LangChain4j AnthropicChatModel rejects requests with non-null ResponseFormat even if ResponseFormatType is TEXT responseFormat = config.getResponseFormat().toLangChainResponseFormat(); } + if (config.getResourceIds() != null && !config.getResourceIds().isEmpty()) { + resourceIds = new HashSet<>(config.getResourceIds().size()); + for (UUID resourceId : config.getResourceIds()) { + TbResourceId tbResourceId = new TbResourceId(resourceId); + validateResource(ctx, tbResourceId); + resourceIds.add(tbResourceId); + } + } systemPrompt = config.getSystemPrompt(); userPrompt = config.getUserPrompt(); @@ -126,12 +157,42 @@ public final class TbAiNode extends TbAbstractExternalNode implements TbNode { @Override public void onMsg(TbContext ctx, TbMsg msg) { var ackedMsg = ackIfNeeded(ctx, msg); + final String processedUserPrompt = TbNodeUtils.processPattern(this.userPrompt, ackedMsg); + + final ListenableFuture userMessageFuture = + resourceIds == null + ? Futures.immediateFuture(UserMessage.from(processedUserPrompt)) + : Futures.transform( + loadResources(ctx), + resources -> UserMessage.from(buildContents(processedUserPrompt, resources)), + ctx.getDbCallbackExecutor() + ); + + Futures.addCallback( + userMessageFuture, + new FutureCallback<>() { + @Override + public void onSuccess(UserMessage userMessage) { + buildAndSendRequest(ctx, ackedMsg, userMessage); + } + @Override + public void onFailure(Throwable t) { + tellFailure(ctx, ackedMsg, t); + } + }, + MoreExecutors.directExecutor() + ); + } + + private void buildAndSendRequest(TbContext ctx, TbMsg ackedMsg, UserMessage userMessage) { List chatMessages = new ArrayList<>(2); - if (systemPrompt != null) { + + if (systemPrompt != null && !systemPrompt.isBlank()) { chatMessages.add(SystemMessage.from(TbNodeUtils.processPattern(systemPrompt, ackedMsg))); } - chatMessages.add(UserMessage.from(TbNodeUtils.processPattern(userPrompt, ackedMsg))); + + chatMessages.add(userMessage); var chatRequest = ChatRequest.builder() .messages(chatMessages) @@ -192,11 +253,67 @@ public final class TbAiNode extends TbAbstractExternalNode implements TbNode { return JacksonUtil.newObjectNode().put("response", response).toString(); } + private void validateResource(TbContext ctx, TbResourceId tbResourceId) throws TbNodeException { + TbResourceInfo resource = ctx.getResourceService().findResourceInfoById(ctx.getTenantId(), tbResourceId); + if (resource == null) { + throw new TbNodeException("[" + ctx.getTenantId() + "] Resource with ID: [" + tbResourceId + "] was not found", true); + } + if (!ResourceType.GENERAL.equals(resource.getResourceType())) { + throw new TbNodeException("[" + ctx.getTenantId() + "] Resource with ID: [" + tbResourceId + "] has unsupported resource type: " + resource.getResourceType(), true); + } + ctx.checkTenantEntity(resource); + } + + private ListenableFuture> loadResources(TbContext ctx) { + final TenantId tenantId = ctx.getTenantId(); + final TbResourceDataCache cache = ctx.getTbResourceDataCache(); + List> futures = resourceIds.stream() + .map(id -> cache.getResourceDataInfoAsync(tenantId, id)) + .toList(); + return Futures.allAsList(futures); + } + + private List buildContents(String userPrompt, List resources) { + List contents = new ArrayList<>(1 + resources.size()); + contents.add(new TextContent(userPrompt)); // user prompt first + + resources.stream() + .filter(Objects::nonNull) + .map(this::toContent) + .forEach(contents::add); + + return contents; + } + + private Content toContent(TbResourceDataInfo resource) { + if (resource.getDescriptor() == null) { + throw new RuntimeException("Missing descriptor for resource"); + } + GeneralFileDescriptor descriptor = JacksonUtil.treeToValue(resource.getDescriptor(), GeneralFileDescriptor.class); + String mediaType = descriptor.getMediaType(); + if (mediaType == null) { + throw new RuntimeException("Missing mediaType in resource descriptor " + resource.getDescriptor()); + } + byte[] data = resource.getData(); + if (mediaType.startsWith("text/")) { + return new TextContent(new String(data, StandardCharsets.UTF_8)); + } + if (mediaType.equals("application/pdf")) { + return new PdfFileContent(Base64.getEncoder().encodeToString(data), mediaType); + } + if (mediaType.startsWith("image/")) { + return new ImageContent(Base64.getEncoder().encodeToString(data), mediaType); + } + log.debug("Trying to create text content for {}", resource.getDescriptor()); + return new TextContent(new String(data, StandardCharsets.UTF_8)); + } + @Override public void destroy() { super.destroy(); systemPrompt = null; userPrompt = null; + resourceIds = null; responseFormat = null; modelId = null; } diff --git a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/ai/TbAiNodeConfiguration.java b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/ai/TbAiNodeConfiguration.java index 48392aa76e..f51983ecb1 100644 --- a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/ai/TbAiNodeConfiguration.java +++ b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/ai/TbAiNodeConfiguration.java @@ -20,12 +20,14 @@ import jakarta.validation.constraints.Max; import jakarta.validation.constraints.Min; import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.NotNull; -import jakarta.validation.constraints.Pattern; import lombok.Data; import org.thingsboard.rule.engine.api.NodeConfiguration; import org.thingsboard.server.common.data.id.AiModelId; import org.thingsboard.server.common.data.validation.Length; +import java.util.Set; +import java.util.UUID; + import static org.thingsboard.rule.engine.ai.TbResponseFormat.TbJsonResponseFormat; @Data @@ -41,6 +43,8 @@ public class TbAiNodeConfiguration implements NodeConfiguration resourceIds; + @NotNull @Valid private TbResponseFormat responseFormat; diff --git a/rule-engine/rule-engine-components/src/test/java/org/thingsboard/rule/engine/ai/TbAiNodeTest.java b/rule-engine/rule-engine-components/src/test/java/org/thingsboard/rule/engine/ai/TbAiNodeTest.java index f21aa559d4..c5b7f2c44b 100644 --- a/rule-engine/rule-engine-components/src/test/java/org/thingsboard/rule/engine/ai/TbAiNodeTest.java +++ b/rule-engine/rule-engine-components/src/test/java/org/thingsboard/rule/engine/ai/TbAiNodeTest.java @@ -17,8 +17,11 @@ package org.thingsboard.rule.engine.ai; import com.fasterxml.jackson.databind.node.ObjectNode; import com.google.common.util.concurrent.FluentFuture; +import com.google.common.util.concurrent.Futures; import dev.langchain4j.data.message.AiMessage; +import dev.langchain4j.data.message.ImageContent; import dev.langchain4j.data.message.SystemMessage; +import dev.langchain4j.data.message.TextContent; import dev.langchain4j.data.message.UserMessage; import dev.langchain4j.model.chat.request.ResponseFormat; import dev.langchain4j.model.chat.request.ResponseFormatType; @@ -32,6 +35,7 @@ import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.Arguments; import org.junit.jupiter.params.provider.MethodSource; import org.junit.jupiter.params.provider.ValueSource; +import org.mockito.ArgumentCaptor; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; import org.thingsboard.common.util.JacksonUtil; @@ -43,6 +47,10 @@ import org.thingsboard.rule.engine.api.RuleEngineAiChatModelService; import org.thingsboard.rule.engine.api.TbContext; import org.thingsboard.rule.engine.api.TbNodeConfiguration; import org.thingsboard.rule.engine.api.TbNodeException; +import org.thingsboard.server.common.data.GeneralFileDescriptor; +import org.thingsboard.server.common.data.ResourceType; +import org.thingsboard.server.common.data.TbResource; +import org.thingsboard.server.common.data.TbResourceDataInfo; import org.thingsboard.server.common.data.ai.AiModel; import org.thingsboard.server.common.data.ai.model.AiModelConfig; import org.thingsboard.server.common.data.ai.model.chat.AnthropicChatModelConfig; @@ -52,6 +60,7 @@ import org.thingsboard.server.common.data.ai.provider.OpenAiProviderConfig; import org.thingsboard.server.common.data.id.AiModelId; import org.thingsboard.server.common.data.id.DeviceId; import org.thingsboard.server.common.data.id.RuleNodeId; +import org.thingsboard.server.common.data.id.TbResourceId; import org.thingsboard.server.common.data.id.TenantId; import org.thingsboard.server.common.data.msg.TbNodeConnectionType; import org.thingsboard.server.common.data.rule.RuleNode; @@ -59,9 +68,14 @@ import org.thingsboard.server.common.msg.TbMsg; import org.thingsboard.server.common.msg.TbMsgMetaData; import org.thingsboard.server.dao.ai.AiModelService; import org.thingsboard.server.dao.exception.DataValidationException; +import org.thingsboard.server.dao.resource.ResourceService; +import org.thingsboard.server.dao.resource.TbResourceDataCache; +import java.util.Base64; +import java.util.List; import java.util.Map; import java.util.Optional; +import java.util.Set; import java.util.UUID; import java.util.stream.Stream; @@ -76,16 +90,23 @@ import static org.mockito.BDDMockito.given; import static org.mockito.BDDMockito.then; import static org.mockito.Mockito.lenient; import static org.mockito.Mockito.never; +import static org.thingsboard.server.common.data.ResourceType.GENERAL; @ExtendWith(MockitoExtension.class) class TbAiNodeTest { + private static final byte[] PNG_IMAGE = Base64.getDecoder().decode("iVBORw0KGgoAAAANSUhEUgAAAMgAAACgCAMAAAB+IdObAAAC9FBMVEUAAAABAQEBAgICAgICAwMCBAQDAwMDBQUDBgYEBAQEBwcECAgFCQkFCgoGBgYGCwsGDAwHBwcHDQ0HDg4ICAgIDw8IEBAJCQkJEREKEhIKExMLFBQLFRUMFhYMFxcNDQ0NGBgNGRkODg4OGhoOGxsPDw8PHBwPHR0QEBAQHh4QHx8RERERICARISESEhISIiITExMTIyMTJCQUJSUUJiYVKCgWFhYWKSkXFxcXGhwYGBgYLC0ZGRkaMDEaMTIbGxsbMjMcMzQdNTYfOTogICAgOzwiP0AiQEEjIyMjQkMkQ0QnJycnSEkoS0wpKSkrUFErUVIsLCwvV1gvWFkwWlszMzMzYGE1NTU2NjY3Zmc4aWo5OTk5ams5a2w6Ojo6bG07bm88cXI9cnM9c3Q/dndAQEBAeHlBeXpCQkJCe3xCfH1DQ0NEREREf4FFRUVFgIJGg4VHhYdISEhIhohJh4lLi41LjI5MTExMjpBNj5FNkJJOkpRQUFBQlZdRUVFSUlJTU1NTmpxUVFRUnZ9VVVVVnqBWVlZYpadZWVlZp6laqKpbW1tbqatbqqxcXFxcrK5dra9drrBeXl5er7FfsbNfsrRgs7VhYWFiYmJiuLpjubtku71lvL5lvb9mvsBnwcNowsRpxMZpxcdra2tryctsysxubm5uzc9vb29vz9Fw0dNx0tVy1Ndy1dhz1tlz19p0dHR02Nt02dx12t1229523N93d3d33eB33uF5eXl54eR6enp64+Z65Od75eh75ul8fHx85+p86Ot96ex96u2AgICA7vGA7/KB8fSC8/aD9PeD9fiEhISE9/qF+PuF+fyGhoaG+v2G+/6Hh4eH/P+IiIiMjIyNjY2Ojo6QkJCRkZGSkpKTk5Obm5ucnJyfn5+lpaWnp6eoqKipqamqqqqwsLCzs7O1tbW4uLi5ubm6urq7u7u8vLy/v7/BwcHCwsLFxcXGxsbPz8/Y2Nji4uLj4+Pv7+/4+Pj5+fn+/v7/75T///+GLm1tAAAAAWJLR0T7omo23AAABJtJREFUeNrt3Wd8E3UYB/CH0oqm1dJaS5N0IKu0qQSVinXG4gKlKFi3uMC9FVwoVQnQqCBgBVxFnKCoFFFExFGhliWt/zoYLuIMKEpB7b3xuf9dQu+MvAjXcsTf7/PJk/ul1/S+TS53r3KkNFfk0V6evDHbFGruQ3EQTzNVUFxkHOXFB6QbIQiCIAiC/GeSs/QkR6vkCPeUaNUeSUjkkdR1npCp6a7VV7U6P1dbKfNFrS89rJNas/T6rlZtkUS/i2evhw99Q92y9/r7nVzzw7VfeDX3y2qv893plTVb1uW+uw6xiyNpspAQ8bjLy8l5REiImOlUq3Pniunyxw8Ib+vqF7aB5AgdItLVmit0iOgc9W0owhDt1RSAABL3EGeDDqmXhwRXgw6pj3qESFhtgHC1DYSGrJCQjweFq4SEqzkD67zGah8Inay+p1yl4XqKWt2lF69UDxQrzzevXZprrDn2gfTIUs85Iv/oHpny8HKHdugeVZhpXNudu6u6J1P8lmpIX1ys10X6myVfPeLl919UZFi74JXjWtfCecfa5sj+odx908XSg9Taqdaw+3I1QuYLA6RG2AbiEDpE9JJnvcYP1BRhgiw3QuoAASTuIQnP6JCF8hQlcbYBwrWIKgPDIg9UGSGP2QdCnZ+QkDneKQs4swqe1CDJ09RaXfBUETWKm3a+gFMMEMc0+0AoJVX9nM1+VDsCznLurz64b5VWq7nWLLi81QfygYZfNlU7nAUP0nOwrLnGiiAIgiAIgiAIgiDI/zstLS3tMEtKSiycgAACCCCAAAIIIIAAAggggAACCCCAAAIIIIAAAggggAACCCCAAAIIIIAAAggggAACCCCAAAIIIIBYAkEQBEEQBEEQBEEQBGmrdLwuyLmhg703km8Z63k7N2Tw0jnqFt/f0bROn69WBYOfbuxiyR+8MXC9vB8QCBTQkEAgMOG2gVyvDmTzdAWuifFp077m8f503vwZr/PSd28Hg+uaTjVDlOFEIxVrINVijfwi4glCHE1XioXPz6kX9xHNFIUkvyM/xqeduIPHup95bGni8edYotOUqJCrrII0iMv4LnNFg4Sczd/9/Zw4abchD0Ygv0pIBVFZG0Nq587lu/PE02EIXSQuaSfI92l88bfNFkHqLxUnEM1+bXQEMloMY8hgn893esyQIzbzWHtveXn51GW89AtfTeyATWZIWm919s6wBtLYdfXdVCyuuEdCHhoxwr/mAzdDtMQKoaP4duQmRVG+kUtyu83X3OuylX09f+9r0c6eOvkjx82fdPdLiHrdjsrD1Z39LP5W06ExQ475g8eqSR6PZ+oXvLSVNWk/nmmGKNcSXaBYBXEPFkMXV1GlhFyYlSof3t19ZOxfPJp+4/HTeh47JhGdqLQxJDtpyRJxBgUi+0g7QkYSlVsHoVtFrcNiyO0SsoXHDxIykej4v/8F+XxDKLRxmXWQfo2jyGJIh894PDs9FArNeIGXvlwbCn37Upl5rXObOMPtf1K4z5u8ne/sx0tl6hbfgtNkBEGQPZs4uUBwTxoTH5DxtM0TD46+20lpHrfXX7e52/jtyj9kFKbIT2L3FQAAAABJRU5ErkJggg=="); + @Mock TbContext ctxMock; @Mock AiModelService aiModelServiceMock; @Mock RuleEngineAiChatModelService aiChatModelServiceMock; + @Mock + TbResourceDataCache tbResourceDataCacheMock; + @Mock + ResourceService resourceServiceMock; TbAiNode aiNode; TbAiNodeConfiguration config; @@ -141,6 +162,8 @@ class TbAiNodeTest { lenient().when(ctxMock.getAiModelService()).thenReturn(aiModelServiceMock); lenient().when(ctxMock.getAiChatModelService()).thenReturn(aiChatModelServiceMock); lenient().when(ctxMock.getDbCallbackExecutor()).thenReturn(new TestDbCallbackExecutor()); + lenient().when(ctxMock.getTbResourceDataCache()).thenReturn(tbResourceDataCacheMock); + lenient().when(ctxMock.getResourceService()).thenReturn(resourceServiceMock); } @Test @@ -158,6 +181,7 @@ class TbAiNodeTest { assertThat(config.getResponseFormat()).isEqualTo(new TbJsonResponseFormat()); assertThat(config.getTimeoutSeconds()).isEqualTo(60); assertThat(config.isForceAck()).isTrue(); + assertThat(config.getResourceIds()).isNull(); } /* -- Node initialization tests -- */ @@ -373,6 +397,36 @@ class TbAiNodeTest { .matches(e -> ((TbNodeException) e).isUnrecoverable()); } + @Test + void givenNotExistingResources_whenInit_thenThrowsException() { + // GIVEN + config = constructValidConfig(); + UUID resourceId = UUID.randomUUID(); + config.setResourceIds(Set.of(resourceId)); + + // WHEN-THEN + assertThatThrownBy(() -> aiNode.init(ctxMock, new TbNodeConfiguration(JacksonUtil.valueToTree(config)))) + .isInstanceOf(TbNodeException.class) + .hasMessageContaining("[" + tenantId + "] Resource with ID: [" + resourceId + "] was not found"); + } + + @Test + void givenResourceOfWrongType_whenInit_thenThrowsException() { + // GIVEN + config = constructValidConfig(); + UUID resourceId = UUID.randomUUID(); + config.setResourceIds(Set.of(resourceId)); + + // WHEN-THEN + TbResource tbResource = new TbResource(); + tbResource.setResourceType(ResourceType.DASHBOARD); + given(resourceServiceMock.findResourceInfoById(any(), any())).willReturn(tbResource); + + assertThatThrownBy(() -> aiNode.init(ctxMock, new TbNodeConfiguration(JacksonUtil.valueToTree(config)))) + .isInstanceOf(TbNodeException.class) + .hasMessageContaining("[" + tenantId + "] Resource with ID: [" + resourceId + "] has unsupported resource type: " + ResourceType.DASHBOARD); + } + /* -- Message processing tests -- */ @Test @@ -560,6 +614,166 @@ class TbAiNodeTest { ); } + @Test + void givenSystemPromptAndUserPromptAndResourcesConfigured_whenOnMsg_thenRequestContainsSystemAndUserAndResourceContent() throws TbNodeException { + String systemPrompt = "Respond with valid JSON"; + String userPrompt = "Tell me a joke"; + String textData = "Text resource content for AI request."; + String xmlData = ""; + + // GIVEN + config = constructValidConfig(); + config.setSystemPrompt(systemPrompt); + config.setUserPrompt(userPrompt); + UUID resourceId = UUID.randomUUID(); + UUID resourceId2 = UUID.randomUUID(); + UUID resourceId3 = UUID.randomUUID(); + + config.setResourceIds(Set.of(resourceId, resourceId2, resourceId3)); + + // WHEN-THEN + TbResource textResource = buildGeneralResource(textData.getBytes(), "text/plain"); + TbResource xmlResource = buildGeneralResource(xmlData.getBytes(), "application/xml"); + TbResource imageResource = buildGeneralResource(PNG_IMAGE, "image/png"); + + given(resourceServiceMock.findResourceInfoById(any(), eq(new TbResourceId(resourceId)))).willReturn(textResource); + given(resourceServiceMock.findResourceInfoById(any(), eq(new TbResourceId(resourceId2)))).willReturn(xmlResource); + given(resourceServiceMock.findResourceInfoById(any(), eq(new TbResourceId(resourceId3)))).willReturn(imageResource); + + given(tbResourceDataCacheMock.getResourceDataInfoAsync(any(), eq(new TbResourceId(resourceId)))).willReturn(FluentFuture.from(Futures.immediateFuture(textResource.toResourceDataInfo()))); + given(tbResourceDataCacheMock.getResourceDataInfoAsync(any(), eq(new TbResourceId(resourceId2)))).willReturn(FluentFuture.from(Futures.immediateFuture(xmlResource.toResourceDataInfo()))); + given(tbResourceDataCacheMock.getResourceDataInfoAsync(any(), eq(new TbResourceId(resourceId3)))).willReturn(FluentFuture.from(Futures.immediateFuture(imageResource.toResourceDataInfo()))); + + aiNode.init(ctxMock, new TbNodeConfiguration(JacksonUtil.valueToTree(config))); + + var msg = TbMsg.newMsg() + .originator(deviceId) + .data(TbMsg.EMPTY_JSON_OBJECT) + .metaData(TbMsgMetaData.EMPTY) + .build(); + + var chatResponse = ChatResponse.builder() + .aiMessage(AiMessage.from("{\"type\":\"joke\",\"setup\":\"Why did the scarecrow win an award?\",\"punchline\":\"Because he was outstanding in his field.\"}")) + .build(); + + given(aiChatModelServiceMock.sendChatRequestAsync(any(), any())).willReturn(FluentFuture.from(immediateFuture(chatResponse))); + + // WHEN + aiNode.onMsg(ctxMock, msg); + + // THEN + then(aiChatModelServiceMock).should().sendChatRequestAsync(any(), + argThat(actualChatRequest -> { + assertThat(actualChatRequest.messages()).hasSize(2); + assertThat(actualChatRequest.messages().get(0)).isEqualTo(SystemMessage.from(systemPrompt)); + assertThat(((UserMessage)actualChatRequest.messages().get(1)).contents()) + .containsAll(List.of(new TextContent(userPrompt), new TextContent(textData), + new TextContent(xmlData), new ImageContent(Base64.getEncoder().encodeToString(PNG_IMAGE), "image/png"))); + return true; + }) + ); + } + + @Test + void givenNullResource_whenOnMsg_thenRequestContainsSystemAndUserPrompt() throws TbNodeException { + // GIVEN + config = constructValidConfig(); + UUID resourceId = UUID.randomUUID(); + config.setResourceIds(Set.of(resourceId)); + + // WHEN-THEN + TbResource tbResource = buildGeneralResource("Text resource content for AI request.".getBytes(), "text/plain"); + + given(resourceServiceMock.findResourceInfoById(any(), eq(new TbResourceId(resourceId)))).willReturn(tbResource); + given(tbResourceDataCacheMock.getResourceDataInfoAsync(any(), eq(new TbResourceId(resourceId)))).willReturn(FluentFuture.from(Futures.immediateFuture(null))); + + aiNode.init(ctxMock, new TbNodeConfiguration(JacksonUtil.valueToTree(config))); + + var msg = TbMsg.newMsg() + .originator(deviceId) + .data(TbMsg.EMPTY_JSON_OBJECT) + .metaData(TbMsgMetaData.EMPTY) + .build(); + + // WHEN + aiNode.onMsg(ctxMock, msg); + + // THEN + then(aiChatModelServiceMock).should().sendChatRequestAsync(any(), + argThat(actualChatRequest -> { + assertThat(actualChatRequest.messages()).hasSize(2); + assertThat(actualChatRequest.messages().get(0)).isEqualTo(SystemMessage.from(config.getSystemPrompt())); + assertThat(((UserMessage)actualChatRequest.messages().get(1)).contents()) + .containsAll(List.of(new TextContent(config.getUserPrompt()))); + return true; + }) + ); + } + + @Test + void givenResourceWithNoDescriptor_whenOnMsg_thenEnqueueForTellFailure() throws TbNodeException { + // GIVEN + config = constructValidConfig(); + UUID resourceId = UUID.randomUUID(); + config.setResourceIds(Set.of(resourceId)); + + // WHEN-THEN + TbResource tbResource = buildGeneralResource("Text resource content for AI request.".getBytes(), "text/plain"); + TbResourceDataInfo resourceDataInfo = new TbResourceDataInfo(tbResource.getData(), null); + + given(resourceServiceMock.findResourceInfoById(any(), eq(new TbResourceId(resourceId)))).willReturn(tbResource); + given(tbResourceDataCacheMock.getResourceDataInfoAsync(any(), eq(new TbResourceId(resourceId)))).willReturn(FluentFuture.from(Futures.immediateFuture(resourceDataInfo))); + + aiNode.init(ctxMock, new TbNodeConfiguration(JacksonUtil.valueToTree(config))); + + var msg = TbMsg.newMsg() + .originator(deviceId) + .data(TbMsg.EMPTY_JSON_OBJECT) + .metaData(TbMsgMetaData.EMPTY) + .build(); + + // WHEN + aiNode.onMsg(ctxMock, msg); + + // THEN + var exceptionCaptor = ArgumentCaptor.forClass(Throwable.class); + then(ctxMock).should().enqueueForTellFailure(any(), exceptionCaptor.capture()); + Throwable actualException = exceptionCaptor.getValue(); + assertThat(actualException.getMessage()).isEqualTo("Missing descriptor for resource"); + } + + @Test + void givenResourceWithNoMediaType_whenOnMsg_thenEnqueueForTellFailure() throws TbNodeException { + // GIVEN + config = constructValidConfig(); + UUID resourceId = UUID.randomUUID(); + config.setResourceIds(Set.of(resourceId)); + + // WHEN-THEN + TbResource tbResource = buildGeneralResource("Text resource content for AI request.".getBytes(), "text/plain"); + TbResourceDataInfo resourceDataInfo = new TbResourceDataInfo(tbResource.getData(), JacksonUtil.newObjectNode()); + + given(resourceServiceMock.findResourceInfoById(any(), eq(new TbResourceId(resourceId)))).willReturn(tbResource); + given(tbResourceDataCacheMock.getResourceDataInfoAsync(any(), eq(new TbResourceId(resourceId)))).willReturn(FluentFuture.from(Futures.immediateFuture(resourceDataInfo))); + + aiNode.init(ctxMock, new TbNodeConfiguration(JacksonUtil.valueToTree(config))); + + var msg = TbMsg.newMsg() + .originator(deviceId) + .data(TbMsg.EMPTY_JSON_OBJECT) + .metaData(TbMsgMetaData.EMPTY) + .build(); + + // WHEN + aiNode.onMsg(ctxMock, msg); + + // THEN + var exceptionCaptor = ArgumentCaptor.forClass(Throwable.class); + then(ctxMock).should().enqueueForTellFailure(any(), exceptionCaptor.capture()); + Throwable actualException = exceptionCaptor.getValue(); + assertThat(actualException.getMessage()).isEqualTo("Missing mediaType in resource descriptor {}"); + } + @Test void givenTemplatedPrompts_whenOnMsg_thenRequestContainsSubstitutedMessages() throws TbNodeException { // GIVEN @@ -950,4 +1164,13 @@ class TbAiNodeTest { then(ctxMock).should(never()).tellFailure(any(), any()); } + private TbResource buildGeneralResource(byte[] data, String mediaType) { + TbResource tbResource = new TbResource(); + tbResource.setResourceType(GENERAL); + GeneralFileDescriptor descriptor = new GeneralFileDescriptor(mediaType); + tbResource.setDescriptorValue(descriptor); + tbResource.setData(data); + return tbResource; + } + } From e477dd17b641488546d0303c06b495fa45067c7c Mon Sep 17 00:00:00 2001 From: Volodymyr Babak Date: Fri, 26 Sep 2025 13:34:58 +0300 Subject: [PATCH 026/102] Improved Edge session cleanup --- .../service/edge/rpc/EdgeGrpcService.java | 30 +++++++++++-------- 1 file changed, 18 insertions(+), 12 deletions(-) diff --git a/application/src/main/java/org/thingsboard/server/service/edge/rpc/EdgeGrpcService.java b/application/src/main/java/org/thingsboard/server/service/edge/rpc/EdgeGrpcService.java index 7340696788..ec3f839cb3 100644 --- a/application/src/main/java/org/thingsboard/server/service/edge/rpc/EdgeGrpcService.java +++ b/application/src/main/java/org/thingsboard/server/service/edge/rpc/EdgeGrpcService.java @@ -93,14 +93,13 @@ import static org.thingsboard.server.service.state.DefaultDeviceStateService.LAS @TbCoreComponent public class EdgeGrpcService extends EdgeRpcServiceGrpc.EdgeRpcServiceImplBase implements EdgeRpcService { - private static final int DESTROY_SESSION_MAX_ATTEMPTS = 10; - private final ConcurrentMap sessions = new ConcurrentHashMap<>(); private final ConcurrentMap sessionNewEventsLocks = new ConcurrentHashMap<>(); private final Map sessionNewEvents = new HashMap<>(); private final ConcurrentMap> sessionEdgeEventChecks = new ConcurrentHashMap<>(); private final ConcurrentMap> localSyncEdgeRequests = new ConcurrentHashMap<>(); private final ConcurrentMap edgeEventsMigrationProcessed = new ConcurrentHashMap<>(); + private final List zombieSessions = new ArrayList<>(); @Value("${edges.rpc.port}") private int rpcPort; @@ -193,7 +192,7 @@ public class EdgeGrpcService extends EdgeRpcServiceGrpc.EdgeRpcServiceImplBase i this.edgeEventProcessingExecutorService = ThingsBoardExecutors.newScheduledThreadPool(schedulerPoolSize, "edge-event-check-scheduler"); this.sendDownlinkExecutorService = ThingsBoardExecutors.newScheduledThreadPool(sendSchedulerPoolSize, "edge-send-scheduler"); this.executorService = ThingsBoardExecutors.newSingleThreadScheduledExecutor("edge-service"); - this.executorService.scheduleAtFixedRate(this::destroyKafkaSessionIfDisconnectedAndConsumerActive, 60, 60, TimeUnit.SECONDS); + this.executorService.scheduleAtFixedRate(this::cleanupZombieSessions, 60, 60, TimeUnit.SECONDS); log.info("Edge RPC service initialized!"); } @@ -518,14 +517,10 @@ public class EdgeGrpcService extends EdgeRpcServiceGrpc.EdgeRpcServiceImplBase i private void destroySession(EdgeGrpcSession session) { try (session) { - for (int i = 0; i < DESTROY_SESSION_MAX_ATTEMPTS; i++) { - if (session.destroy()) { - break; - } else { - try { - Thread.sleep(100); - } catch (InterruptedException ignored) {} - } + if (!session.destroy()) { + log.warn("[{}][{}] Session destroy failed for edge [{}] with session id [{}]. Adding to zombie queue for later cleanup.", + session.getTenantId(), session.getEdge().getId(), session.getEdge().getName(), session.getSessionId()); + zombieSessions.add(session); } } } @@ -634,7 +629,7 @@ public class EdgeGrpcService extends EdgeRpcServiceGrpc.EdgeRpcServiceImplBase i } } - private void destroyKafkaSessionIfDisconnectedAndConsumerActive() { + private void cleanupZombieSessions() { try { List toRemove = new ArrayList<>(); for (EdgeGrpcSession session : sessions.values()) { @@ -655,6 +650,17 @@ public class EdgeGrpcService extends EdgeRpcServiceGrpc.EdgeRpcServiceImplBase i } } } + zombieSessions.removeIf(zombie -> { + if (zombie.destroy()) { + log.info("[{}][{}] Successfully cleaned up zombie session [{}] for edge [{}].", + zombie.getTenantId(), zombie.getEdge().getId(), zombie.getSessionId(), zombie.getEdge().getName()); + return true; + } else { + log.warn("[{}][{}] Failed to remove zombie session [{}] for edge [{}].", + zombie.getTenantId(), zombie.getEdge().getId(), zombie.getSessionId(), zombie.getEdge().getName()); + return false; + } + }); } catch (Exception e) { log.warn("Failed to cleanup kafka sessions", e); } From 41f0a9702e57f8e1ba7ecc641c6f6b5fafe7b088 Mon Sep 17 00:00:00 2001 From: Dmytro Skarzhynets Date: Mon, 15 Sep 2025 17:32:57 +0300 Subject: [PATCH 027/102] AI models: add support for Ollama --- application/pom.xml | 4 ++ .../Langchain4jChatModelConfigurerImpl.java | 16 ++++++ .../common/data/ai/dto/TbChatResponse.java | 6 +- .../common/data/ai/model/AiModelConfig.java | 8 ++- .../data/ai/model/chat/AiChatModelConfig.java | 2 +- .../chat/Langchain4jChatModelConfigurer.java | 2 + .../ai/model/chat/OllamaChatModelConfig.java | 57 +++++++++++++++++++ .../common/data/ai/provider/AiProvider.java | 3 +- .../data/ai/provider/AiProviderConfig.java | 2 +- .../ai/provider/OllamaProviderConfig.java | 22 +++++++ .../rule/engine/ai/TbResponseFormat.java | 8 +-- .../ai-model/ai-model-dialog.component.html | 9 +++ .../ai-model/ai-model-dialog.component.ts | 1 + .../src/app/shared/models/ai-model.models.ts | 21 +++++-- .../assets/locale/locale.constant-en_US.json | 5 +- 15 files changed, 146 insertions(+), 20 deletions(-) create mode 100644 common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/OllamaChatModelConfig.java create mode 100644 common/data/src/main/java/org/thingsboard/server/common/data/ai/provider/OllamaProviderConfig.java diff --git a/application/pom.xml b/application/pom.xml index 33bc0972d4..0413f7732c 100644 --- a/application/pom.xml +++ b/application/pom.xml @@ -419,6 +419,10 @@ + + dev.langchain4j + langchain4j-ollama + diff --git a/application/src/main/java/org/thingsboard/server/service/ai/Langchain4jChatModelConfigurerImpl.java b/application/src/main/java/org/thingsboard/server/service/ai/Langchain4jChatModelConfigurerImpl.java index 69dd98f47f..7008e866f4 100644 --- a/application/src/main/java/org/thingsboard/server/service/ai/Langchain4jChatModelConfigurerImpl.java +++ b/application/src/main/java/org/thingsboard/server/service/ai/Langchain4jChatModelConfigurerImpl.java @@ -32,6 +32,7 @@ import dev.langchain4j.model.chat.request.ChatRequestParameters; import dev.langchain4j.model.github.GitHubModelsChatModel; import dev.langchain4j.model.googleai.GoogleAiGeminiChatModel; import dev.langchain4j.model.mistralai.MistralAiChatModel; +import dev.langchain4j.model.ollama.OllamaChatModel; import dev.langchain4j.model.openai.OpenAiChatModel; import dev.langchain4j.model.vertexai.gemini.VertexAiGeminiChatModel; import org.springframework.stereotype.Component; @@ -43,6 +44,7 @@ import org.thingsboard.server.common.data.ai.model.chat.GoogleAiGeminiChatModelC import org.thingsboard.server.common.data.ai.model.chat.GoogleVertexAiGeminiChatModelConfig; import org.thingsboard.server.common.data.ai.model.chat.Langchain4jChatModelConfigurer; import org.thingsboard.server.common.data.ai.model.chat.MistralAiChatModelConfig; +import org.thingsboard.server.common.data.ai.model.chat.OllamaChatModelConfig; import org.thingsboard.server.common.data.ai.model.chat.OpenAiChatModelConfig; import org.thingsboard.server.common.data.ai.provider.AmazonBedrockProviderConfig; import org.thingsboard.server.common.data.ai.provider.AzureOpenAiProviderConfig; @@ -262,6 +264,20 @@ class Langchain4jChatModelConfigurerImpl implements Langchain4jChatModelConfigur .build(); } + @Override + public ChatModel configureChatModel(OllamaChatModelConfig chatModelConfig) { + return OllamaChatModel.builder() + .baseUrl(chatModelConfig.providerConfig().baseUrl()) + .modelName(chatModelConfig.modelId()) + .temperature(chatModelConfig.temperature()) + .topP(chatModelConfig.topP()) + .topK(chatModelConfig.topK()) + .numPredict(chatModelConfig.maxOutputTokens()) + .timeout(toDuration(chatModelConfig.timeoutSeconds())) + .maxRetries(chatModelConfig.maxRetries()) + .build(); + } + private static Duration toDuration(Integer timeoutSeconds) { return timeoutSeconds != null ? Duration.ofSeconds(timeoutSeconds) : null; } diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/ai/dto/TbChatResponse.java b/common/data/src/main/java/org/thingsboard/server/common/data/ai/dto/TbChatResponse.java index 2cc17e4553..73e6557fb5 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/ai/dto/TbChatResponse.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/ai/dto/TbChatResponse.java @@ -22,7 +22,7 @@ import io.swagger.v3.oas.annotations.media.Schema; @JsonTypeInfo( use = JsonTypeInfo.Id.NAME, property = "status", - include = JsonTypeInfo.As.PROPERTY, + include = JsonTypeInfo.As.EXISTING_PROPERTY, visible = true ) @JsonSubTypes({ @@ -51,9 +51,7 @@ public sealed interface TbChatResponse permits TbChatResponse.Success, TbChatRes } record Failure( - @Schema( - description = "A string containing details about the failure" - ) + @Schema(description = "A string containing details about the failure") String errorDetails ) implements TbChatResponse { diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/AiModelConfig.java b/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/AiModelConfig.java index bfaa29a6e3..f18429e7cf 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/AiModelConfig.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/AiModelConfig.java @@ -24,6 +24,7 @@ import org.thingsboard.server.common.data.ai.model.chat.GitHubModelsChatModelCon import org.thingsboard.server.common.data.ai.model.chat.GoogleAiGeminiChatModelConfig; import org.thingsboard.server.common.data.ai.model.chat.GoogleVertexAiGeminiChatModelConfig; import org.thingsboard.server.common.data.ai.model.chat.MistralAiChatModelConfig; +import org.thingsboard.server.common.data.ai.model.chat.OllamaChatModelConfig; import org.thingsboard.server.common.data.ai.model.chat.OpenAiChatModelConfig; import org.thingsboard.server.common.data.ai.provider.AiProvider; import org.thingsboard.server.common.data.ai.provider.AiProviderConfig; @@ -34,6 +35,7 @@ import org.thingsboard.server.common.data.ai.provider.GitHubModelsProviderConfig import org.thingsboard.server.common.data.ai.provider.GoogleAiGeminiProviderConfig; import org.thingsboard.server.common.data.ai.provider.GoogleVertexAiGeminiProviderConfig; import org.thingsboard.server.common.data.ai.provider.MistralAiProviderConfig; +import org.thingsboard.server.common.data.ai.provider.OllamaProviderConfig; import org.thingsboard.server.common.data.ai.provider.OpenAiProviderConfig; @JsonTypeInfo( @@ -50,7 +52,8 @@ import org.thingsboard.server.common.data.ai.provider.OpenAiProviderConfig; @JsonSubTypes.Type(value = MistralAiChatModelConfig.class, name = "MISTRAL_AI"), @JsonSubTypes.Type(value = AnthropicChatModelConfig.class, name = "ANTHROPIC"), @JsonSubTypes.Type(value = AmazonBedrockChatModelConfig.class, name = "AMAZON_BEDROCK"), - @JsonSubTypes.Type(value = GitHubModelsChatModelConfig.class, name = "GITHUB_MODELS") + @JsonSubTypes.Type(value = GitHubModelsChatModelConfig.class, name = "GITHUB_MODELS"), + @JsonSubTypes.Type(value = OllamaChatModelConfig.class, name = "OLLAMA") }) public interface AiModelConfig { @@ -69,7 +72,8 @@ public interface AiModelConfig { @JsonSubTypes.Type(value = MistralAiProviderConfig.class, name = "MISTRAL_AI"), @JsonSubTypes.Type(value = AnthropicProviderConfig.class, name = "ANTHROPIC"), @JsonSubTypes.Type(value = AmazonBedrockProviderConfig.class, name = "AMAZON_BEDROCK"), - @JsonSubTypes.Type(value = GitHubModelsProviderConfig.class, name = "GITHUB_MODELS") + @JsonSubTypes.Type(value = GitHubModelsProviderConfig.class, name = "GITHUB_MODELS"), + @JsonSubTypes.Type(value = OllamaProviderConfig.class, name = "OLLAMA") }) AiProviderConfig providerConfig(); diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/AiChatModelConfig.java b/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/AiChatModelConfig.java index 2bc28cfce0..49126c1861 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/AiChatModelConfig.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/AiChatModelConfig.java @@ -24,7 +24,7 @@ public sealed interface AiChatModelConfig> extend permits OpenAiChatModelConfig, AzureOpenAiChatModelConfig, GoogleAiGeminiChatModelConfig, GoogleVertexAiGeminiChatModelConfig, MistralAiChatModelConfig, AnthropicChatModelConfig, - AmazonBedrockChatModelConfig, GitHubModelsChatModelConfig { + AmazonBedrockChatModelConfig, GitHubModelsChatModelConfig, OllamaChatModelConfig { ChatModel configure(Langchain4jChatModelConfigurer configurer); diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/Langchain4jChatModelConfigurer.java b/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/Langchain4jChatModelConfigurer.java index c9c1bc3173..828256dcdc 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/Langchain4jChatModelConfigurer.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/Langchain4jChatModelConfigurer.java @@ -35,4 +35,6 @@ public interface Langchain4jChatModelConfigurer { ChatModel configureChatModel(GitHubModelsChatModelConfig chatModelConfig); + ChatModel configureChatModel(OllamaChatModelConfig chatModelConfig); + } diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/OllamaChatModelConfig.java b/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/OllamaChatModelConfig.java new file mode 100644 index 0000000000..360b514d6d --- /dev/null +++ b/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/OllamaChatModelConfig.java @@ -0,0 +1,57 @@ +/** + * 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.common.data.ai.model.chat; + +import dev.langchain4j.model.chat.ChatModel; +import jakarta.validation.Valid; +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Positive; +import jakarta.validation.constraints.PositiveOrZero; +import lombok.Builder; +import lombok.With; +import org.thingsboard.server.common.data.ai.provider.AiProvider; +import org.thingsboard.server.common.data.ai.provider.OllamaProviderConfig; + +@Builder +public record OllamaChatModelConfig( + @NotNull @Valid OllamaProviderConfig providerConfig, + @NotBlank String modelId, + @PositiveOrZero Double temperature, + @Positive @Max(1) Double topP, + @PositiveOrZero Integer topK, + @Positive Integer maxOutputTokens, + @With @Positive Integer timeoutSeconds, + @With @PositiveOrZero Integer maxRetries +) implements AiChatModelConfig { + + @Override + public AiProvider provider() { + return AiProvider.OLLAMA; + } + + @Override + public ChatModel configure(Langchain4jChatModelConfigurer configurer) { + return configurer.configureChatModel(this); + } + + @Override + public boolean supportsJsonMode() { + return true; + } + +} diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/ai/provider/AiProvider.java b/common/data/src/main/java/org/thingsboard/server/common/data/ai/provider/AiProvider.java index d0a5bd0510..a9a6af4de8 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/ai/provider/AiProvider.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/ai/provider/AiProvider.java @@ -24,6 +24,7 @@ public enum AiProvider { MISTRAL_AI, ANTHROPIC, AMAZON_BEDROCK, - GITHUB_MODELS + GITHUB_MODELS, + OLLAMA } diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/ai/provider/AiProviderConfig.java b/common/data/src/main/java/org/thingsboard/server/common/data/ai/provider/AiProviderConfig.java index bd32c88efb..5423b24410 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/ai/provider/AiProviderConfig.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/ai/provider/AiProviderConfig.java @@ -19,4 +19,4 @@ public sealed interface AiProviderConfig permits OpenAiProviderConfig, AzureOpenAiProviderConfig, GoogleAiGeminiProviderConfig, GoogleVertexAiGeminiProviderConfig, MistralAiProviderConfig, AnthropicProviderConfig, - AmazonBedrockProviderConfig, GitHubModelsProviderConfig {} + AmazonBedrockProviderConfig, GitHubModelsProviderConfig, OllamaProviderConfig {} diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/ai/provider/OllamaProviderConfig.java b/common/data/src/main/java/org/thingsboard/server/common/data/ai/provider/OllamaProviderConfig.java new file mode 100644 index 0000000000..fc0a2d6fd8 --- /dev/null +++ b/common/data/src/main/java/org/thingsboard/server/common/data/ai/provider/OllamaProviderConfig.java @@ -0,0 +1,22 @@ +/** + * 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.common.data.ai.provider; + +import jakarta.validation.constraints.NotBlank; + +public record OllamaProviderConfig( + @NotBlank String baseUrl +) implements AiProviderConfig {} diff --git a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/ai/TbResponseFormat.java b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/ai/TbResponseFormat.java index 5c891a9c74..5107e613a4 100644 --- a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/ai/TbResponseFormat.java +++ b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/ai/TbResponseFormat.java @@ -60,9 +60,7 @@ public sealed interface TbResponseFormat permits TbTextResponseFormat, TbJsonRes @Override public ResponseFormat toLangChainResponseFormat() { - return ResponseFormat.builder() - .type(ResponseFormatType.TEXT) - .build(); + return ResponseFormat.TEXT; } } @@ -76,9 +74,7 @@ public sealed interface TbResponseFormat permits TbTextResponseFormat, TbJsonRes @Override public ResponseFormat toLangChainResponseFormat() { - return ResponseFormat.builder() - .type(ResponseFormatType.JSON) - .build(); + return ResponseFormat.JSON; } } diff --git a/ui-ngx/src/app/modules/home/components/ai-model/ai-model-dialog.component.html b/ui-ngx/src/app/modules/home/components/ai-model/ai-model-dialog.component.html index 5f9c189399..1a3c2bf181 100644 --- a/ui-ngx/src/app/modules/home/components/ai-model/ai-model-dialog.component.html +++ b/ui-ngx/src/app/modules/home/components/ai-model/ai-model-dialog.component.html @@ -150,6 +150,15 @@ } + @if (providerFieldsList.includes('baseUrl')) { + + ai-models.baseurl + + + {{ 'ai-models.baseurl-required' | translate }} + + + }
diff --git a/ui-ngx/src/app/modules/home/components/ai-model/ai-model-dialog.component.ts b/ui-ngx/src/app/modules/home/components/ai-model/ai-model-dialog.component.ts index c459d66f12..e6490cd84d 100644 --- a/ui-ngx/src/app/modules/home/components/ai-model/ai-model-dialog.component.ts +++ b/ui-ngx/src/app/modules/home/components/ai-model/ai-model-dialog.component.ts @@ -100,6 +100,7 @@ export class AIModelDialogComponent extends DialogComponent, 'label'>, HasTenantId region?: string; accessKeyId?: string; secretAccessKey?: string; + baseUrl?: string; }; modelId: string; temperature?: number; @@ -57,7 +58,8 @@ export enum AiProvider { MISTRAL_AI = 'MISTRAL_AI', ANTHROPIC = 'ANTHROPIC', AMAZON_BEDROCK = 'AMAZON_BEDROCK', - GITHUB_MODELS = 'GITHUB_MODELS' + GITHUB_MODELS = 'GITHUB_MODELS', + OLLAMA = 'OLLAMA' } export const AiProviderTranslations = new Map( @@ -69,7 +71,8 @@ export const AiProviderTranslations = new Map( [AiProvider.MISTRAL_AI , 'ai-models.ai-providers.mistral-ai'], [AiProvider.ANTHROPIC , 'ai-models.ai-providers.anthropic'], [AiProvider.AMAZON_BEDROCK , 'ai-models.ai-providers.amazon-bedrock'], - [AiProvider.GITHUB_MODELS , 'ai-models.ai-providers.github-models'] + [AiProvider.GITHUB_MODELS , 'ai-models.ai-providers.github-models'], + [AiProvider.OLLAMA , 'ai-models.ai-providers.ollama'] ] ); @@ -84,7 +87,8 @@ export const ProviderFieldsAllList = [ 'serviceVersion', 'region', 'accessKeyId', - 'secretAccessKey' + 'secretAccessKey', + 'baseUrl' ]; export const ModelFieldsAllList = ['temperature', 'topP', 'topK', 'frequencyPenalty', 'presencePenalty', 'maxOutputTokens']; @@ -191,6 +195,14 @@ export const AiModelMap = new Map Date: Tue, 16 Sep 2025 14:41:38 +0300 Subject: [PATCH 028/102] AI models: add context length support for Ollama --- .../Langchain4jChatModelConfigurerImpl.java | 1 + .../chat/AmazonBedrockChatModelConfig.java | 2 +- .../model/chat/AnthropicChatModelConfig.java | 2 +- .../chat/AzureOpenAiChatModelConfig.java | 2 +- .../chat/GitHubModelsChatModelConfig.java | 2 +- .../chat/GoogleAiGeminiChatModelConfig.java | 2 +- .../GoogleVertexAiGeminiChatModelConfig.java | 2 +- .../model/chat/MistralAiChatModelConfig.java | 2 +- .../ai/model/chat/OllamaChatModelConfig.java | 3 ++- .../ai/model/chat/OpenAiChatModelConfig.java | 2 +- .../ai-model/ai-model-dialog.component.html | 23 +++++++++++-------- .../ai-model/ai-model-dialog.component.ts | 3 ++- .../src/app/shared/models/ai-model.models.ts | 5 ++-- .../assets/locale/locale.constant-en_US.json | 3 ++- 14 files changed, 31 insertions(+), 23 deletions(-) diff --git a/application/src/main/java/org/thingsboard/server/service/ai/Langchain4jChatModelConfigurerImpl.java b/application/src/main/java/org/thingsboard/server/service/ai/Langchain4jChatModelConfigurerImpl.java index 7008e866f4..84b09b9188 100644 --- a/application/src/main/java/org/thingsboard/server/service/ai/Langchain4jChatModelConfigurerImpl.java +++ b/application/src/main/java/org/thingsboard/server/service/ai/Langchain4jChatModelConfigurerImpl.java @@ -272,6 +272,7 @@ class Langchain4jChatModelConfigurerImpl implements Langchain4jChatModelConfigur .temperature(chatModelConfig.temperature()) .topP(chatModelConfig.topP()) .topK(chatModelConfig.topK()) + .numCtx(chatModelConfig.contextLength()) .numPredict(chatModelConfig.maxOutputTokens()) .timeout(toDuration(chatModelConfig.timeoutSeconds())) .maxRetries(chatModelConfig.maxRetries()) diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/AmazonBedrockChatModelConfig.java b/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/AmazonBedrockChatModelConfig.java index 2bb4de5aa8..d2ab72086a 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/AmazonBedrockChatModelConfig.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/AmazonBedrockChatModelConfig.java @@ -33,7 +33,7 @@ public record AmazonBedrockChatModelConfig( @NotBlank String modelId, @PositiveOrZero Double temperature, @Positive @Max(1) Double topP, - @Positive Integer maxOutputTokens, + Integer maxOutputTokens, @With @Positive Integer timeoutSeconds, @With @PositiveOrZero Integer maxRetries ) implements AiChatModelConfig { diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/AnthropicChatModelConfig.java b/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/AnthropicChatModelConfig.java index 69b5578fb3..6d505f75a6 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/AnthropicChatModelConfig.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/AnthropicChatModelConfig.java @@ -34,7 +34,7 @@ public record AnthropicChatModelConfig( @PositiveOrZero Double temperature, @Positive @Max(1) Double topP, @PositiveOrZero Integer topK, - @Positive Integer maxOutputTokens, + Integer maxOutputTokens, @With @Positive Integer timeoutSeconds, @With @PositiveOrZero Integer maxRetries ) implements AiChatModelConfig { diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/AzureOpenAiChatModelConfig.java b/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/AzureOpenAiChatModelConfig.java index 47e7e96c37..f70f2af539 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/AzureOpenAiChatModelConfig.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/AzureOpenAiChatModelConfig.java @@ -35,7 +35,7 @@ public record AzureOpenAiChatModelConfig( @Positive @Max(1) Double topP, Double frequencyPenalty, Double presencePenalty, - @Positive Integer maxOutputTokens, + Integer maxOutputTokens, @With @Positive Integer timeoutSeconds, @With @PositiveOrZero Integer maxRetries ) implements AiChatModelConfig { diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/GitHubModelsChatModelConfig.java b/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/GitHubModelsChatModelConfig.java index b509254f77..0aafd72197 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/GitHubModelsChatModelConfig.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/GitHubModelsChatModelConfig.java @@ -35,7 +35,7 @@ public record GitHubModelsChatModelConfig( @Positive @Max(1) Double topP, Double frequencyPenalty, Double presencePenalty, - @Positive Integer maxOutputTokens, + Integer maxOutputTokens, @With @Positive Integer timeoutSeconds, @With @PositiveOrZero Integer maxRetries ) implements AiChatModelConfig { diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/GoogleAiGeminiChatModelConfig.java b/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/GoogleAiGeminiChatModelConfig.java index fe11a11460..b5c3d4263d 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/GoogleAiGeminiChatModelConfig.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/GoogleAiGeminiChatModelConfig.java @@ -36,7 +36,7 @@ public record GoogleAiGeminiChatModelConfig( @PositiveOrZero Integer topK, Double frequencyPenalty, Double presencePenalty, - @Positive Integer maxOutputTokens, + Integer maxOutputTokens, @With @Positive Integer timeoutSeconds, @With @PositiveOrZero Integer maxRetries ) implements AiChatModelConfig { diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/GoogleVertexAiGeminiChatModelConfig.java b/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/GoogleVertexAiGeminiChatModelConfig.java index 609e14f86e..944963ee27 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/GoogleVertexAiGeminiChatModelConfig.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/GoogleVertexAiGeminiChatModelConfig.java @@ -36,7 +36,7 @@ public record GoogleVertexAiGeminiChatModelConfig( @PositiveOrZero Integer topK, Double frequencyPenalty, Double presencePenalty, - @Positive Integer maxOutputTokens, + Integer maxOutputTokens, @With @Positive Integer timeoutSeconds, @With @PositiveOrZero Integer maxRetries ) implements AiChatModelConfig { diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/MistralAiChatModelConfig.java b/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/MistralAiChatModelConfig.java index f603e99c53..8f67d93398 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/MistralAiChatModelConfig.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/MistralAiChatModelConfig.java @@ -35,7 +35,7 @@ public record MistralAiChatModelConfig( @Positive @Max(1) Double topP, Double frequencyPenalty, Double presencePenalty, - @Positive Integer maxOutputTokens, + Integer maxOutputTokens, @With @Positive Integer timeoutSeconds, @With @PositiveOrZero Integer maxRetries ) implements AiChatModelConfig { diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/OllamaChatModelConfig.java b/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/OllamaChatModelConfig.java index 360b514d6d..ea48670b63 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/OllamaChatModelConfig.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/OllamaChatModelConfig.java @@ -34,7 +34,8 @@ public record OllamaChatModelConfig( @PositiveOrZero Double temperature, @Positive @Max(1) Double topP, @PositiveOrZero Integer topK, - @Positive Integer maxOutputTokens, + Integer contextLength, + Integer maxOutputTokens, @With @Positive Integer timeoutSeconds, @With @PositiveOrZero Integer maxRetries ) implements AiChatModelConfig { diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/OpenAiChatModelConfig.java b/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/OpenAiChatModelConfig.java index 00b5115d7d..23db9accc2 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/OpenAiChatModelConfig.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/OpenAiChatModelConfig.java @@ -35,7 +35,7 @@ public record OpenAiChatModelConfig( @Positive @Max(1) Double topP, Double frequencyPenalty, Double presencePenalty, - @Positive Integer maxOutputTokens, + Integer maxOutputTokens, @With @Positive Integer timeoutSeconds, @With @PositiveOrZero Integer maxRetries ) implements AiChatModelConfig { diff --git a/ui-ngx/src/app/modules/home/components/ai-model/ai-model-dialog.component.html b/ui-ngx/src/app/modules/home/components/ai-model/ai-model-dialog.component.html index 1a3c2bf181..c730850474 100644 --- a/ui-ngx/src/app/modules/home/components/ai-model/ai-model-dialog.component.html +++ b/ui-ngx/src/app/modules/home/components/ai-model/ai-model-dialog.component.html @@ -151,7 +151,7 @@ } @if (providerFieldsList.includes('baseUrl')) { - + ai-models.baseurl @@ -264,15 +264,18 @@
- - warning - + type="number" step="1" placeholder="{{ 'ai-models.set' | translate }}"> + + + } + @if (modelFieldsList.includes('contextLength')) { +
+
+ {{ 'ai-models.context-length' | translate }} +
+ +
} diff --git a/ui-ngx/src/app/modules/home/components/ai-model/ai-model-dialog.component.ts b/ui-ngx/src/app/modules/home/components/ai-model/ai-model-dialog.component.ts index e6490cd84d..3294c6ac76 100644 --- a/ui-ngx/src/app/modules/home/components/ai-model/ai-model-dialog.component.ts +++ b/ui-ngx/src/app/modules/home/components/ai-model/ai-model-dialog.component.ts @@ -108,7 +108,8 @@ export class AIModelDialogComponent extends DialogComponent, 'label'>, HasTenantId frequencyPenalty?: number; presencePenalty?: number; maxOutputTokens?: number; + contextLength?: number; } } @@ -91,7 +92,7 @@ export const ProviderFieldsAllList = [ 'baseUrl' ]; -export const ModelFieldsAllList = ['temperature', 'topP', 'topK', 'frequencyPenalty', 'presencePenalty', 'maxOutputTokens']; +export const ModelFieldsAllList = ['temperature', 'topP', 'topK', 'frequencyPenalty', 'presencePenalty', 'maxOutputTokens', 'contextLength']; export const AiModelMap = new Map([ [ @@ -200,7 +201,7 @@ export const AiModelMap = new Map Date: Mon, 22 Sep 2025 12:18:06 +0300 Subject: [PATCH 029/102] AI models: add auth support for Ollama --- .../Langchain4jChatModelConfigurerImpl.java | 28 +++++++++++++--- .../ai/provider/OllamaProviderConfig.java | 32 +++++++++++++++++-- 2 files changed, 53 insertions(+), 7 deletions(-) diff --git a/application/src/main/java/org/thingsboard/server/service/ai/Langchain4jChatModelConfigurerImpl.java b/application/src/main/java/org/thingsboard/server/service/ai/Langchain4jChatModelConfigurerImpl.java index 84b09b9188..2cb6c2097f 100644 --- a/application/src/main/java/org/thingsboard/server/service/ai/Langchain4jChatModelConfigurerImpl.java +++ b/application/src/main/java/org/thingsboard/server/service/ai/Langchain4jChatModelConfigurerImpl.java @@ -35,6 +35,7 @@ import dev.langchain4j.model.mistralai.MistralAiChatModel; import dev.langchain4j.model.ollama.OllamaChatModel; import dev.langchain4j.model.openai.OpenAiChatModel; import dev.langchain4j.model.vertexai.gemini.VertexAiGeminiChatModel; +import org.springframework.http.HttpHeaders; import org.springframework.stereotype.Component; import org.thingsboard.server.common.data.ai.model.chat.AmazonBedrockChatModelConfig; import org.thingsboard.server.common.data.ai.model.chat.AnthropicChatModelConfig; @@ -49,6 +50,7 @@ import org.thingsboard.server.common.data.ai.model.chat.OpenAiChatModelConfig; import org.thingsboard.server.common.data.ai.provider.AmazonBedrockProviderConfig; import org.thingsboard.server.common.data.ai.provider.AzureOpenAiProviderConfig; import org.thingsboard.server.common.data.ai.provider.GoogleVertexAiGeminiProviderConfig; +import org.thingsboard.server.common.data.ai.provider.OllamaProviderConfig; import software.amazon.awssdk.auth.credentials.AwsBasicCredentials; import software.amazon.awssdk.auth.credentials.StaticCredentialsProvider; import software.amazon.awssdk.regions.Region; @@ -56,7 +58,11 @@ import software.amazon.awssdk.services.bedrockruntime.BedrockRuntimeClient; import java.io.ByteArrayInputStream; import java.io.IOException; +import java.nio.charset.StandardCharsets; import java.time.Duration; +import java.util.Base64; + +import static java.util.Collections.singletonMap; @Component class Langchain4jChatModelConfigurerImpl implements Langchain4jChatModelConfigurer { @@ -136,7 +142,7 @@ class Langchain4jChatModelConfigurerImpl implements Langchain4jChatModelConfigur // set request timeout from model config if (chatModelConfig.timeoutSeconds() != null) { - retrySettings.setTotalTimeout(org.threeten.bp.Duration.ofSeconds(chatModelConfig.timeoutSeconds())); + retrySettings.setTotalTimeoutDuration(Duration.ofSeconds(chatModelConfig.timeoutSeconds())); } // set updated retry settings @@ -266,7 +272,7 @@ class Langchain4jChatModelConfigurerImpl implements Langchain4jChatModelConfigur @Override public ChatModel configureChatModel(OllamaChatModelConfig chatModelConfig) { - return OllamaChatModel.builder() + var builder = OllamaChatModel.builder() .baseUrl(chatModelConfig.providerConfig().baseUrl()) .modelName(chatModelConfig.modelId()) .temperature(chatModelConfig.temperature()) @@ -275,8 +281,22 @@ class Langchain4jChatModelConfigurerImpl implements Langchain4jChatModelConfigur .numCtx(chatModelConfig.contextLength()) .numPredict(chatModelConfig.maxOutputTokens()) .timeout(toDuration(chatModelConfig.timeoutSeconds())) - .maxRetries(chatModelConfig.maxRetries()) - .build(); + .maxRetries(chatModelConfig.maxRetries()); + + var auth = chatModelConfig.providerConfig().auth(); + if (auth instanceof OllamaProviderConfig.OllamaAuth.Basic basicAuth) { + String credentials = basicAuth.username() + ":" + basicAuth.password(); + String encodedCredentials = Base64.getEncoder().encodeToString(credentials.getBytes(StandardCharsets.UTF_8)); + builder.customHeaders(singletonMap(HttpHeaders.AUTHORIZATION, "Basic " + encodedCredentials)); + } else if (auth instanceof OllamaProviderConfig.OllamaAuth.Token tokenAuth) { + builder.customHeaders(singletonMap(HttpHeaders.AUTHORIZATION, "Bearer " + tokenAuth.token())); + } else if (auth instanceof OllamaProviderConfig.OllamaAuth.None) { + // do nothing + } else { + throw new UnsupportedOperationException("Unknown authentication type: " + auth.getClass().getSimpleName()); + } + + return builder.build(); } private static Duration toDuration(Integer timeoutSeconds) { diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/ai/provider/OllamaProviderConfig.java b/common/data/src/main/java/org/thingsboard/server/common/data/ai/provider/OllamaProviderConfig.java index fc0a2d6fd8..39bb57834c 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/ai/provider/OllamaProviderConfig.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/ai/provider/OllamaProviderConfig.java @@ -15,8 +15,34 @@ */ package org.thingsboard.server.common.data.ai.provider; -import jakarta.validation.constraints.NotBlank; +import com.fasterxml.jackson.annotation.JsonSubTypes; +import com.fasterxml.jackson.annotation.JsonTypeInfo; +import jakarta.validation.Valid; +import jakarta.validation.constraints.NotNull; public record OllamaProviderConfig( - @NotBlank String baseUrl -) implements AiProviderConfig {} + @NotNull String baseUrl, + @NotNull @Valid OllamaAuth auth +) implements AiProviderConfig { + + @JsonTypeInfo( + use = JsonTypeInfo.Id.NAME, + include = JsonTypeInfo.As.PROPERTY, + property = "type" + ) + @JsonSubTypes({ + @JsonSubTypes.Type(value = OllamaAuth.None.class, name = "NONE"), + @JsonSubTypes.Type(value = OllamaAuth.Basic.class, name = "BASIC"), + @JsonSubTypes.Type(value = OllamaAuth.Token.class, name = "TOKEN") + }) + public sealed interface OllamaAuth { + + record None() implements OllamaAuth {} + + record Basic(@NotNull String username, @NotNull String password) implements OllamaAuth {} + + record Token(@NotNull String token) implements OllamaAuth {} + + } + +} From 329534df61f80eee17e6536cec449dd08028a8ea Mon Sep 17 00:00:00 2001 From: ArtemDzhereleiko Date: Thu, 25 Sep 2025 15:44:50 +0300 Subject: [PATCH 030/102] UI: Add authentication for Ollama model --- .../ai-model/ai-model-dialog.component.html | 87 +++++++++++++++---- .../ai-model/ai-model-dialog.component.ts | 67 +++++++++++--- .../src/app/shared/models/ai-model.models.ts | 11 +++ .../assets/locale/locale.constant-en_US.json | 16 +++- 4 files changed, 153 insertions(+), 28 deletions(-) diff --git a/ui-ngx/src/app/modules/home/components/ai-model/ai-model-dialog.component.html b/ui-ngx/src/app/modules/home/components/ai-model/ai-model-dialog.component.html index c730850474..abfe8500b4 100644 --- a/ui-ngx/src/app/modules/home/components/ai-model/ai-model-dialog.component.html +++ b/ui-ngx/src/app/modules/home/components/ai-model/ai-model-dialog.component.html @@ -55,31 +55,34 @@
-
+
@if (providerFieldsList.includes('personalAccessToken')) { ai-models.personal-access-token - + {{ 'ai-models.personal-access-token-required' | translate }} } @if (providerFieldsList.includes('projectId')) { - + ai-models.project-id - + {{ 'ai-models.project-id-required' | translate }} } @if (providerFieldsList.includes('location')) { - + ai-models.location - + {{ 'ai-models.location-required' | translate }} @@ -98,16 +101,17 @@ } @if (providerFieldsList.includes('endpoint')) { - + ai-models.endpoint - + {{ 'ai-models.endpoint-required' | translate }} } @if (providerFieldsList.includes('serviceVersion')) { - + ai-models.service-version @@ -117,25 +121,28 @@ ai-models.api-key - + {{ 'ai-models.api-key-required' | translate }} } @if (providerFieldsList.includes('region')) { - + ai-models.region - + {{ 'ai-models.region-required' | translate }} } @if (providerFieldsList.includes('accessKeyId')) { - + ai-models.access-key-id - + {{ 'ai-models.access-key-id-required' | translate }} @@ -145,7 +152,8 @@ ai-models.secret-access-key - + {{ 'ai-models.secret-access-key-required' | translate }} @@ -154,11 +162,58 @@ ai-models.baseurl - + {{ 'ai-models.baseurl-required' | translate }} } + @if (provider === aiProvider.OLLAMA) { +
+
+
+ {{ 'ai-models.authentication' | translate }} +
+ + {{ 'ai-models.authentication-type.none' | translate }} + {{ 'ai-models.authentication-type.basic' | translate }} + {{ 'ai-models.authentication-type.token' | translate }} + +
+
+ @if (aiModelForms.get('configuration.providerConfig.auth.type').value === AuthenticationType.BASIC) { + + ai-models.username + + + {{ 'ai-models.username-required' | translate }} + + + + ai-models.password + + + + {{ 'ai-models.password-required' | translate }} + + + } + @if (aiModelForms.get('configuration.providerConfig.auth.type').value === AuthenticationType.TOKEN) { + + ai-models.token + + + + {{ 'ai-models.token-required' | translate }} + + + } +
+
+ }
diff --git a/ui-ngx/src/app/modules/home/components/ai-model/ai-model-dialog.component.ts b/ui-ngx/src/app/modules/home/components/ai-model/ai-model-dialog.component.ts index 3294c6ac76..9d0d28e627 100644 --- a/ui-ngx/src/app/modules/home/components/ai-model/ai-model-dialog.component.ts +++ b/ui-ngx/src/app/modules/home/components/ai-model/ai-model-dialog.component.ts @@ -30,6 +30,7 @@ import { AiModelMap, AiProvider, AiProviderTranslations, + AuthenticationType, ModelType, ProviderFieldsAllList } from '@shared/models/ai-model.models'; @@ -37,6 +38,7 @@ import { AiModelService } from '@core/http/ai-model.service'; import { CheckConnectivityDialogComponent } from '@home/components/ai-model/check-connectivity-dialog.component'; import { map } from 'rxjs/operators'; import { deepTrim } from '@core/utils'; +import { TranslateService } from '@ngx-translate/core'; export interface AIModelDialogData { AIModel?: AiModel; @@ -62,18 +64,23 @@ export class AIModelDialogComponent extends DialogComponent, protected router: Router, protected dialogRef: MatDialogRef, @Inject(MAT_DIALOG_DATA) public data: AIModelDialogData, private fb: FormBuilder, private aiModelService: AiModelService, + private translate: TranslateService, private dialog: MatDialog) { super(store, router, dialogRef); @@ -89,18 +96,24 @@ export class AIModelDialogComponent extends DialogComponent { + this.getAuthenticationHint(type); + this.aiModelForms.get('configuration.providerConfig.auth.username').disable(); + this.aiModelForms.get('configuration.providerConfig.auth.password').disable(); + this.aiModelForms.get('configuration.providerConfig.auth.token').disable(); + if (type === AuthenticationType.BASIC) { + this.aiModelForms.get('configuration.providerConfig.auth.username').enable(); + this.aiModelForms.get('configuration.providerConfig.auth.password').enable(); + } + if (type === AuthenticationType.TOKEN) { + this.aiModelForms.get('configuration.providerConfig.auth.token').enable(); + } + }); this.updateValidation(this.provider); } @@ -132,6 +161,16 @@ export class AIModelDialogComponent extends DialogComponent { if (AiModelMap.get(provider).providerFieldsList.includes(key)) { @@ -139,7 +178,13 @@ export class AIModelDialogComponent extends DialogComponent, 'label'>, HasTenantId accessKeyId?: string; secretAccessKey?: string; baseUrl?: string; + auth?: { + type: AuthenticationType; + username?: string; + password?: string; + token?: string + } }; modelId: string; temperature?: number; @@ -242,3 +248,8 @@ export interface CheckConnectivityResult { status: string; errorDetails: string; } +export enum AuthenticationType { + NONE = 'NONE', + BASIC = 'BASIC', + TOKEN = 'TOKEN' +} 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 5e527ee1db..67c5d99f24 100644 --- a/ui-ngx/src/assets/locale/locale.constant-en_US.json +++ b/ui-ngx/src/assets/locale/locale.constant-en_US.json @@ -1169,7 +1169,21 @@ "check-connectivity-failed": "Test request failed", "no-model-matching": "No models matching '{{entity}}' were found.", "model-required": "Model is required.", - "no-model-text": "No models found." + "no-model-text": "No models found.", + "authentication": "Authentication", + "authentication-basic-hint": "Uses standard HTTP Basic authentication. The username and password will be combined, Base64-encoded, and sent in an \"Authorization\" header with each request to the Ollama server.", + "authentication-token-hint": "Uses Bearer token authentication. The provided token will be sent directly in an \"Authorization\" eader with each request to the Ollama server.", + "authentication-type": { + "none": "None", + "basic": "Basic", + "token": "Token" + }, + "username": "Username", + "username-required": "Username is required.", + "password": "Password", + "password-required": "Password is required.", + "token": "Token", + "token-required": "Token is required." }, "confirm-on-exit": { "message": "You have unsaved changes. Are you sure you want to leave this page?", From c3d40a61e879200291ea30a5b13b943b294c484b Mon Sep 17 00:00:00 2001 From: ArtemDzhereleiko Date: Tue, 2 Sep 2025 14:30:09 +0300 Subject: [PATCH 031/102] UI: Add new resource type text --- ui-ngx/src/app/core/http/entity.service.ts | 8 +- ui-ngx/src/app/core/http/resource.service.ts | 8 +- .../home/components/home-components.module.ts | 6 + .../resources/resources-dialog.component.html | 54 +++++++++ .../resources/resources-dialog.component.scss | 24 ++++ .../resources/resources-dialog.component.ts | 113 ++++++++++++++++++ .../resources-library.component.html | 2 +- .../resources}/resources-library.component.ts | 18 ++- .../external/ai-config.component.html | 10 ++ .../rule-node/external/ai-config.component.ts | 24 ++++ .../modules/home/pages/admin/admin.module.ts | 2 - .../resources-library-table-config.resolve.ts | 2 +- .../resources-table-header.component.ts | 2 +- .../entity/entity-list.component.html | 11 ++ .../entity/entity-list.component.ts | 29 ++++- .../src/app/shared/models/resource.models.ts | 12 +- .../assets/locale/locale.constant-en_US.json | 6 +- 17 files changed, 310 insertions(+), 21 deletions(-) create mode 100644 ui-ngx/src/app/modules/home/components/resources/resources-dialog.component.html create mode 100644 ui-ngx/src/app/modules/home/components/resources/resources-dialog.component.scss create mode 100644 ui-ngx/src/app/modules/home/components/resources/resources-dialog.component.ts rename ui-ngx/src/app/modules/home/{pages/admin/resource => components/resources}/resources-library.component.html (98%) rename ui-ngx/src/app/modules/home/{pages/admin/resource => components/resources}/resources-library.component.ts (89%) diff --git a/ui-ngx/src/app/core/http/entity.service.ts b/ui-ngx/src/app/core/http/entity.service.ts index 572d6cc473..53a55bfe7f 100644 --- a/ui-ngx/src/app/core/http/entity.service.ts +++ b/ui-ngx/src/app/core/http/entity.service.ts @@ -100,6 +100,7 @@ import { OAuth2Service } from '@core/http/oauth2.service'; import { MobileAppService } from '@core/http/mobile-app.service'; import { PlatformType } from '@shared/models/oauth2.models'; import { AiModelService } from '@core/http/ai-model.service'; +import { ResourceType } from "@shared/models/resource.models"; @Injectable({ providedIn: 'root' @@ -297,6 +298,11 @@ export class EntityService { (id) => this.ruleChainService.getRuleChain(id, config), entityIds); break; + case EntityType.TB_RESOURCE: + observable = this.getEntitiesByIdsObservable( + (id) => this.resourceService.getResource(id, config), + entityIds); + break; } return observable; } @@ -472,7 +478,7 @@ export class EntityService { break; case EntityType.TB_RESOURCE: pageLink.sortOrder.property = 'title'; - entitiesObservable = this.resourceService.getTenantResources(pageLink, config); + entitiesObservable = this.resourceService.getTenantResources(pageLink, subType as ResourceType, config); break; case EntityType.QUEUE_STATS: pageLink.sortOrder.property = 'createdTime'; diff --git a/ui-ngx/src/app/core/http/resource.service.ts b/ui-ngx/src/app/core/http/resource.service.ts index 615b721b97..52c92d96e4 100644 --- a/ui-ngx/src/app/core/http/resource.service.ts +++ b/ui-ngx/src/app/core/http/resource.service.ts @@ -47,8 +47,12 @@ export class ResourceService { return this.http.get>(url, defaultHttpOptionsFromConfig(config)); } - public getTenantResources(pageLink: PageLink, config?: RequestConfig): Observable> { - return this.http.get>(`/api/resource/tenant${pageLink.toQuery()}`, defaultHttpOptionsFromConfig(config)); + public getTenantResources(pageLink: PageLink, resourceType?: ResourceType, config?: RequestConfig): Observable> { + let url = `/api/resource${pageLink.toQuery()}`; + if (isNotEmptyStr(resourceType)) { + url += `&resourceType=${resourceType}`; + } + return this.http.get>(url, defaultHttpOptionsFromConfig(config)); } public getResource(resourceId: string, config?: RequestConfig): Observable { diff --git a/ui-ngx/src/app/modules/home/components/home-components.module.ts b/ui-ngx/src/app/modules/home/components/home-components.module.ts index 31a3066edf..060f804cdf 100644 --- a/ui-ngx/src/app/modules/home/components/home-components.module.ts +++ b/ui-ngx/src/app/modules/home/components/home-components.module.ts @@ -205,6 +205,8 @@ import { } from '@home/components/calculated-fields/components/test-arguments/calculated-field-test-arguments.component'; import { CheckConnectivityDialogComponent } from '@home/components/ai-model/check-connectivity-dialog.component'; import { AIModelDialogComponent } from '@home/components/ai-model/ai-model-dialog.component'; +import { ResourcesDialogComponent } from "@home/components/resources/resources-dialog.component"; +import { ResourcesLibraryComponent } from "@home/components/resources/resources-library.component"; @NgModule({ declarations: @@ -358,6 +360,8 @@ import { AIModelDialogComponent } from '@home/components/ai-model/ai-model-dialo CalculatedFieldTestArgumentsComponent, CheckConnectivityDialogComponent, AIModelDialogComponent, + ResourcesDialogComponent, + ResourcesLibraryComponent, ], imports: [ CommonModule, @@ -505,6 +509,8 @@ import { AIModelDialogComponent } from '@home/components/ai-model/ai-model-dialo CalculatedFieldTestArgumentsComponent, CheckConnectivityDialogComponent, AIModelDialogComponent, + ResourcesDialogComponent, + ResourcesLibraryComponent, ], providers: [ WidgetComponentService, diff --git a/ui-ngx/src/app/modules/home/components/resources/resources-dialog.component.html b/ui-ngx/src/app/modules/home/components/resources/resources-dialog.component.html new file mode 100644 index 0000000000..c6813ff0f2 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/resources/resources-dialog.component.html @@ -0,0 +1,54 @@ + +
+ +

{{ 'resource.add' | translate }}

+ + +
+ + +
+
+ + +
+
+ + +
+
diff --git a/ui-ngx/src/app/modules/home/components/resources/resources-dialog.component.scss b/ui-ngx/src/app/modules/home/components/resources/resources-dialog.component.scss new file mode 100644 index 0000000000..b32c3933c5 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/resources/resources-dialog.component.scss @@ -0,0 +1,24 @@ +/** + * 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. + */ + +:host ::ng-deep { + .mat-mdc-dialog-content { + display: flex; + flex-direction: column; + height: 100%; + padding: 0 !important; + } +} diff --git a/ui-ngx/src/app/modules/home/components/resources/resources-dialog.component.ts b/ui-ngx/src/app/modules/home/components/resources/resources-dialog.component.ts new file mode 100644 index 0000000000..a06c72827a --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/resources/resources-dialog.component.ts @@ -0,0 +1,113 @@ +/// +/// 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. +/// + +import { AfterViewInit, Component, Inject, SkipSelf, ViewChild } from '@angular/core'; +import { DialogComponent } from '@shared/components/dialog.component'; +import { Store } from '@ngrx/store'; +import { AppState } from '@core/core.state'; +import { Router } from '@angular/router'; +import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog'; +import { FormGroupDirective, NgForm, UntypedFormControl } from '@angular/forms'; +import { EntityType } from '@shared/models/entity-type.models'; +import { map } from 'rxjs/operators'; +import { ResourcesLibraryComponent } from "@home/components/resources/resources-library.component"; +import { ErrorStateMatcher } from "@angular/material/core"; +import { Resource, ResourceType } from "@shared/models/resource.models"; +import { ResourceService } from "@core/http/resource.service"; + +export interface ResourcesDialogData { + resources?: Resource; + isAdd?: boolean; +} + +@Component({ + selector: 'tb-resources-dialog', + templateUrl: './resources-dialog.component.html', + providers: [{provide: ErrorStateMatcher, useExisting: ResourcesDialogComponent}], + styleUrls: ['./resources-dialog.component.scss'] +}) +export class ResourcesDialogComponent extends DialogComponent implements ErrorStateMatcher, AfterViewInit { + + readonly entityType = EntityType; + + ResourceType = ResourceType; + + isAdd = false; + + submitted = false; + + resources: Resource; + + @ViewChild('resourcesComponent', {static: true}) resourcesComponent: ResourcesLibraryComponent; + + constructor(protected store: Store, + protected router: Router, + protected dialogRef: MatDialogRef, + @Inject(MAT_DIALOG_DATA) public data: ResourcesDialogData, + @SkipSelf() private errorStateMatcher: ErrorStateMatcher, + private resourceService: ResourceService) { + super(store, router, dialogRef); + + if (this.data.isAdd) { + this.isAdd = true; + } + + if (this.data.resources) { + this.resources = this.data.resources; + } + } + + ngAfterViewInit(): void { + if (this.isAdd) { + setTimeout(() => { + this.resourcesComponent.entityForm.markAsDirty(); + }, 0); + } + } + + isErrorState(control: UntypedFormControl | null, form: FormGroupDirective | NgForm | null): boolean { + const originalErrorState = this.errorStateMatcher.isErrorState(control, form); + const customErrorState = !!(control && control.invalid && this.submitted); + return originalErrorState || customErrorState; + } + + cancel(): void { + this.dialogRef.close(null); + } + + save(): void { + this.submitted = true; + if (this.resourcesComponent.entityForm.valid) { + const resource = {...this.resourcesComponent.entityFormValue()}; + if (Array.isArray(resource.data)) { + const resources = []; + resource.data.forEach((data, index) => { + resources.push({ + resourceType: resource.resourceType, + data, + fileName: resource.fileName[index], + title: resource.title + }); + }); + this.resourceService.saveResources(resources, {resendRequest: true}).pipe( + map((response) => response[0]) + ).subscribe(result => this.dialogRef.close(result)); + } else { + this.resourceService.saveResource(resource).subscribe(result => this.dialogRef.close(result)); + } + } + } +} diff --git a/ui-ngx/src/app/modules/home/pages/admin/resource/resources-library.component.html b/ui-ngx/src/app/modules/home/components/resources/resources-library.component.html similarity index 98% rename from ui-ngx/src/app/modules/home/pages/admin/resource/resources-library.component.html rename to ui-ngx/src/app/modules/home/components/resources/resources-library.component.html index ebb946ccbc..c602906be1 100644 --- a/ui-ngx/src/app/modules/home/pages/admin/resource/resources-library.component.html +++ b/ui-ngx/src/app/modules/home/components/resources/resources-library.component.html @@ -15,7 +15,7 @@ limitations under the License. --> -
+
diff --git a/ui-ngx/src/app/modules/home/components/rule-node/external/ai-config.component.ts b/ui-ngx/src/app/modules/home/components/rule-node/external/ai-config.component.ts index bfba25afa1..a4d973e998 100644 --- a/ui-ngx/src/app/modules/home/components/rule-node/external/ai-config.component.ts +++ b/ui-ngx/src/app/modules/home/components/rule-node/external/ai-config.component.ts @@ -24,6 +24,8 @@ import { AiModel, AiRuleNodeResponseFormatTypeOnlyText, ResponseFormat } from '@ import { deepTrim } from '@core/utils'; import { TranslateService } from '@ngx-translate/core'; import { jsonRequired } from '@shared/components/json-object-edit.component'; +import { Resource, ResourceType } from "@shared/models/resource.models"; +import { ResourcesDialogComponent, ResourcesDialogData } from "@home/components/resources/resources-dialog.component"; @Component({ selector: 'tb-external-node-ai-config', @@ -38,6 +40,9 @@ export class AiConfigComponent extends RuleNodeConfigurationComponent { responseFormat = ResponseFormat; + EntityType = EntityType; + ResourceType = ResourceType; + constructor(private fb: UntypedFormBuilder, private translate: TranslateService, private dialog: MatDialog) { @@ -53,6 +58,7 @@ export class AiConfigComponent extends RuleNodeConfigurationComponent { modelId: [configuration?.modelId ?? null, [Validators.required]], systemPrompt: [configuration?.systemPrompt ?? '', [Validators.maxLength(500_000), Validators.pattern(/.*\S.*/)]], userPrompt: [configuration?.userPrompt ?? '', [Validators.required, Validators.maxLength(500_000), Validators.pattern(/.*\S.*/)]], + resourceIds: [configuration?.resourceIds ?? []], responseFormat: this.fb.group({ type: [configuration?.responseFormat?.type ?? ResponseFormat.JSON, []], schema: [configuration?.responseFormat?.schema ?? null, [jsonRequired]], @@ -116,5 +122,23 @@ export class AiConfigComponent extends RuleNodeConfigurationComponent { this.aiConfigForm.get(formControl).markAsDirty(); } }); + }; + + createAiResources(name: string, formControl: string) { + this.dialog.open(ResourcesDialogComponent, { + disableClose: true, + panelClass: ['tb-dialog', 'tb-fullscreen-dialog'], + data: { + resources: {title: name, resourceType: ResourceType.TEXT}, + isAdd: true + } + }).afterClosed() + .subscribe((resource) => { + if (resource) { + const resourceIds = [...(this.aiConfigForm.get(formControl).value || []), resource.id.id]; + this.aiConfigForm.get(formControl).patchValue(resourceIds); + this.aiConfigForm.get(formControl).markAsDirty(); + } + }); } } diff --git a/ui-ngx/src/app/modules/home/pages/admin/admin.module.ts b/ui-ngx/src/app/modules/home/pages/admin/admin.module.ts index 10721ade5a..60790edd74 100644 --- a/ui-ngx/src/app/modules/home/pages/admin/admin.module.ts +++ b/ui-ngx/src/app/modules/home/pages/admin/admin.module.ts @@ -26,7 +26,6 @@ import { HomeComponentsModule } from '@modules/home/components/home-components.m import { SmsProviderComponent } from '@home/pages/admin/sms-provider.component'; import { SendTestSmsDialogComponent } from '@home/pages/admin/send-test-sms-dialog.component'; import { HomeSettingsComponent } from '@home/pages/admin/home-settings.component'; -import { ResourcesLibraryComponent } from '@home/pages/admin/resource/resources-library.component'; import { ResourceTabsComponent } from '@home/pages/admin/resource/resource-tabs.component'; import { ResourcesTableHeaderComponent } from '@home/pages/admin/resource/resources-table-header.component'; import { QueueComponent } from '@home/pages/admin/queue/queue.component'; @@ -49,7 +48,6 @@ import { ResourceLibraryTabsComponent } from '@home/pages/admin/resource/resourc SendTestSmsDialogComponent, SecuritySettingsComponent, HomeSettingsComponent, - ResourcesLibraryComponent, ResourceTabsComponent, ResourceLibraryTabsComponent, ResourcesTableHeaderComponent, diff --git a/ui-ngx/src/app/modules/home/pages/admin/resource/resources-library-table-config.resolve.ts b/ui-ngx/src/app/modules/home/pages/admin/resource/resources-library-table-config.resolve.ts index f39ed9ff7b..dc85ca4914 100644 --- a/ui-ngx/src/app/modules/home/pages/admin/resource/resources-library-table-config.resolve.ts +++ b/ui-ngx/src/app/modules/home/pages/admin/resource/resources-library-table-config.resolve.ts @@ -32,7 +32,7 @@ import { getCurrentAuthUser } from '@core/auth/auth.selectors'; import { Store } from '@ngrx/store'; import { AppState } from '@core/core.state'; import { Authority } from '@shared/models/authority.enum'; -import { ResourcesLibraryComponent } from '@home/pages/admin/resource/resources-library.component'; +import { ResourcesLibraryComponent } from '@home/components/resources/resources-library.component'; import { PageLink } from '@shared/models/page/page-link'; import { EntityAction } from '@home/models/entity/entity-component.models'; import { map } from 'rxjs/operators'; diff --git a/ui-ngx/src/app/modules/home/pages/admin/resource/resources-table-header.component.ts b/ui-ngx/src/app/modules/home/pages/admin/resource/resources-table-header.component.ts index 80c760c6ca..d4b5d18493 100644 --- a/ui-ngx/src/app/modules/home/pages/admin/resource/resources-table-header.component.ts +++ b/ui-ngx/src/app/modules/home/pages/admin/resource/resources-table-header.component.ts @@ -28,7 +28,7 @@ import { PageLink } from '@shared/models/page/page-link'; }) export class ResourcesTableHeaderComponent extends EntityTableHeaderComponent { - readonly resourceTypes = [ResourceType.LWM2M_MODEL, ResourceType.PKCS_12, ResourceType.JKS]; + readonly resourceTypes = [ResourceType.LWM2M_MODEL, ResourceType.PKCS_12, ResourceType.JKS, ResourceType.TEXT]; readonly resourceTypesTranslationMap = ResourceTypeTranslationMap; constructor(protected store: Store) { diff --git a/ui-ngx/src/app/shared/components/entity/entity-list.component.html b/ui-ngx/src/app/shared/components/entity/entity-list.component.html index 6bf7cdb78d..e0e7dfe3f7 100644 --- a/ui-ngx/src/app/shared/components/entity/entity-list.component.html +++ b/ui-ngx/src/app/shared/components/entity/entity-list.component.html @@ -40,6 +40,12 @@ [matAutocompleteConnectedTo]="origin" [matAutocomplete]="entityAutocomplete" [matChipInputFor]="chipList"> + {{ 'entity.no-entities-matching' | translate: {entity: searchText} }} + @if (allowCreateNew) { + + entity.create-new-key + + }
diff --git a/ui-ngx/src/app/shared/components/entity/entity-list.component.ts b/ui-ngx/src/app/shared/components/entity/entity-list.component.ts index 552c4f1f71..9d1de180e9 100644 --- a/ui-ngx/src/app/shared/components/entity/entity-list.component.ts +++ b/ui-ngx/src/app/shared/components/entity/entity-list.component.ts @@ -14,7 +14,18 @@ /// limitations under the License. /// -import { Component, ElementRef, forwardRef, Input, OnChanges, OnInit, SimpleChanges, ViewChild } from '@angular/core'; +import { + Component, + ElementRef, + EventEmitter, + forwardRef, + Input, + OnChanges, + OnInit, + Output, + SimpleChanges, + ViewChild +} from '@angular/core'; import { ControlValueAccessor, NG_VALIDATORS, @@ -93,6 +104,7 @@ export class EntityListComponent implements ControlValueAccessor, OnInit, OnChan } @Input() + @coerceBoolean() disabled: boolean; @Input() @@ -109,6 +121,13 @@ export class EntityListComponent implements ControlValueAccessor, OnInit, OnChan @coerceBoolean() inlineField: boolean; + @Input() + @coerceBoolean() + allowCreateNew: boolean; + + @Output() + createNew = new EventEmitter(); + @ViewChild('entityInput') entityInput: ElementRef; @ViewChild('entityAutocomplete') matAutocomplete: MatAutocomplete; @ViewChild('chipList', {static: true}) chipList: MatChipGrid; @@ -136,6 +155,11 @@ export class EntityListComponent implements ControlValueAccessor, OnInit, OnChan this.entityListFormGroup.get('entities').updateValueAndValidity(); } + createNewEntity($event: Event, searchText?: string) { + $event.stopPropagation(); + this.createNew.emit(searchText); + } + registerOnChange(fn: any): void { this.propagateChange = fn; } @@ -201,6 +225,9 @@ export class EntityListComponent implements ControlValueAccessor, OnInit, OnChan this.modelValue = null; } this.dirty = true; + if (this.entityInput) { + this.entityInput.nativeElement.value = ''; + } } validate(): ValidationErrors | null { diff --git a/ui-ngx/src/app/shared/models/resource.models.ts b/ui-ngx/src/app/shared/models/resource.models.ts index 0419e40a7c..3495bf9eac 100644 --- a/ui-ngx/src/app/shared/models/resource.models.ts +++ b/ui-ngx/src/app/shared/models/resource.models.ts @@ -24,7 +24,8 @@ export enum ResourceType { LWM2M_MODEL = 'LWM2M_MODEL', PKCS_12 = 'PKCS_12', JKS = 'JKS', - JS_MODULE = 'JS_MODULE' + JS_MODULE = 'JS_MODULE', + TEXT = 'TEXT', } export enum ResourceSubType { @@ -57,7 +58,8 @@ export const ResourceTypeTranslationMap = new Map( [ResourceType.LWM2M_MODEL, 'resource.type.lwm2m-model'], [ResourceType.PKCS_12, 'resource.type.pkcs-12'], [ResourceType.JKS, 'resource.type.jks'], - [ResourceType.JS_MODULE, 'resource.type.js-module'] + [ResourceType.JS_MODULE, 'resource.type.js-module'], + [ResourceType.TEXT, 'resource.type.text'], ] ); @@ -76,8 +78,8 @@ export interface TbResourceInfo extends Omit, 'name' | title?: string; resourceType: ResourceType; resourceSubType?: ResourceSubType; - fileName: string; - public: boolean; + fileName?: string; + public?: boolean; publicResourceKey?: string; readonly link?: string; readonly publicLink?: string; @@ -87,7 +89,7 @@ export interface TbResourceInfo extends Omit, 'name' | export type ResourceInfo = TbResourceInfo; export interface Resource extends ResourceInfo { - data: string; + data?: string; name?: string; } 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 80328181f6..d4bfd4ea06 100644 --- a/ui-ngx/src/assets/locale/locale.constant-en_US.json +++ b/ui-ngx/src/assets/locale/locale.constant-en_US.json @@ -4488,7 +4488,8 @@ "jks": "JKS", "js-module": "JS module", "lwm2m-model": "LWM2M model", - "pkcs-12": "PKCS #12" + "pkcs-12": "PKCS #12", + "text": "Text" }, "resource-sub-type": "Sub-type", "sub-type": { @@ -5467,7 +5468,8 @@ "timeout-required": "Timeout is required", "timeout-validation": "Must be from 1 second to 10 minutes.", "force-acknowledgement": "Force acknowledgement", - "force-acknowledgement-hint": "If enabled, the incoming message is acknowledged immediately. The model's response is then enqueued as a separate, new message." + "force-acknowledgement-hint": "If enabled, the incoming message is acknowledged immediately. The model's response is then enqueued as a separate, new message.", + "ai-resources": "AI resources" } }, "timezone": { From cd6063fd097e8c2db83b87e392011ad34866065c Mon Sep 17 00:00:00 2001 From: ArtemDzhereleiko Date: Wed, 10 Sep 2025 09:41:47 +0300 Subject: [PATCH 032/102] UI: General resources --- ui-ngx/src/app/core/http/entity.service.ts | 6 +- ui-ngx/src/app/core/http/resource.service.ts | 14 +++-- .../resources/resources-dialog.component.html | 4 +- .../resources/resources-dialog.component.ts | 3 + .../resources-library.component.html | 61 ++++++++++--------- .../resources/resources-library.component.ts | 13 +++- .../external/ai-config.component.html | 2 +- .../rule-node/external/ai-config.component.ts | 2 +- .../resources-table-header.component.ts | 2 +- .../shared/components/file-input.component.ts | 14 ++++- .../src/app/shared/models/resource.models.ts | 4 +- .../assets/locale/locale.constant-en_US.json | 2 +- 12 files changed, 77 insertions(+), 50 deletions(-) diff --git a/ui-ngx/src/app/core/http/entity.service.ts b/ui-ngx/src/app/core/http/entity.service.ts index 53a55bfe7f..c652ba39ba 100644 --- a/ui-ngx/src/app/core/http/entity.service.ts +++ b/ui-ngx/src/app/core/http/entity.service.ts @@ -299,9 +299,7 @@ export class EntityService { entityIds); break; case EntityType.TB_RESOURCE: - observable = this.getEntitiesByIdsObservable( - (id) => this.resourceService.getResource(id, config), - entityIds); + observable = this.resourceService.getResourcesByIds(entityIds, config); break; } return observable; @@ -478,7 +476,7 @@ export class EntityService { break; case EntityType.TB_RESOURCE: pageLink.sortOrder.property = 'title'; - entitiesObservable = this.resourceService.getTenantResources(pageLink, subType as ResourceType, config); + entitiesObservable = this.resourceService.getResources(pageLink, subType as ResourceType, null, config); break; case EntityType.QUEUE_STATS: pageLink.sortOrder.property = 'createdTime'; diff --git a/ui-ngx/src/app/core/http/resource.service.ts b/ui-ngx/src/app/core/http/resource.service.ts index 52c92d96e4..90335758dd 100644 --- a/ui-ngx/src/app/core/http/resource.service.ts +++ b/ui-ngx/src/app/core/http/resource.service.ts @@ -24,6 +24,7 @@ import { Resource, ResourceInfo, ResourceSubType, ResourceType, TBResourceScope import { catchError, mergeMap } from 'rxjs/operators'; import { isNotEmptyStr } from '@core/utils'; import { ResourcesService } from '@core/services/resources.service'; +import { NotificationTarget } from "@shared/models/notification.models"; @Injectable({ providedIn: 'root' @@ -47,12 +48,8 @@ export class ResourceService { return this.http.get>(url, defaultHttpOptionsFromConfig(config)); } - public getTenantResources(pageLink: PageLink, resourceType?: ResourceType, config?: RequestConfig): Observable> { - let url = `/api/resource${pageLink.toQuery()}`; - if (isNotEmptyStr(resourceType)) { - url += `&resourceType=${resourceType}`; - } - return this.http.get>(url, defaultHttpOptionsFromConfig(config)); + public getTenantResources(pageLink: PageLink, config?: RequestConfig): Observable> { + return this.http.get>(`/api/resource/tenant${pageLink.toQuery()}`, defaultHttpOptionsFromConfig(config)) } public getResource(resourceId: string, config?: RequestConfig): Observable { @@ -98,4 +95,9 @@ export class ResourceService { return this.http.delete(`/api/resource/${resourceId}?force=${force}`, defaultHttpOptionsFromConfig(config)); } + public getResourcesByIds(ids: string[], config?: RequestConfig): Observable> { + return this.http.get>(`/api/resource?resourceIds=${ids.join(',')}`, + defaultHttpOptionsFromConfig(config)); + } + } diff --git a/ui-ngx/src/app/modules/home/components/resources/resources-dialog.component.html b/ui-ngx/src/app/modules/home/components/resources/resources-dialog.component.html index c6813ff0f2..0062cfa9ac 100644 --- a/ui-ngx/src/app/modules/home/components/resources/resources-dialog.component.html +++ b/ui-ngx/src/app/modules/home/components/resources/resources-dialog.component.html @@ -32,8 +32,8 @@ diff --git a/ui-ngx/src/app/modules/home/components/resources/resources-dialog.component.ts b/ui-ngx/src/app/modules/home/components/resources/resources-dialog.component.ts index a06c72827a..6216f087b1 100644 --- a/ui-ngx/src/app/modules/home/components/resources/resources-dialog.component.ts +++ b/ui-ngx/src/app/modules/home/components/resources/resources-dialog.component.ts @@ -106,6 +106,9 @@ export class ResourcesDialogComponent extends DialogComponent response[0]) ).subscribe(result => this.dialogRef.close(result)); } else { + if (resource.resourceType !== ResourceType.GENERAL) { + delete resource.descriptor; + } this.resourceService.saveResource(resource).subscribe(result => this.dialogRef.close(result)); } } diff --git a/ui-ngx/src/app/modules/home/components/resources/resources-library.component.html b/ui-ngx/src/app/modules/home/components/resources/resources-library.component.html index c602906be1..4737b75ef2 100644 --- a/ui-ngx/src/app/modules/home/components/resources/resources-library.component.html +++ b/ui-ngx/src/app/modules/home/components/resources/resources-library.component.html @@ -48,14 +48,16 @@
- - resource.resource-type - - - {{ resourceTypesTranslationMap.get(resourceType) | translate }} - - - + @if (resourceTypes.length > 1) { + + resource.resource-type + + + {{ resourceTypesTranslationMap.get(resourceType) | translate }} + + + + } resource.title @@ -66,26 +68,29 @@ {{ 'resource.title-max-length' | translate }} - - -
- - resource.file-name - - -
+ @if (isAdd || ((isAdd || isEdit) && entityForm.get('resourceType').value === resourceType.GENERAL)) { + + + } @else { +
+ + resource.file-name + + +
+ }
diff --git a/ui-ngx/src/app/modules/home/components/resources/resources-library.component.ts b/ui-ngx/src/app/modules/home/components/resources/resources-library.component.ts index 0b50e45a7a..e3ad0e15f3 100644 --- a/ui-ngx/src/app/modules/home/components/resources/resources-library.component.ts +++ b/ui-ngx/src/app/modules/home/components/resources/resources-library.component.ts @@ -44,7 +44,7 @@ export class ResourcesLibraryComponent extends EntityComponent impleme standalone = false; @Input() - resourceTypes = [ResourceType.LWM2M_MODEL, ResourceType.PKCS_12, ResourceType.JKS, ResourceType.TEXT]; + resourceTypes = [ResourceType.LWM2M_MODEL, ResourceType.PKCS_12, ResourceType.JKS, ResourceType.GENERAL]; @Input() defaultResourceType = ResourceType.LWM2M_MODEL; @@ -90,10 +90,19 @@ export class ResourcesLibraryComponent extends EntityComponent impleme title: [entity ? entity.title : '', [Validators.required, Validators.maxLength(255)]], resourceType: [entity?.resourceType ? entity.resourceType : ResourceType.LWM2M_MODEL, Validators.required], fileName: [entity ? entity.fileName : null, Validators.required], - data: [entity ? entity.data : null, this.isAdd ? [Validators.required] : []] + data: [entity ? entity.data : null, this.isAdd ? [Validators.required] : []], + descriptor: this.fb.group({ + mediaType: [''] + }) }); } + mediaTypeChange(mediaType: string): void { + if (this.entityForm.get('resourceType').value === ResourceType.GENERAL) { + this.entityForm.get('descriptor').get('mediaType').patchValue(mediaType); + } + } + updateForm(entity: Resource): void { this.entityForm.patchValue(entity); } diff --git a/ui-ngx/src/app/modules/home/components/rule-node/external/ai-config.component.html b/ui-ngx/src/app/modules/home/components/rule-node/external/ai-config.component.html index d259d57ef3..5381fc770b 100644 --- a/ui-ngx/src/app/modules/home/components/rule-node/external/ai-config.component.html +++ b/ui-ngx/src/app/modules/home/components/rule-node/external/ai-config.component.html @@ -76,7 +76,7 @@ placeholderText="{{ 'rule-node-config.ai.ai-resources' | translate }}" [inlineField]="true" [entityType]="EntityType.TB_RESOURCE" - [subType]="ResourceType.TEXT" + [subType]="ResourceType.GENERAL" (createNew)="createAiResources($event, 'resourceIds')" formControlName="resourceIds"> diff --git a/ui-ngx/src/app/modules/home/components/rule-node/external/ai-config.component.ts b/ui-ngx/src/app/modules/home/components/rule-node/external/ai-config.component.ts index a4d973e998..07bcb86fdf 100644 --- a/ui-ngx/src/app/modules/home/components/rule-node/external/ai-config.component.ts +++ b/ui-ngx/src/app/modules/home/components/rule-node/external/ai-config.component.ts @@ -129,7 +129,7 @@ export class AiConfigComponent extends RuleNodeConfigurationComponent { disableClose: true, panelClass: ['tb-dialog', 'tb-fullscreen-dialog'], data: { - resources: {title: name, resourceType: ResourceType.TEXT}, + resources: {title: name, resourceType: ResourceType.GENERAL}, isAdd: true } }).afterClosed() diff --git a/ui-ngx/src/app/modules/home/pages/admin/resource/resources-table-header.component.ts b/ui-ngx/src/app/modules/home/pages/admin/resource/resources-table-header.component.ts index d4b5d18493..136c143e3c 100644 --- a/ui-ngx/src/app/modules/home/pages/admin/resource/resources-table-header.component.ts +++ b/ui-ngx/src/app/modules/home/pages/admin/resource/resources-table-header.component.ts @@ -28,7 +28,7 @@ import { PageLink } from '@shared/models/page/page-link'; }) export class ResourcesTableHeaderComponent extends EntityTableHeaderComponent { - readonly resourceTypes = [ResourceType.LWM2M_MODEL, ResourceType.PKCS_12, ResourceType.JKS, ResourceType.TEXT]; + readonly resourceTypes = [ResourceType.LWM2M_MODEL, ResourceType.PKCS_12, ResourceType.JKS, ResourceType.GENERAL]; readonly resourceTypesTranslationMap = ResourceTypeTranslationMap; constructor(protected store: Store) { diff --git a/ui-ngx/src/app/shared/components/file-input.component.ts b/ui-ngx/src/app/shared/components/file-input.component.ts index bbe68bb9c6..6960db73dd 100644 --- a/ui-ngx/src/app/shared/components/file-input.component.ts +++ b/ui-ngx/src/app/shared/components/file-input.component.ts @@ -129,10 +129,15 @@ export class FileInputComponent extends PageComponent implements AfterViewInit, @Output() fileNameChanged = new EventEmitter(); + @Output() + mediaTypeChanged = new EventEmitter(); + fileName: string | string[]; fileContent: any; files: File[]; + mediaType: string; + @ViewChild('flow', {static: true}) flow: FlowDirective; @@ -180,6 +185,7 @@ export class FileInputComponent extends PageComponent implements AfterViewInit, this.fileContent = files[0].fileContent; this.fileName = files[0].fileName; this.files = files[0].files; + this.mediaType = files[0].mediaType; this.updateModel(); } else if (files.length > 1) { this.fileContent = files.map(content => content.fileContent); @@ -203,6 +209,7 @@ export class FileInputComponent extends PageComponent implements AfterViewInit, let fileName = null; let fileContent = null; let files = null; + let mediaType = null; if (reader.readyState === reader.DONE) { if (!this.workFromFileObj) { fileContent = reader.result; @@ -211,16 +218,18 @@ export class FileInputComponent extends PageComponent implements AfterViewInit, fileContent = this.contentConvertFunction(fileContent); } fileName = fileContent ? file.name : null; + mediaType = file?.file?.type || null; } } else if (file.name || file.file){ files = file.file; fileName = file.name; + mediaType = file.file.type || null; } } - resolve({fileContent, fileName, files}); + resolve({fileContent, fileName, files, mediaType}); }; reader.onerror = () => { - resolve({fileContent: null, fileName: null, files: null}); + resolve({fileContent: null, fileName: null, files: null, mediaType: null}); }; if (this.readAsBinary) { reader.readAsBinaryString(file.file); @@ -283,6 +292,7 @@ export class FileInputComponent extends PageComponent implements AfterViewInit, this.propagateChange(this.files); } else { this.propagateChange(this.fileContent); + this.mediaTypeChanged.emit(this.mediaType); this.fileNameChanged.emit(this.fileName); } } diff --git a/ui-ngx/src/app/shared/models/resource.models.ts b/ui-ngx/src/app/shared/models/resource.models.ts index 3495bf9eac..12590e7608 100644 --- a/ui-ngx/src/app/shared/models/resource.models.ts +++ b/ui-ngx/src/app/shared/models/resource.models.ts @@ -25,7 +25,7 @@ export enum ResourceType { PKCS_12 = 'PKCS_12', JKS = 'JKS', JS_MODULE = 'JS_MODULE', - TEXT = 'TEXT', + GENERAL = 'GENERAL', } export enum ResourceSubType { @@ -59,7 +59,7 @@ export const ResourceTypeTranslationMap = new Map( [ResourceType.PKCS_12, 'resource.type.pkcs-12'], [ResourceType.JKS, 'resource.type.jks'], [ResourceType.JS_MODULE, 'resource.type.js-module'], - [ResourceType.TEXT, 'resource.type.text'], + [ResourceType.GENERAL, 'resource.type.general'], ] ); 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 d4bfd4ea06..d79bb81ae5 100644 --- a/ui-ngx/src/assets/locale/locale.constant-en_US.json +++ b/ui-ngx/src/assets/locale/locale.constant-en_US.json @@ -4489,7 +4489,7 @@ "js-module": "JS module", "lwm2m-model": "LWM2M model", "pkcs-12": "PKCS #12", - "text": "Text" + "general": "General" }, "resource-sub-type": "Sub-type", "sub-type": { From caf90636382db5418dd59703c52ceed0a0aaf0e5 Mon Sep 17 00:00:00 2001 From: ArtemDzhereleiko Date: Fri, 12 Sep 2025 16:23:53 +0300 Subject: [PATCH 033/102] UI: Add resources in use dialog with force to delete --- .../resources-library-table-config.resolve.ts | 180 +++++++++++++++++- .../assets/locale/locale.constant-en_US.json | 7 +- 2 files changed, 177 insertions(+), 10 deletions(-) diff --git a/ui-ngx/src/app/modules/home/pages/admin/resource/resources-library-table-config.resolve.ts b/ui-ngx/src/app/modules/home/pages/admin/resource/resources-library-table-config.resolve.ts index dc85ca4914..d92355fbac 100644 --- a/ui-ngx/src/app/modules/home/pages/admin/resource/resources-library-table-config.resolve.ts +++ b/ui-ngx/src/app/modules/home/pages/admin/resource/resources-library-table-config.resolve.ts @@ -22,7 +22,13 @@ import { EntityTableConfig } from '@home/models/entity/entities-table-config.models'; import { Router } from '@angular/router'; -import { Resource, ResourceInfo, ResourceType, ResourceTypeTranslationMap } from '@shared/models/resource.models'; +import { + Resource, + ResourceInfo, ResourceInfoWithReferences, + ResourceType, + ResourceTypeTranslationMap, + toResourceDeleteResult +} from '@shared/models/resource.models'; import { EntityType, entityTypeResources, entityTypeTranslations } from '@shared/models/entity-type.models'; import { NULL_UUID } from '@shared/models/id/has-uuid'; import { DatePipe } from '@angular/common'; @@ -35,9 +41,19 @@ import { Authority } from '@shared/models/authority.enum'; import { ResourcesLibraryComponent } from '@home/components/resources/resources-library.component'; import { PageLink } from '@shared/models/page/page-link'; import { EntityAction } from '@home/models/entity/entity-component.models'; -import { map } from 'rxjs/operators'; +import { catchError, map } from 'rxjs/operators'; import { ResourcesTableHeaderComponent } from '@home/pages/admin/resource/resources-table-header.component'; import { ResourceLibraryTabsComponent } from '@home/pages/admin/resource/resource-library-tabs.component'; +import { forkJoin, of } from "rxjs"; +import { + ResourcesInUseDialogComponent, + ResourcesInUseDialogData +} from "@shared/components/resource/resources-in-use-dialog.component"; +import { parseHttpErrorMessage } from "@core/utils"; +import { ActionNotificationShow } from "@core/notification/notification.actions"; +import { ResourcesDatasource } from "@home/pages/admin/resource/resources-datasource"; +import { MatDialog } from "@angular/material/dialog"; +import { DialogService } from "@core/services/dialog.service"; @Injectable() export class ResourcesLibraryTableConfigResolver { @@ -49,6 +65,8 @@ export class ResourcesLibraryTableConfigResolver { private resourceService: ResourceService, private translate: TranslateService, private router: Router, + private dialog: MatDialog, + private dialogService: DialogService, private datePipe: DatePipe) { this.config.entityType = EntityType.TB_RESOURCE; @@ -76,19 +94,27 @@ export class ResourcesLibraryTableConfigResolver { icon: 'file_download', isEnabled: () => true, onAction: ($event, entity) => this.downloadResource($event, entity) - } + }, + { + name: this.translate.instant('resource.delete'), + icon: 'delete', + isEnabled: (resource) => this.config.deleteEnabled(resource), + onAction: ($event, entity) => this.deleteResource($event, entity) + }, ); - this.config.deleteEntityTitle = resource => this.translate.instant('resource.delete-resource-title', - { resourceTitle: resource.title }); - this.config.deleteEntityContent = () => this.translate.instant('resource.delete-resource-text'); - this.config.deleteEntitiesTitle = count => this.translate.instant('resource.delete-resources-title', {count}); - this.config.deleteEntitiesContent = () => this.translate.instant('resource.delete-resources-text'); + this.config.groupActionDescriptors = [{ + name: this.translate.instant('action.delete'), + icon: 'delete', + isEnabled: true, + onAction: ($event, entities) => this.deleteResources($event, entities) + }]; + + this.config.entitiesDeleteEnabled = false; this.config.entitiesFetchFunction = pageLink => this.resourceService.getResources(pageLink, this.config.componentsData.resourceType); this.config.loadEntity = id => this.resourceService.getResourceInfoById(id.id); this.config.saveEntity = resource => this.saveResource(resource); - this.config.deleteEntity = id => this.resourceService.deleteResource(id.id); this.config.onEntityAction = action => this.onResourceAction(action); } @@ -147,6 +173,8 @@ export class ResourcesLibraryTableConfigResolver { case 'downloadResource': this.downloadResource(action.event, action.entity); return true; + case 'deleteLibrary': + this.deleteResource(action.event, action.entity); } return false; } @@ -165,4 +193,138 @@ export class ResourcesLibraryTableConfigResolver { return authority === Authority.SYS_ADMIN; } } + + private deleteResource($event: Event, resource: ResourceInfo) { + if ($event) { + $event.stopPropagation(); + } + this.dialogService.confirm( + this.translate.instant('resource.delete-resource-title', { resourceTitle: resource.title }), + this.translate.instant('resource.delete-resource-text'), + this.translate.instant('action.no'), + this.translate.instant('action.yes'), + true + ).subscribe((result) => { + if (result) { + this.resourceService.deleteResource(resource.id.id, false, {ignoreErrors: true}).pipe( + map(() => toResourceDeleteResult(resource)), + catchError((err) => of(toResourceDeleteResult(resource, err))) + ).subscribe( + (deleteResult) => { + if (deleteResult.success) { + if (this.config.getEntityDetailsPage()) { + this.config.getEntityDetailsPage().goBack(); + } else { + this.config.updateData(true); + } + } else if (deleteResult.resourceIsReferencedError) { + const resources: ResourceInfoWithReferences[] = [{...resource, ...{references: deleteResult.references}}]; + const data = { + multiple: false, + resources, + configuration: { + title: 'resource.resource-is-in-use', + message: this.translate.instant('resource.resource-is-in-use-text', {title: resources[0].title}), + deleteText: 'resource.delete-resource-in-use-text', + selectedText: 'resource.selected-resources', + columns: ['select', 'title', 'references'] + } + }; + this.dialog.open(ResourcesInUseDialogComponent, { + disableClose: true, + panelClass: ['tb-dialog', 'tb-fullscreen-dialog'], + data + }).afterClosed().subscribe((resources) => { + if (resources) { + this.resourceService.deleteResource(resource.id.id, true).subscribe(() => { + if (this.config.getEntityDetailsPage()) { + this.config.getEntityDetailsPage().goBack(); + } else { + this.config.updateData(true); + } + }); + } + }); + } else { + const errorMessageWithTimeout = parseHttpErrorMessage(deleteResult.error, this.translate); + setTimeout(() => { + this.store.dispatch(new ActionNotificationShow({message: errorMessageWithTimeout.message, type: 'error'})); + }, errorMessageWithTimeout.timeout); + } + } + ); + } + }); + } + + private deleteResources($event: Event, resources: ResourceInfo[]) { + if ($event) { + $event.stopPropagation(); + } + if (resources && resources.length) { + const title = this.translate.instant('resource.delete-resources-title', {count: resources.length}); + const content = this.translate.instant('resource.delete-resources-text'); + this.dialogService.confirm(title, content, + this.translate.instant('action.no'), + this.translate.instant('action.yes')).subscribe((result) => { + if (result) { + const tasks = resources.map((resource) => + this.resourceService.deleteResource(resource.id.id, false, {ignoreErrors: true}).pipe( + map(() => toResourceDeleteResult(resource)), + catchError((err) => of(toResourceDeleteResult(resource, err))) + ) + ); + forkJoin(tasks).subscribe( + (deleteResults) => { + const anySuccess = deleteResults.some(res => res.success); + const referenceErrors = deleteResults.filter(res => res.resourceIsReferencedError); + const otherError = deleteResults.find(res => !res.success); + if (anySuccess) { + this.config.updateData(); + } + if (referenceErrors?.length) { + const resourcesWithReferences: ResourceInfoWithReferences[] = + referenceErrors.map(ref => ({...ref.resource, ...{references: ref.references}})); + const data = { + multiple: true, + resources: resourcesWithReferences, + configuration: { + title: 'resource.resources-are-in-use', + message: this.translate.instant('resource.resources-are-in-use-text'), + deleteText: 'resource.delete-resource-in-use-text', + selectedText: 'resource.selected-resources', + datasource: new ResourcesDatasource(this.resourceService, resourcesWithReferences, () => true), + columns: ['select', 'title', 'references'] + } + }; + this.dialog.open(ResourcesInUseDialogComponent, { + disableClose: true, + panelClass: ['tb-dialog', 'tb-fullscreen-dialog'], + data + }).afterClosed().subscribe((forceDeleteResources) => { + if (forceDeleteResources && forceDeleteResources.length) { + const forceDeleteTasks = forceDeleteResources.map((resource) => + this.resourceService.deleteResource(resource.id.id, true) + ); + forkJoin(forceDeleteTasks).subscribe( + () => { + this.config.updateData(); + } + ); + } + }); + } else if (otherError) { + const errorMessageWithTimeout = parseHttpErrorMessage(otherError.error, this.translate); + setTimeout(() => { + this.store.dispatch(new ActionNotificationShow({message: errorMessageWithTimeout.message, type: 'error'})); + }, errorMessageWithTimeout.timeout); + } + } + ); + } + }); + } + } } 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 d79bb81ae5..4639552658 100644 --- a/ui-ngx/src/assets/locale/locale.constant-en_US.json +++ b/ui-ngx/src/assets/locale/locale.constant-en_US.json @@ -4497,7 +4497,12 @@ "scada-symbol": "Scada symbol", "extension": "Extension", "module": "Module" - } + }, + "resource-is-in-use": "Resource is used by other entities", + "resources-are-in-use": "Resources are used by other entities", + "resource-is-in-use-text": "The Resource '{{title}}' was not deleted because it is used by the following entities:", + "resources-are-in-use-text": "Not all Resources have been deleted because they are used by other entities.
You can view referenced entities by clicking the References button in the corresponding resource row.
If you still want to delete these resources, select them in the table below and click the Delete selected button.", + "delete-resource-in-use-text": "If you still want to delete the resource, click the Delete anyway button." }, "javascript": { "add": "Add JavaScript resource", From af600ff450925466baa86461f33e98065d82af9b Mon Sep 17 00:00:00 2001 From: ArtemDzhereleiko Date: Fri, 12 Sep 2025 16:46:38 +0300 Subject: [PATCH 034/102] UI: oprimize import --- ui-ngx/src/app/core/http/resource.service.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/ui-ngx/src/app/core/http/resource.service.ts b/ui-ngx/src/app/core/http/resource.service.ts index 90335758dd..168c63b3b1 100644 --- a/ui-ngx/src/app/core/http/resource.service.ts +++ b/ui-ngx/src/app/core/http/resource.service.ts @@ -24,7 +24,6 @@ import { Resource, ResourceInfo, ResourceSubType, ResourceType, TBResourceScope import { catchError, mergeMap } from 'rxjs/operators'; import { isNotEmptyStr } from '@core/utils'; import { ResourcesService } from '@core/services/resources.service'; -import { NotificationTarget } from "@shared/models/notification.models"; @Injectable({ providedIn: 'root' From f9b52c3a43e33caf494972fb19f4749f36c8082e Mon Sep 17 00:00:00 2001 From: dashevchenko Date: Mon, 29 Sep 2025 11:44:27 +0300 Subject: [PATCH 035/102] fixed missing ACK when message publishing fails --- .../AbstractGatewaySessionHandler.java | 28 ++++++++++++------- 1 file changed, 18 insertions(+), 10 deletions(-) diff --git a/common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/session/AbstractGatewaySessionHandler.java b/common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/session/AbstractGatewaySessionHandler.java index 91e7dedbf2..af8ed2aa6d 100644 --- a/common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/session/AbstractGatewaySessionHandler.java +++ b/common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/session/AbstractGatewaySessionHandler.java @@ -412,7 +412,7 @@ public abstract class AbstractGatewaySessionHandler processPostTelemetryMsg(deviceCtx, deviceEntry.getValue(), deviceName, msgId), - t -> failedToProcessLog(deviceName, TELEMETRY, t)); + t -> processFailure(msgId, deviceName, TELEMETRY, t)); } } @@ -444,7 +444,7 @@ public abstract class AbstractGatewaySessionHandler { String deviceName = checkDeviceName(telemetryMsg.getDeviceName()); process(deviceName, deviceCtx -> processPostTelemetryMsg(deviceCtx, telemetryMsg.getMsg(), deviceName, msgId), - t -> failedToProcessLog(deviceName, TELEMETRY, t)); + t -> processFailure(msgId, deviceName, TELEMETRY, t)); }); } catch (RuntimeException | InvalidProtocolBufferException e) { throw new AdaptorException(e); @@ -483,7 +483,7 @@ public abstract class AbstractGatewaySessionHandler processClaimDeviceMsg(deviceCtx, deviceEntry.getValue(), deviceName, msgId), - t -> failedToProcessLog(deviceName, CLAIMING, t)); + t -> processFailure(msgId, deviceName, CLAIMING, t)); } } @@ -510,7 +510,7 @@ public abstract class AbstractGatewaySessionHandler { String deviceName = checkDeviceName(claimDeviceMsg.getDeviceName()); process(deviceName, deviceCtx -> processClaimDeviceMsg(deviceCtx, claimDeviceMsg.getClaimRequest(), deviceName, msgId), - t -> failedToProcessLog(deviceName, CLAIMING, t)); + t -> processFailure(msgId, deviceName, CLAIMING, t)); }); } catch (RuntimeException | InvalidProtocolBufferException e) { throw new AdaptorException(e); @@ -539,7 +539,7 @@ public abstract class AbstractGatewaySessionHandler processPostAttributesMsg(deviceCtx, deviceEntry.getValue(), deviceName, msgId), - t -> failedToProcessLog(deviceName, ATTRIBUTE, t)); + t -> processFailure(msgId, deviceName, ATTRIBUTE, t)); } } @@ -565,7 +565,7 @@ public abstract class AbstractGatewaySessionHandler { String deviceName = checkDeviceName(attributesMsg.getDeviceName()); process(deviceName, deviceCtx -> processPostAttributesMsg(deviceCtx, attributesMsg.getMsg(), deviceName, msgId), - t -> failedToProcessLog(deviceName, ATTRIBUTE, t)); + t -> processFailure(msgId, deviceName, ATTRIBUTE, t)); }); } catch (RuntimeException | InvalidProtocolBufferException e) { throw new AdaptorException(e); @@ -648,7 +648,7 @@ public abstract class AbstractGatewaySessionHandler processRpcResponseMsg(deviceCtx, requestId, data, deviceName, msgId), - t -> failedToProcessLog(deviceName, RPC_RESPONSE, t)); + t -> processFailure(msgId, deviceName, RPC_RESPONSE, t)); } private void processRpcResponseMsg(MqttDeviceAwareSessionContext deviceCtx, Integer requestId, String data, String deviceName, int msgId) { @@ -661,8 +661,7 @@ public abstract class AbstractGatewaySessionHandler processGetAttributeRequestMessage(deviceCtx, requestMsg, deviceName, msgId), t -> { - failedToProcessLog(deviceName, ATTRIBUTES_REQUEST, t); - ack(mqttMsg, MqttReasonCodes.PubAck.IMPLEMENTATION_SPECIFIC_ERROR); + processFailure(msgId, deviceName, ATTRIBUTES_REQUEST, t, MqttReasonCodes.PubAck.IMPLEMENTATION_SPECIFIC_ERROR); }); } @@ -790,10 +789,19 @@ public abstract class AbstractGatewaySessionHandler Date: Mon, 29 Sep 2025 13:35:22 +0300 Subject: [PATCH 036/102] AI models: add base URL to OpenAI --- .../Langchain4jChatModelConfigurerImpl.java | 1 + .../controller/AiModelControllerTest.java | 12 ++++++-- .../ai/DefaultTbAiModelServiceTest.java | 2 +- .../sql/BaseTbResourceServiceTest.java | 6 +++- .../ai/provider/OpenAiProviderConfig.java | 30 +++++++++++++++++-- .../rule/engine/ai/TbAiNodeTest.java | 5 +++- 6 files changed, 47 insertions(+), 9 deletions(-) diff --git a/application/src/main/java/org/thingsboard/server/service/ai/Langchain4jChatModelConfigurerImpl.java b/application/src/main/java/org/thingsboard/server/service/ai/Langchain4jChatModelConfigurerImpl.java index 2cb6c2097f..c631f1a3d0 100644 --- a/application/src/main/java/org/thingsboard/server/service/ai/Langchain4jChatModelConfigurerImpl.java +++ b/application/src/main/java/org/thingsboard/server/service/ai/Langchain4jChatModelConfigurerImpl.java @@ -70,6 +70,7 @@ class Langchain4jChatModelConfigurerImpl implements Langchain4jChatModelConfigur @Override public ChatModel configureChatModel(OpenAiChatModelConfig chatModelConfig) { return OpenAiChatModel.builder() + .baseUrl(chatModelConfig.providerConfig().baseUrl()) .apiKey(chatModelConfig.providerConfig().apiKey()) .modelName(chatModelConfig.modelId()) .temperature(chatModelConfig.temperature()) diff --git a/application/src/test/java/org/thingsboard/server/controller/AiModelControllerTest.java b/application/src/test/java/org/thingsboard/server/controller/AiModelControllerTest.java index ae2972b0cc..53648af441 100644 --- a/application/src/test/java/org/thingsboard/server/controller/AiModelControllerTest.java +++ b/application/src/test/java/org/thingsboard/server/controller/AiModelControllerTest.java @@ -104,7 +104,10 @@ public class AiModelControllerTest extends AbstractControllerTest { var model = doPost("/api/ai/model", constructValidOpenAiModel("Test model"), AiModel.class); var newModelConfig = OpenAiChatModelConfig.builder() - .providerConfig(new OpenAiProviderConfig("test-api-key-updated")) + .providerConfig(OpenAiProviderConfig.builder() + .baseUrl(OpenAiProviderConfig.OPENAI_OFFICIAL_BASE_URL) + .apiKey("test-api-key-updated") + .build()) .modelId("o4-mini") .temperature(0.2) .topP(0.4) @@ -270,7 +273,7 @@ public class AiModelControllerTest extends AbstractControllerTest { .tenantId(tenantId) .name("Test model 1") .configuration(OpenAiChatModelConfig.builder() - .providerConfig(new OpenAiProviderConfig("test-api-key")) + .providerConfig(OpenAiProviderConfig.builder().apiKey("test-api-key").build()) .modelId("o3-pro") .build()) .build(), AiModel.class); @@ -594,7 +597,10 @@ public class AiModelControllerTest extends AbstractControllerTest { private AiModel constructValidOpenAiModel(String name) { var modelConfig = OpenAiChatModelConfig.builder() - .providerConfig(new OpenAiProviderConfig("test-api-key")) + .providerConfig(OpenAiProviderConfig.builder() + .baseUrl(OpenAiProviderConfig.OPENAI_OFFICIAL_BASE_URL) + .apiKey("test-api-key") + .build()) .modelId("gpt-4o") .temperature(0.5) .topP(0.3) diff --git a/application/src/test/java/org/thingsboard/server/service/entitiy/ai/DefaultTbAiModelServiceTest.java b/application/src/test/java/org/thingsboard/server/service/entitiy/ai/DefaultTbAiModelServiceTest.java index 2321446b44..b6be136f82 100644 --- a/application/src/test/java/org/thingsboard/server/service/entitiy/ai/DefaultTbAiModelServiceTest.java +++ b/application/src/test/java/org/thingsboard/server/service/entitiy/ai/DefaultTbAiModelServiceTest.java @@ -253,7 +253,7 @@ class DefaultTbAiModelServiceTest { private static AiModelConfig constructValidOpenAiModelConfig() { return OpenAiChatModelConfig.builder() - .providerConfig(new OpenAiProviderConfig("test-api-key")) + .providerConfig(OpenAiProviderConfig.builder().apiKey("test-api-key").build()) .modelId("gpt-4o") .temperature(0.5) .topP(0.3) diff --git a/application/src/test/java/org/thingsboard/server/service/resource/sql/BaseTbResourceServiceTest.java b/application/src/test/java/org/thingsboard/server/service/resource/sql/BaseTbResourceServiceTest.java index da20b9489c..a146730465 100644 --- a/application/src/test/java/org/thingsboard/server/service/resource/sql/BaseTbResourceServiceTest.java +++ b/application/src/test/java/org/thingsboard/server/service/resource/sql/BaseTbResourceServiceTest.java @@ -682,7 +682,10 @@ public class BaseTbResourceServiceTest extends AbstractControllerTest { private AiModel constructValidOpenAiModel(String name) { var modelConfig = OpenAiChatModelConfig.builder() - .providerConfig(new OpenAiProviderConfig("test-api-key")) + .providerConfig(OpenAiProviderConfig.builder() + .baseUrl(OpenAiProviderConfig.OPENAI_OFFICIAL_BASE_URL) + .apiKey("test-api-key") + .build()) .modelId("gpt-4o") .temperature(0.5) .topP(0.3) @@ -699,6 +702,7 @@ public class BaseTbResourceServiceTest extends AbstractControllerTest { .configuration(modelConfig) .build(); } + @Test public void testFindTenantResourcesByTenantId() throws Exception { loginSysAdmin(); diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/ai/provider/OpenAiProviderConfig.java b/common/data/src/main/java/org/thingsboard/server/common/data/ai/provider/OpenAiProviderConfig.java index 09ffda837b..f7864db6e3 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/ai/provider/OpenAiProviderConfig.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/ai/provider/OpenAiProviderConfig.java @@ -15,8 +15,32 @@ */ package org.thingsboard.server.common.data.ai.provider; -import jakarta.validation.constraints.NotNull; +import com.fasterxml.jackson.annotation.JsonIgnore; +import jakarta.validation.constraints.AssertTrue; +import lombok.Builder; +import org.apache.commons.lang3.StringUtils; +import java.util.Objects; + +@Builder public record OpenAiProviderConfig( - @NotNull String apiKey -) implements AiProviderConfig {} + String baseUrl, + String apiKey +) implements AiProviderConfig { + + public static final String OPENAI_OFFICIAL_BASE_URL = "https://api.openai.com/v1"; + + public OpenAiProviderConfig { + baseUrl = Objects.requireNonNullElse(baseUrl, OPENAI_OFFICIAL_BASE_URL); + } + + @JsonIgnore + @AssertTrue(message = "API key is required when using the official OpenAI API") + public boolean isValid() { + if (baseUrl.equals(OPENAI_OFFICIAL_BASE_URL)) { + return StringUtils.isNotBlank(apiKey); + } + return true; + } + +} diff --git a/rule-engine/rule-engine-components/src/test/java/org/thingsboard/rule/engine/ai/TbAiNodeTest.java b/rule-engine/rule-engine-components/src/test/java/org/thingsboard/rule/engine/ai/TbAiNodeTest.java index c5b7f2c44b..a786cdd536 100644 --- a/rule-engine/rule-engine-components/src/test/java/org/thingsboard/rule/engine/ai/TbAiNodeTest.java +++ b/rule-engine/rule-engine-components/src/test/java/org/thingsboard/rule/engine/ai/TbAiNodeTest.java @@ -129,7 +129,10 @@ class TbAiNodeTest { config = new TbAiNodeConfiguration(); modelConfig = OpenAiChatModelConfig.builder() - .providerConfig(new OpenAiProviderConfig("test-api-key")) + .providerConfig(OpenAiProviderConfig.builder() + .baseUrl(OpenAiProviderConfig.OPENAI_OFFICIAL_BASE_URL) + .apiKey("test-api-key") + .build()) .modelId("gpt-4o") .temperature(0.5) .topP(0.3) From e167110c6751ae2165bfd4f6620c6fd744a5a646 Mon Sep 17 00:00:00 2001 From: nickAS21 Date: Mon, 29 Sep 2025 14:26:44 +0300 Subject: [PATCH 037/102] fix_lwm2m: convert Time to Long --- .../lwm2m/server/LwM2mTransportServerHelper.java | 11 ++++++++++- .../lwm2m/utils/LwM2mValueConverterImpl.java | 7 ++++--- 2 files changed, 14 insertions(+), 4 deletions(-) diff --git a/common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/server/LwM2mTransportServerHelper.java b/common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/server/LwM2mTransportServerHelper.java index ea3358de60..416547ef36 100644 --- a/common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/server/LwM2mTransportServerHelper.java +++ b/common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/server/LwM2mTransportServerHelper.java @@ -38,6 +38,7 @@ import java.io.ByteArrayInputStream; import java.io.IOException; import java.time.Instant; import java.util.ArrayList; +import java.util.Date; import java.util.List; import java.util.Map; import java.util.concurrent.atomic.AtomicLong; @@ -180,8 +181,16 @@ public class LwM2mTransportServerHelper { case BOOLEAN: kvProto.setType(BOOLEAN_V).setBoolV((Boolean) value).build(); break; - case STRING: case TIME: + if (value instanceof Date) { + kvProto.setType(TransportProtos.KeyValueType.LONG_V).setLongV(((Date) value).getTime()); + } else if (value instanceof Integer || value instanceof Long) { + kvProto.setType(TransportProtos.KeyValueType.LONG_V).setLongV((long) (value)); + } else { + kvProto.setType(TransportProtos.KeyValueType.STRING_V).setStringV(value.toString()); + } + break; + case STRING: case OPAQUE: case OBJLNK: kvProto.setType(TransportProtos.KeyValueType.STRING_V).setStringV((String) value); diff --git a/common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/utils/LwM2mValueConverterImpl.java b/common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/utils/LwM2mValueConverterImpl.java index 91d55305da..9d496a2f88 100644 --- a/common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/utils/LwM2mValueConverterImpl.java +++ b/common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/utils/LwM2mValueConverterImpl.java @@ -33,6 +33,7 @@ import java.util.Date; import static org.eclipse.leshan.core.model.ResourceModel.Type.NONE; import static org.eclipse.leshan.core.model.ResourceModel.Type.OPAQUE; +import static org.eclipse.leshan.core.model.ResourceModel.Type.TIME; @Slf4j public class LwM2mValueConverterImpl implements LwM2mValueConverter { @@ -58,7 +59,7 @@ public class LwM2mValueConverterImpl implements LwM2mValueConverter { currentType = OPAQUE; } - if (currentType == expectedType || currentType == NONE) { + if (currentType == expectedType || currentType == NONE || currentType == TIME) { /** expected type */ return value; } @@ -135,7 +136,7 @@ public class LwM2mValueConverterImpl implements LwM2mValueConverter { **/ } catch (IllegalArgumentException e) { log.debug("Unable to convert string to date", e); - throw new CodecException("Unable to convert string (%s) to date for resource %s", value, + throw new CodecException("Unable to convert string (%s) to %s for resource %s", value, TIME.name(), resourcePath); } default: @@ -149,7 +150,7 @@ public class LwM2mValueConverterImpl implements LwM2mValueConverter { case FLOAT: return String.valueOf(value); case TIME: - String DATE_FORMAT = "MMM d, yyyy HH:mm a"; + String DATE_FORMAT = "yyyy-MM-dd[[ ]['T']HH:mm[:ss[.SSS]][ ][XXX][Z][z][VV][O]]"; Long timeValue; try { timeValue = ((Date) value).getTime(); From 5e9ae421bea3b510e09fedc0932e0046b1a0a763 Mon Sep 17 00:00:00 2001 From: dashevchenko Date: Mon, 29 Sep 2025 16:16:15 +0300 Subject: [PATCH 038/102] added tests --- ...AbstractMqttTimeseriesIntegrationTest.java | 39 ++++++++++++++++++- ...ractMqttTimeseriesJsonIntegrationTest.java | 11 ++++++ ...actMqttTimeseriesProtoIntegrationTest.java | 25 ++++++++++++ 3 files changed, 74 insertions(+), 1 deletion(-) diff --git a/application/src/test/java/org/thingsboard/server/transport/mqtt/mqttv3/telemetry/timeseries/AbstractMqttTimeseriesIntegrationTest.java b/application/src/test/java/org/thingsboard/server/transport/mqtt/mqttv3/telemetry/timeseries/AbstractMqttTimeseriesIntegrationTest.java index 6543955ed5..9856aa18cb 100644 --- a/application/src/test/java/org/thingsboard/server/transport/mqtt/mqttv3/telemetry/timeseries/AbstractMqttTimeseriesIntegrationTest.java +++ b/application/src/test/java/org/thingsboard/server/transport/mqtt/mqttv3/telemetry/timeseries/AbstractMqttTimeseriesIntegrationTest.java @@ -25,10 +25,10 @@ import org.springframework.boot.test.mock.mockito.SpyBean; import org.thingsboard.common.util.JacksonUtil; import org.thingsboard.server.common.data.Device; import org.thingsboard.server.common.data.id.DeviceId; +import org.thingsboard.server.common.msg.gateway.metrics.GatewayMetadata; import org.thingsboard.server.transport.mqtt.AbstractMqttIntegrationTest; import org.thingsboard.server.transport.mqtt.MqttTestConfigProperties; import org.thingsboard.server.transport.mqtt.gateway.GatewayMetricsService; -import org.thingsboard.server.common.msg.gateway.metrics.GatewayMetadata; import org.thingsboard.server.transport.mqtt.gateway.metrics.GatewayMetricsState; import org.thingsboard.server.transport.mqtt.mqttv3.MqttTestCallback; import org.thingsboard.server.transport.mqtt.mqttv3.MqttTestClient; @@ -43,6 +43,7 @@ import java.util.Random; import java.util.Set; import java.util.concurrent.TimeUnit; +import static org.awaitility.Awaitility.await; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertNotNull; import static org.mockito.Mockito.any; @@ -112,6 +113,42 @@ public abstract class AbstractMqttTimeseriesIntegrationTest extends AbstractMqtt processGatewayTelemetryTest(GATEWAY_TELEMETRY_TOPIC, expectedKeys, payload.getBytes(), deviceName1, deviceName2); } + @Test + public void testAckIsReceivedOnFailedPublishMessage() throws Exception { + String devicePayload = "[{\"ts\": 10000, \"values\": " + PAYLOAD_VALUES_STR + "}]"; + String payloadA = "{\"Device A\": " + devicePayload + "}"; + + String deviceBPayload = "[{\"ts\": 10000, \"values\": " + PAYLOAD_VALUES_STR + "}]"; + String payloadB = "{\"Device B\": " + deviceBPayload + "}"; + + testAckIsReceivedOnFailedPublishMessage("Device A", payloadA.getBytes(), "Device B", payloadB.getBytes()); + } + + protected void testAckIsReceivedOnFailedPublishMessage(String deviceName1, byte[] payload1, String deviceName2, byte[] payload2) throws Exception { + updateDefaultTenantProfileConfig(profileConfiguration -> { + profileConfiguration.setMaxDevices(3); + }); + + MqttTestClient client = new MqttTestClient(); + client.connectAndWait(gatewayAccessToken); + client.publishAndWait(GATEWAY_TELEMETRY_TOPIC, payload1); + + // check device is created + await().atMost(TIMEOUT, TimeUnit.SECONDS).untilAsserted(() -> { + assertNotNull(doGet("/api/tenant/devices?deviceName=" + deviceName1, Device.class)); + }); + + client.publishAndWait(GATEWAY_TELEMETRY_TOPIC, payload2); + client.disconnectAndWait(); + + // check device was not created due to limit + doGet("/api/tenant/devices?deviceName=" + deviceName2).andExpect(status().isNotFound()); + + updateDefaultTenantProfileConfig(profileConfiguration -> { + profileConfiguration.setMaxDevices(0); + }); + } + @Test public void testGatewayConnect() throws Exception { String payload = "{\"device\":\"Device A\"}"; diff --git a/application/src/test/java/org/thingsboard/server/transport/mqtt/mqttv3/telemetry/timeseries/AbstractMqttTimeseriesJsonIntegrationTest.java b/application/src/test/java/org/thingsboard/server/transport/mqtt/mqttv3/telemetry/timeseries/AbstractMqttTimeseriesJsonIntegrationTest.java index a23506a160..92b35d896c 100644 --- a/application/src/test/java/org/thingsboard/server/transport/mqtt/mqttv3/telemetry/timeseries/AbstractMqttTimeseriesJsonIntegrationTest.java +++ b/application/src/test/java/org/thingsboard/server/transport/mqtt/mqttv3/telemetry/timeseries/AbstractMqttTimeseriesJsonIntegrationTest.java @@ -186,4 +186,15 @@ public abstract class AbstractMqttTimeseriesJsonIntegrationTest extends Abstract assertFalse(callback.isPubAckReceived()); } + @Override + public void testAckIsReceivedOnFailedPublishMessage() throws Exception { + MqttTestConfigProperties configProperties = MqttTestConfigProperties.builder() + .deviceName("Test Post Telemetry device json payload") + .gatewayName("Test Post Telemetry gateway json payload") + .transportPayloadType(TransportPayloadType.JSON) + .telemetryTopicFilter(POST_DATA_TELEMETRY_TOPIC) + .build(); + processBeforeTest(configProperties); + super.testAckIsReceivedOnFailedPublishMessage(); + } } diff --git a/application/src/test/java/org/thingsboard/server/transport/mqtt/mqttv3/telemetry/timeseries/AbstractMqttTimeseriesProtoIntegrationTest.java b/application/src/test/java/org/thingsboard/server/transport/mqtt/mqttv3/telemetry/timeseries/AbstractMqttTimeseriesProtoIntegrationTest.java index 1b6c247d96..60e5857c04 100644 --- a/application/src/test/java/org/thingsboard/server/transport/mqtt/mqttv3/telemetry/timeseries/AbstractMqttTimeseriesProtoIntegrationTest.java +++ b/application/src/test/java/org/thingsboard/server/transport/mqtt/mqttv3/telemetry/timeseries/AbstractMqttTimeseriesProtoIntegrationTest.java @@ -459,6 +459,31 @@ public abstract class AbstractMqttTimeseriesProtoIntegrationTest extends Abstrac assertFalse(callback.isPubAckReceived()); } + @Override + public void testAckIsReceivedOnFailedPublishMessage() throws Exception { + MqttTestConfigProperties configProperties = MqttTestConfigProperties.builder() + .deviceName("Test Post Telemetry device proto payload") + .gatewayName("Test Post Telemetry gateway proto payload") + .transportPayloadType(TransportPayloadType.PROTOBUF) + .telemetryTopicFilter(POST_DATA_TELEMETRY_TOPIC) + .build(); + processBeforeTest(configProperties); + + TransportApiProtos.GatewayTelemetryMsg.Builder gatewayTelemetryMsgProtoBuilder = TransportApiProtos.GatewayTelemetryMsg.newBuilder(); + List expectedKeys = Arrays.asList("key1", "key2", "key3", "key4", "key5"); + String deviceName1 = "Device A"; + String deviceName2 = "Device B"; + TransportApiProtos.TelemetryMsg deviceATelemetryMsgProto = getDeviceTelemetryMsgProto(deviceName1, expectedKeys, 10000, 20000); + gatewayTelemetryMsgProtoBuilder.addAllMsg(List.of(deviceATelemetryMsgProto)); + TransportApiProtos.GatewayTelemetryMsg payload1 = gatewayTelemetryMsgProtoBuilder.build(); + + TransportApiProtos.TelemetryMsg deviceBTelemetryMsgProto = getDeviceTelemetryMsgProto(deviceName2, expectedKeys, 10000, 20000); + TransportApiProtos.GatewayTelemetryMsg payload2 = TransportApiProtos.GatewayTelemetryMsg.newBuilder() + .addAllMsg(List.of(deviceBTelemetryMsgProto)) + .build(); + super.testAckIsReceivedOnFailedPublishMessage(deviceName1, payload1.toByteArray(), deviceName2, payload2.toByteArray()); + } + private DynamicSchema getDynamicSchema() { DeviceProfileTransportConfiguration transportConfiguration = deviceProfile.getProfileData().getTransportConfiguration(); assertTrue(transportConfiguration instanceof MqttDeviceProfileTransportConfiguration); From 22dc482b9ee43672ad99138833929de1e5c9aa33 Mon Sep 17 00:00:00 2001 From: ArtemDzhereleiko Date: Tue, 30 Sep 2025 09:47:30 +0300 Subject: [PATCH 039/102] UI: Add baseUrl for openAI provider --- .../ai-model/ai-model-dialog.component.html | 24 +++++++++--------- .../ai-model/ai-model-dialog.component.ts | 25 ++++++++++++++++++- .../src/app/shared/models/ai-model.models.ts | 2 +- .../assets/locale/locale.constant-en_US.json | 1 + 4 files changed, 38 insertions(+), 14 deletions(-) diff --git a/ui-ngx/src/app/modules/home/components/ai-model/ai-model-dialog.component.html b/ui-ngx/src/app/modules/home/components/ai-model/ai-model-dialog.component.html index abfe8500b4..5aa323fe7d 100644 --- a/ui-ngx/src/app/modules/home/components/ai-model/ai-model-dialog.component.html +++ b/ui-ngx/src/app/modules/home/components/ai-model/ai-model-dialog.component.html @@ -116,14 +116,24 @@ } + @if (providerFieldsList.includes('baseUrl')) { + + ai-models.baseurl + + + {{ 'ai-models.baseurl-required' | translate }} + + + } @if (providerFieldsList.includes('apiKey')) { ai-models.api-key - + - {{ 'ai-models.api-key-required' | translate }} + {{ ( provider === aiProvider.OPENAI ? 'ai-models.api-key-open-ai-required' : 'ai-models.api-key-required') | translate }} } @@ -158,16 +168,6 @@ } - @if (providerFieldsList.includes('baseUrl')) { - - ai-models.baseurl - - - {{ 'ai-models.baseurl-required' | translate }} - - - } @if (provider === aiProvider.OLLAMA) {
diff --git a/ui-ngx/src/app/modules/home/components/ai-model/ai-model-dialog.component.ts b/ui-ngx/src/app/modules/home/components/ai-model/ai-model-dialog.component.ts index 9d0d28e627..935f35ab5f 100644 --- a/ui-ngx/src/app/modules/home/components/ai-model/ai-model-dialog.component.ts +++ b/ui-ngx/src/app/modules/home/components/ai-model/ai-model-dialog.component.ts @@ -74,6 +74,8 @@ export class AIModelDialogComponent extends DialogComponent, protected router: Router, protected dialogRef: MatDialogRef, @@ -107,7 +109,7 @@ export class AIModelDialogComponent extends DialogComponent { + if (this.provider === AiProvider.OPENAI) { + this.updateApiKeyValidatorForOpenAIProvider(url); + } }); this.aiModelForms.get('configuration.providerConfig.auth.type').valueChanges.pipe( @@ -161,6 +175,15 @@ export class AIModelDialogComponent extends DialogComponent Date: Tue, 30 Sep 2025 10:34:37 +0300 Subject: [PATCH 040/102] UI: Add for apiKey required value --- .../home/components/ai-model/ai-model-dialog.component.html | 2 +- .../home/components/ai-model/ai-model-dialog.component.ts | 3 +++ 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/ui-ngx/src/app/modules/home/components/ai-model/ai-model-dialog.component.html b/ui-ngx/src/app/modules/home/components/ai-model/ai-model-dialog.component.html index 5aa323fe7d..44fe9dcda7 100644 --- a/ui-ngx/src/app/modules/home/components/ai-model/ai-model-dialog.component.html +++ b/ui-ngx/src/app/modules/home/components/ai-model/ai-model-dialog.component.html @@ -129,7 +129,7 @@ @if (providerFieldsList.includes('apiKey')) { ai-models.api-key - + diff --git a/ui-ngx/src/app/modules/home/components/ai-model/ai-model-dialog.component.ts b/ui-ngx/src/app/modules/home/components/ai-model/ai-model-dialog.component.ts index 935f35ab5f..02f18e60b5 100644 --- a/ui-ngx/src/app/modules/home/components/ai-model/ai-model-dialog.component.ts +++ b/ui-ngx/src/app/modules/home/components/ai-model/ai-model-dialog.component.ts @@ -74,6 +74,7 @@ export class AIModelDialogComponent extends DialogComponent, @@ -178,8 +179,10 @@ export class AIModelDialogComponent extends DialogComponent Date: Tue, 30 Sep 2025 10:37:57 +0300 Subject: [PATCH 041/102] UI: Improve Markdown widget: hide unused data settings block in configuration --- .../system/widget_types/markdown_html_card.json | 15 +++++++-------- 1 file changed, 7 insertions(+), 8 deletions(-) diff --git a/application/src/main/data/json/system/widget_types/markdown_html_card.json b/application/src/main/data/json/system/widget_types/markdown_html_card.json index 64a847952a..b66e3ee88e 100644 --- a/application/src/main/data/json/system/widget_types/markdown_html_card.json +++ b/application/src/main/data/json/system/widget_types/markdown_html_card.json @@ -11,16 +11,10 @@ "resources": [], "templateHtml": "\n", "templateCss": "#container tb-markdown-widget {\n height: 100%;\n display: block;\n}\n\n#container tb-markdown-widget .tb-markdown-view {\n height: 100%;\n overflow: auto;\n}\n", - "controllerScript": "self.onInit = function() {\n}\n\nself.onDataUpdated = function() {\n self.ctx.$scope.markdownWidget.onDataUpdated();\n}\n\nself.actionSources = function() {\n return {\n 'elementClick': {\n name: 'widget-action.element-click',\n multiple: true\n }\n };\n}\n\nself.typeParameters = function() {\n return {\n dataKeysOptional: true,\n datasourcesOptional: true,\n hasDataPageLink: true\n };\n}\n\nself.onDestroy = function() {\n}\n\n", - "settingsSchema": "", - "dataKeySettingsSchema": "", + "controllerScript": "self.onInit = function() {\n}\n\nself.onDataUpdated = function() {\n self.ctx.$scope.markdownWidget.onDataUpdated();\n}\n\nself.actionSources = function() {\n return {\n 'elementClick': {\n name: 'widget-action.element-click',\n multiple: true\n }\n };\n}\n\nself.typeParameters = function() {\n return {\n dataKeysOptional: true,\n datasourcesOptional: true,\n hasDataPageLink: true,\n hideDataSettings: true\n };\n}\n\nself.onDestroy = function() {\n}\n\n", "settingsDirective": "tb-markdown-widget-settings", - "defaultConfig": "{\"datasources\":[{\"type\":\"function\",\"name\":\"function\",\"dataKeys\":[{\"name\":\"f(x)\",\"type\":\"function\",\"label\":\"Random\",\"color\":\"#2196f3\",\"settings\":{},\"_hash\":0.15479322438769105,\"funcBody\":\"var value = prevValue + Math.random() * 100 - 50;\\nvar multiplier = Math.pow(10, 2 || 0);\\nvar value = Math.round(value * multiplier) / multiplier;\\nif (value < -1000) {\\n\\tvalue = -1000;\\n} else if (value > 1000) {\\n\\tvalue = 1000;\\n}\\nreturn value;\"}]}],\"timewindow\":{\"realtime\":{\"timewindowMs\":60000}},\"showTitle\":false,\"backgroundColor\":\"#fff\",\"color\":\"rgba(0, 0, 0, 0.87)\",\"padding\":\"0px\",\"settings\":{\"markdownTextPattern\":\"### Markdown/HTML card\\n - **Current entity**: ${entityName}.\\n - **Current value**: ${Random}.\",\"markdownTextFunction\":\"return '# Some title\\\\n - Entity name: ' + data[0]['entityName'];\",\"useMarkdownTextFunction\":false},\"title\":\"Markdown/HTML Card\",\"showTitleIcon\":false,\"iconColor\":\"rgba(0, 0, 0, 0.87)\",\"iconSize\":\"24px\",\"titleTooltip\":\"\",\"dropShadow\":true,\"enableFullscreen\":true,\"widgetStyle\":{},\"titleStyle\":{\"fontSize\":\"16px\",\"fontWeight\":400},\"showLegend\":false}" + "defaultConfig": "{\"datasources\":[{\"type\":\"function\",\"name\":\"function\",\"dataKeys\":[{\"name\":\"f(x)\",\"type\":\"function\",\"label\":\"Temperature\",\"color\":\"#2196f3\",\"settings\":{},\"_hash\":0.15479322438769105,\"funcBody\":\"const baseTemp = 20;\\nconst dailySwing = 10;\\nconst hourlyVariation = Math.sin((time % 24) * Math.PI / 12) * dailySwing;\\nconst randomness = (Math.random() - 0.5) * 2;\\nconst smoothingFactor = 0.8;\\nreturn (prevValue * smoothingFactor) + ((baseTemp + hourlyVariation + randomness) * (1 - smoothingFactor));\",\"aggregationType\":null,\"units\":null,\"decimals\":null,\"usePostProcessing\":null,\"postFuncBody\":null}],\"alarmFilterConfig\":{\"statusList\":[\"ACTIVE\"]}}],\"timewindow\":{\"realtime\":{\"timewindowMs\":60000}},\"showTitle\":false,\"backgroundColor\":\"#fff\",\"color\":\"rgba(0, 0, 0, 0.87)\",\"padding\":\"0px\",\"settings\":{\"useMarkdownTextFunction\":false,\"markdownTextPattern\":\"### Markdown/HTML card\\n - **Current entity**: ${entityName}.\\n - **Current value**: ${Temperature}.\",\"markdownTextFunction\":\"return '# Some title\\\\n - Entity name: ' + data[0]['entityName'];\",\"applyDefaultMarkdownStyle\":true,\"markdownCss\":\"\"},\"title\":\"Markdown/HTML Card\",\"showTitleIcon\":false,\"iconColor\":\"rgba(0, 0, 0, 0.87)\",\"iconSize\":\"24px\",\"titleTooltip\":\"\",\"dropShadow\":true,\"enableFullscreen\":true,\"widgetStyle\":{},\"titleStyle\":{\"fontSize\":\"16px\",\"fontWeight\":400},\"showLegend\":false,\"useDashboardTimewindow\":true,\"displayTimewindow\":true}" }, - "tags": [ - "web", - "markup" - ], "resources": [ { "link": "/api/images/system/markdown_html_card_system_widget_image.png", @@ -33,5 +27,10 @@ "data": "iVBORw0KGgoAAAANSUhEUgAAAMgAAACgCAMAAAB+IdObAAABdFBMVEX////u7u7g4ODf398nJydISEiCs/Tx8fE/Pz+amppgYGC6urr7+/upqan6+vrW1tbKysr19fX9/f3j4+M5OTn39/fs7OxSUlJxcXHFxcVCQkI8PDyMjIxFRUV+fn40NDTa2trp6el3d3dVVVXAwMCKiorQ0NClpaX6/P8vLy+SkpJbW1srKyvl5eWhoaHn5+dMTEytra2Hh4dlZWVPT0+3t7eenp6Pj49CjO7j7v3MzMx7e3uBgYE2NjabwvbR0dHIyMjV5vvY2NgyMjJra2tUl/A0hO3a6fzA2fqEhIQ4hu1XV1f3+v7Dw8OXl5eUlJT0+P6y0Pi0tLTc3NzV1dWysrKwsLCnyfeIt/U+ie6vr690dHRJSUkqfezy9/641Pnz8/NkoPJIj++rq6vg7fyszfh8r/Tt7e28vLySvfZZmvDR4/uNuvV2q/Pp8v1ppPJMku9oaGifxfctf+zT09NnZ2dfnvHL3/vG3fpup/Lt9P4vgO3lU88CAAAQR0lEQVR42uyby2/aQBDGJ7jINti8DOb9dAA1lEASHhIQJWp7QSiJ1JYcaA6VckivkdL/v4aPLbs1bkqLlZTmOwzKesa7P8WKP80QIq1s+P5xGeWQzWGYez+L/jFpsqFR2dz750GI5DIZe7sAohnk2wkQ8r2ArBQ6o6XCUXpEMS7TS5DmLQcyjaVNIS6UmBFdC8eQ/LSUX3psu6K0NjP2absg5538CiSRp6QpxIVKr3zGq7d/CJJQE2sz5fhWQcz7+gokeJxX3wX5iIqLRu+wkaaj1HBAdH6c+tyyQUKXTaoNC3GJSoVRifQy0Z0u51KFMEFYCedyBtEy8+0oVZXJN6pEyBi/zuV823y0HlYgtUrl0qrxERXX6sm9ek1vS8HTL3RyUIskJH/ivUrNbPBrUTKVYFcxP5+3moOUpNSOsiFUYcVMdyK0zGxmu2eKZOaDEcWQg8V0WvYIhIb7el+I0GG98rneIznYzZfoJE1EktI4IqoX5g/MPA71/mxU+dCX/KtHCCtEgQjLPFxEPdDtBo7waHkEcnU6ydxc8REVB7pp6ld7mWovG16CZK0DIrU6P9g8VtXZQaBxNZ2DJMOowgpAlpnHi9hQVbXmKYisyFY4ykdUHM+IZtXIhMhiIP7EtxxF2lH7YOfJUKgTKSnqpVLiQbBC9D5Iy8wzJfwpLkU6i9tqrxNbBUkpbzIXS5DwSUyJCREqTImmhUSgHSiWGAiFrBnlFOudQceZTJW0otkqajwIVoiC8c7+MvM68L5o0FXWurHXh5nk/hO9EOVyiESFFivRKP1K0btlZixEzdO5+8Z9ZPnp3+x/pmYmf/PheVmUZ6EdBQkW+h93ASQcOCp0dgHEVvmNuRsg9cBu/EaCmfBOgKSVwU781bq7+bobf37DrzKZzHQHQHbnhfgC8vRyAdlqLwuKkSBPQczZqC+tQD49IG7WyxJrhY4WL09ByuPbyoSBaL7DnqEhbtbLEmuFjpYoLx+tj0UGIrUtqy0hbtjLEmqxz7KjNaikrgjC3bwC0U8uGQiVG4EW4sa9LL4WQkcrEb/4eE4Q7uYVyEHg3mQgh7p+iLhxL4uvhdAIit1UDWLC3bx6tO7a+wBh+vNeFiSAkKz6RwThbt6A2L+MVubcCbJ5L8shdLRiIdKyPvyMu3kDEslPlJHwHvmLXpYodLTKmUb7RMPPuJtHj1brobX43GIvS+xoaS1ZuNvTv9mfnf4HkNbt7YDM5dwiJrgsp9xz3N2apyCRDz9A9i19QGeFbER0SpLfZernmuMUcrwEOcsGViAp9vcfTok7gHPq55LzVCDliboWBE4JUz/ptFJ4u5z6wV9hDuiWg30Gw1x/JDvdWvmQqLcnV1Nj3zZBqr2va0HglDD1k7KRo6yGqR/8FeaAbjlLb3A1HI9Up1sLJ+feQFLObrPmFpsPlrkeBBFTv/kjkffhsYG/whzQLYeZnPpld+xwawsQ5Ff07YEcZxqd00tXEEz98O7GIeGvMAd05ogg1/Xu2OnWSh2Wn1O3ByI9POgTSQCBR0LE1A+HxNQP/gpzQLccBkLUHTvdmlz8MgfJx7RkeksgkPhoMY+EiKkfDompH/wV5oBuOTzIGrfWVyw7/40/M/TohQgQeCRENvWDuKkf5oAuOY+6tYSp2VBRmTwDuYhbqnPq58lkUPK/eK1HQeCyNtHg9raFp4fmmkZ/x00hE1H0YLHNHJoIct7r9VoAgcvaCES39oloL9ldWE4lxjsuRKeQiYgcVLn0wXD1cZDxZwbC3h0bKbU/Dz20Lo55x4XoFDIRkYMqOLc/Bxle20EAgTtCtwo+Cu4I69x3rhhIqY1nohEh3nEhwlOx7lYwyDIRkYMqODd+X1RJ8dGwRuik4QxrQe5TelkEgTtCtwo+CqYC6/x3rpYgl2jAmVmNeMeFCE/FulsTi2UiIgdVcG78vqiSil+D2RY6aTjD+tGbfp8sCyBwR+hWwUcBBOvCd64AMpwSETpevONChKdi3a1WE5mIyGFV2J3fd16FlUIdnTScwQkCtfcFELgjvKnho+COsC585wogYx21F0So4kHgqfjuFjIRkYMq7M7vi6r5SlVFJw1nWAticn0teCe4I9wQPgruCOvCd64AclTBsUNEvONChKdi3a16HZmIyGFV2J3fF1X2SqIdQScNZ1gHUspwfS14J7gj3BA+Cu4I68J3rgAiZw2ycT7bgXdciPBUrLsVaCCTReSgCrvz+6LKeNdQcoROGs7wWF8L3klwR/BRcEdYF75zBRC6tWSi+6BgsGREeCp0t9ANQybiKpPtLuyLKjTSUIszbG5R4KPcV1QrfrH41Cl6Gv09T4VMxF/7txevxSRMCWMOj4SrLg6q/IVc5TGIGTx0gvglZ+cK7ghXXR3ULOluO70FCXfe99xA4H9Ej+QOgqv9CrnKU5BJffVoofuEKaEwB4TPgUfCVazIl6mKAUfEHJQGSo/lBKkV+/0BA0H3CVNCfg4InwN3xM8QE+1cLS3DEeGqrQOVvJcTZHZzVI+XfoCkiU0J+fEZfA4++RniRYOITQlxxdYUH95LBJme2AZYF0DU6s8g8DlwR/wMcZoiYlNCXLWlV8l7OUHCSuuu81UAwZQQvgj+Bz4H7oifIfriBrEpIa7aGl6T5xJBoFy+k9oTQDAlhC+C/4HPgTvCVbaSTbbZlBBXqalEyVO5/48VfnKdEkbvBI8kXo21ovBR7Gp0sr1fiCcWBe7o8at6j9z1TECenV5Anpt2CeQ7u3b0mjYQwHH8l/kTE03aNUZtrVFrO0Ps1TnbKjilYvIioxaq60v7IPRBXwvr/79LRre6MdhgGemW70vCwR33gQsEkn8kSfknSiBxK4HErQQStxJI3EogcSuBxK3/DTLR8njqvPcby28hSNEG+K6IIVbNCfddq13gefe8xFOP7q+vzj6C5mzh+ybzKCGCJci6ZPNPQPRZ/acQ9UOkkBEngG5ZAWQ7/OicN1LOpYTImz2YudnrEDK+ck6AG8m73AOObnD/eqI5O5AVjRRM4xV0Y5AxLoCTdnsQQF71nKKxDSjObIyCMbKNQoSQW68LrFmVkDbv0mwjx31OJWRlW69wS8v1JKTKsueNcWrDVPdNlE/hqp7KLGRL0UWTQ2zTCI5WfSRUT0KanlAtaphbns1K0RaurUQJeW/p8NOOhOxWTbNjS8hpannPXMlb4IINM2O7GHMKxS6bPb79RC4OpNctK/CZgez4EH2W4fAkgOyKBQwJybopOBKSHR2gK/JRHy1/wLUitJ6EoNmu2J6EXAP3LPMcaHMQPiNvKGe1mJrwulXqXJ2zDvcxGJlDdiV2Og1OdtOQkHDDc7ZM9wFYUNtiyTCmzEUNyeLOb4viTEKmwn8GEWIXmLEeQobMfFGpw1KrX5qqeA5p8h0ndzN1GkJqjyFki40QsqJVkv0FiCbSPlps7ogGcPoV0uvzBjfMyTE3NKDKAzRULi5YbmxA9JFaRsXmOoSc2SbqbGH/EPhEDbVjADqihxQF1wFky+2Me8J9glzqh7V8xkuvg7Fi7WzPCLb+kappqrzegMDnFE2KZQjR2D3KSsgb9uVVk9fKQLNXOLOOCpFC8GDpAQQfPd5lefIEwdz9oB9Z7GRdYM8mfQVQOASGXG1CDI5hqscIIYVbIaoSsvQpHiSk0HXpXQGGYD4yyGZ6ET9krvAlZYlfbVkIZxYzhWuef/tLeJlB2Et7aXSs/sxSNzb/MiHLSid9m8fXXixkswSSQF5KCSRuJZC4lUDiVgL53N699jQNxXEc/6HHTWVDGXL1Ak6Ezg0F5dISLRTsWmvFFZWuKqut28LuczO7wJv3dJTBiGgMooh8k2U77U6yT5om/ydLT1udEO/u3zOXbs1hv14yg936iR9HNt6N7xRNMnEVu/FpHFFdgxsr/BZIHyErAJ6T4K9DhsePgGQOQfQ8DpczD0Gy0nEhq4+BrsgqhYz51/sxMHfB76WQt3O9uO5/40CWJ4cDWP6KT3P0bamve+BzdwC0lWvAxOR4AE6P3s94XUjAZKBqWlGmEEEXkCvUGskcUN9GTAYkvQE466Rel5Jgc2YqwZgKzx0TErzhw8ORcBCTkScjsz3D5NnqAJn5unoL8+TeZQp5Ebq6OoQ7YWyQCdyc95PpMLkF2q3HmJy+QneCgm6v3Rnpda/IZgy1Wl5kwbOFfDQnW6JWF8EoFcgWtDKfzaNSQE6JFwwerBgvZRleiUvHhHSvbmHw9b0gNrawSN4Ok/foJQs3PgR8o4/paXpR1unx5e5Qz1Bo4QIZ85MLGBp0Ic8n0fWgBZkAPl5vQxosgwaFlIA0Dz5NFYmGWEO8wigSElGVQpo8nBcrQ6VyVsBxIcHnK2RqOoi+4PRlcn2YvKGQCLmIKbLl3CMbpAtYveKNLI9emX1/M+An/XhxyYUsjoQnP4HWvxAeDI23IXoTEFhHgHim9dY0PWZ5W0wmq5Zl7UgUUq47ypagWvwNkPXrkQ9XcTuIl0/uruxBwrNhXxeZcyDjZAo9kYd4efVSb+hJEB0QBCaC90BbCPZjcB+ilQ5DUh5bzVdsFHeSHMcxFCI2fjMkcJusO5DLQwMLZHkXMjMWWsN0eMpP/N5nT7vWIhfxgNzHF/KuEzL7ClMRH4ChBxgb3WpDOIVDpQ2hvxrFaBM5xQOUU2B0B5IvQBXbELt+bAjWRvsdyEaIfCGTLgTzkYmlm+RqyI/hy+TZDLBIFjFDBjohr27P3ngI2tLNkcHZ+TYEFcW22hAhWgLEFMDKgFQuGy1bosba+5C4oh0D0lnPJ3QW8KJVlw9H5vV1ftctwahmFnupsY5zKpwYNYGahr22mdM1omQ8rRQxG7U8P8xSsmUlvb/mTxfELaFVJPwkSU/FzofG/wsS22TwneJ5nKbOKCQhcykZgt4AwKU0FaqcTDkQoQ5niDXB5QRdaEOSKXo00Sim5NaGhiRB3gZMZ6Mute7gnMABgl7HCdcJkaKlDGuVMoYG2eYLIrgd0UMhdUPA5jaKVWhKM6+YLkQrV5oWBKWUsXlIiidtZGDngCinimneqKOh5JuGDr3MizwOdbIQhYHGMqgU0JAQiHLczjZim3Ujhz1IFtBEFyInoVYTghKDVkMhAxT2IEUNyHhQ00FfapRDMcqgs5OFGIAsAqkSEvGsWJW4KCjEKKANsQAu6kK2PVlxsyiwgJyFKAPxPQhMK8umYUhAUxd2LMuqcujsT0EKeQaGC0mx5kGIZLiQEs+gugepmQcgOZsDn6YLB5JUONrfuiLZFOo7wi6EyRkcjDpkCrFjiDddSNmEvMm5kEyJUbMZ1FKQqpwpMqpVQNqDhK0ztgZVx6H+FERWWIs1XQh4kUkpdsmBiHaZcyGmwpaUhguJWYYtZug+u0Zv9prBNrMoimy5rEOgI+6fvNk7YxJo1Tm0ahYOHGdiBz8nAiW9vS/BOFvoQpTd1cnWCfH8NIs96kyJjq/RdOexmpEtG+5nE0f0d2YtyfzR+JrAoYSKpuKHnQ+N55BT3jnktHUOOW2dIUifD2cgXx/uenEG8t5FT5/3n78mzkO0z8hjzX34BpPgTEZLPbVzAAAAAElFTkSuQmCC", "public": true } + ], + "scada": false, + "tags": [ + "web", + "markup" ] } \ No newline at end of file From 7eb22255f6b04d538752cf8cda0faa97b1a00183 Mon Sep 17 00:00:00 2001 From: Ekaterina Chantsova Date: Tue, 23 Sep 2025 18:45:55 +0300 Subject: [PATCH 042/102] Timewindow: fix options displaying when switching between Realtime and History --- .../timewindow-config-dialog.component.ts | 5 +- .../time/timewindow-panel.component.ts | 5 +- .../src/app/shared/models/time/time.models.ts | 52 +++++++++++-------- 3 files changed, 37 insertions(+), 25 deletions(-) diff --git a/ui-ngx/src/app/shared/components/time/timewindow-config-dialog.component.ts b/ui-ngx/src/app/shared/components/time/timewindow-config-dialog.component.ts index 1e1f831c4f..541b584682 100644 --- a/ui-ngx/src/app/shared/components/time/timewindow-config-dialog.component.ts +++ b/ui-ngx/src/app/shared/components/time/timewindow-config-dialog.component.ts @@ -411,9 +411,10 @@ export class TimewindowConfigDialogComponent extends PageComponent implements On const timewindowFormValue = this.timewindowForm.getRawValue(); const realtimeDisableCustomInterval = timewindowFormValue.realtime.disableCustomInterval; const historyDisableCustomInterval = timewindowFormValue.history.disableCustomInterval; - updateFormValuesOnTimewindowTypeChange(selectedTab, this.quickIntervalOnly, this.timewindowForm, + updateFormValuesOnTimewindowTypeChange(selectedTab, this.timewindowForm, realtimeDisableCustomInterval, historyDisableCustomInterval, - timewindowFormValue.realtime.advancedParams, timewindowFormValue.history.advancedParams); + timewindowFormValue.realtime.advancedParams, timewindowFormValue.history.advancedParams, + this.realtimeTimewindowOptions, this.historyTimewindowOptions); this.timewindowForm.patchValue({ hideAggregation: timewindowFormValue.hideAggregation, hideAggInterval: timewindowFormValue.hideAggInterval, diff --git a/ui-ngx/src/app/shared/components/time/timewindow-panel.component.ts b/ui-ngx/src/app/shared/components/time/timewindow-panel.component.ts index d7a0d44013..50d02cdd3e 100644 --- a/ui-ngx/src/app/shared/components/time/timewindow-panel.component.ts +++ b/ui-ngx/src/app/shared/components/time/timewindow-panel.component.ts @@ -400,9 +400,10 @@ export class TimewindowPanelComponent extends PageComponent implements OnInit, O } private onTimewindowTypeChange(selectedTab: TimewindowType) { - updateFormValuesOnTimewindowTypeChange(selectedTab, this.quickIntervalOnly, this.timewindowForm, + updateFormValuesOnTimewindowTypeChange(selectedTab, this.timewindowForm, this.realtimeDisableCustomInterval, this.historyDisableCustomInterval, - this.realtimeAdvancedParams, this.historyAdvancedParams); + this.realtimeAdvancedParams, this.historyAdvancedParams, + this.realtimeTimewindowOptions, this.historyTimewindowOptions); } update() { 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 c204cb6867..e3a5eaef37 100644 --- a/ui-ngx/src/app/shared/models/time/time.models.ts +++ b/ui-ngx/src/app/shared/models/time/time.models.ts @@ -20,6 +20,7 @@ import moment_ from 'moment'; import * as momentTz from 'moment-timezone'; import { IntervalType } from '@shared/models/telemetry/telemetry.models'; import { FormGroup } from '@angular/forms'; +import { ToggleHeaderOption } from '@shared/components/toggle-header.component'; const moment = moment_; @@ -526,17 +527,20 @@ export const timewindowTypeChanged = (newTimewindow: Timewindow, oldTimewindow: }; export const updateFormValuesOnTimewindowTypeChange = (selectedTab: TimewindowType, - quickIntervalOnly: boolean, timewindowForm: FormGroup, + timewindowForm: FormGroup, realtimeDisableCustomInterval: boolean, historyDisableCustomInterval: boolean, - realtimeAdvancedParams?: TimewindowAdvancedParams, - historyAdvancedParams?: TimewindowAdvancedParams) => { + realtimeAdvancedParams: TimewindowAdvancedParams, + historyAdvancedParams: TimewindowAdvancedParams, + realtimeTimewindowOptions: ToggleHeaderOption[], + historyTimewindowOptions: ToggleHeaderOption[]) => { const timewindowFormValue = timewindowForm.getRawValue(); if (selectedTab === TimewindowType.REALTIME) { - if (timewindowFormValue.history.historyType !== HistoryWindowType.FIXED - && !(quickIntervalOnly && timewindowFormValue.history.historyType === HistoryWindowType.LAST_INTERVAL)) { - if (Object.keys(RealtimeWindowType).includes(HistoryWindowType[timewindowFormValue.history.historyType])) { - timewindowForm.get('realtime.realtimeType').patchValue(RealtimeWindowType[HistoryWindowType[timewindowFormValue.history.historyType]]); - } + const sameWindowTypeOptionAvailable = realtimeTimewindowOptions.some( + option => { + return option.value === RealtimeWindowType[HistoryWindowType[timewindowFormValue.history.historyType]] + }); + if (sameWindowTypeOptionAvailable) { + timewindowForm.get('realtime.realtimeType').patchValue(RealtimeWindowType[HistoryWindowType[timewindowFormValue.history.historyType]]); if (!realtimeDisableCustomInterval || !realtimeAdvancedParams?.allowedLastIntervals?.length || realtimeAdvancedParams.allowedLastIntervals.includes(timewindowFormValue.history.timewindowMs)) { timewindowForm.get('realtime.timewindowMs').patchValue(timewindowFormValue.history.timewindowMs); @@ -554,20 +558,26 @@ export const updateFormValuesOnTimewindowTypeChange = (selectedTab: TimewindowTy } } } else { - timewindowForm.get('history.historyType').patchValue(HistoryWindowType[RealtimeWindowType[timewindowFormValue.realtime.realtimeType]]); - if (!historyDisableCustomInterval || + const sameWindowTypeOptionAvailable = historyTimewindowOptions.some( + option => { + return option.value === HistoryWindowType[RealtimeWindowType[timewindowFormValue.realtime.realtimeType]] + }); + if (sameWindowTypeOptionAvailable) { + timewindowForm.get('history.historyType').patchValue(HistoryWindowType[RealtimeWindowType[timewindowFormValue.realtime.realtimeType]]); + if (!historyDisableCustomInterval || !historyAdvancedParams?.allowedLastIntervals?.length || historyAdvancedParams.allowedLastIntervals?.includes(timewindowFormValue.realtime.timewindowMs)) { - timewindowForm.get('history.timewindowMs').patchValue(timewindowFormValue.realtime.timewindowMs); - } - if (!historyAdvancedParams?.allowedQuickIntervals?.length || historyAdvancedParams.allowedQuickIntervals?.includes(timewindowFormValue.realtime.quickInterval)) { - timewindowForm.get('history.quickInterval').patchValue(timewindowFormValue.realtime.quickInterval); - } - const defaultAggInterval = historyDefaultAggInterval(timewindowForm.getRawValue(), historyAdvancedParams); - const allowedAggIntervals = historyAllowedAggIntervals(timewindowForm.getRawValue(), historyAdvancedParams); - if (defaultAggInterval || !allowedAggIntervals.length || allowedAggIntervals.includes(timewindowFormValue.realtime.interval)) { - setTimeout(() => timewindowForm.get('history.interval').patchValue( - defaultAggInterval ?? timewindowFormValue.realtime.interval - )); + timewindowForm.get('history.timewindowMs').patchValue(timewindowFormValue.realtime.timewindowMs); + } + if (!historyAdvancedParams?.allowedQuickIntervals?.length || historyAdvancedParams.allowedQuickIntervals?.includes(timewindowFormValue.realtime.quickInterval)) { + timewindowForm.get('history.quickInterval').patchValue(timewindowFormValue.realtime.quickInterval); + } + const defaultAggInterval = historyDefaultAggInterval(timewindowForm.getRawValue(), historyAdvancedParams); + const allowedAggIntervals = historyAllowedAggIntervals(timewindowForm.getRawValue(), historyAdvancedParams); + if (defaultAggInterval || !allowedAggIntervals.length || allowedAggIntervals.includes(timewindowFormValue.realtime.interval)) { + setTimeout(() => timewindowForm.get('history.interval').patchValue( + defaultAggInterval ?? timewindowFormValue.realtime.interval + )); + } } } timewindowForm.patchValue({ From 5a6ddce8f54ec5ec8ac0987fb5d672feea262e92 Mon Sep 17 00:00:00 2001 From: VIacheslavKlimov Date: Fri, 26 Sep 2025 13:16:31 +0300 Subject: [PATCH 043/102] AI Request Node: added ability to attach files (#13910) --- .../server/actors/ActorSystemContext.java | 5 + .../actors/ruleChain/DefaultTbContext.java | 21 +- .../controller/TbResourceController.java | 17 ++ ...faultTbCalculatedFieldConsumerService.java | 4 +- .../queue/DefaultTbClusterService.java | 9 +- .../queue/DefaultTbCoreConsumerService.java | 4 +- .../queue/DefaultTbEdgeConsumerService.java | 2 +- .../DefaultTbRuleEngineConsumerService.java | 4 +- .../processing/AbstractConsumerService.java | 5 + ...AbstractPartitionBasedConsumerService.java | 4 +- .../resource/DefaultTbResourceService.java | 1 - .../src/main/resources/thingsboard.yml | 3 + .../controller/TbResourceControllerTest.java | 23 +- .../DefaultResourceDataCacheTest.java | 83 +++++++ .../sql/BaseTbResourceServiceTest.java | 121 +++++++++- .../server/dao/resource/ResourceService.java | 5 + .../dao/resource/TbResourceDataCache.java | 28 +++ .../common/data/GeneralFileDescriptor.java | 29 +++ .../server/common/data/ResourceType.java | 3 +- .../server/common/data/TbResource.java | 6 + .../common/data/TbResourceDataInfo.java | 31 +++ .../common/data/TbResourceDeleteResult.java | 3 +- .../thingsboard/common/util/DonAsynchron.java | 15 ++ .../server/dao/ResourceContainerDao.java | 5 +- .../server/dao/resource/BaseImageService.java | 5 +- .../dao/resource/BaseResourceService.java | 72 ++++-- .../resource/DefaultTbResourceDataCache.java | 72 ++++++ .../server/dao/resource/TbResourceDao.java | 2 + .../dao/resource/TbResourceInfoDao.java | 2 + .../server/dao/rule/RuleChainDao.java | 4 +- .../dashboard/DashboardInfoRepository.java | 16 +- .../sql/dashboard/JpaDashboardInfoDao.java | 10 +- .../dao/sql/resource/JpaTbResourceDao.java | 6 + .../sql/resource/JpaTbResourceInfoDao.java | 8 + .../resource/TbResourceInfoRepository.java | 6 + .../sql/resource/TbResourceRepository.java | 3 + .../server/dao/sql/rule/JpaRuleChainDao.java | 12 + .../dao/sql/rule/RuleChainRepository.java | 15 ++ .../dao/sql/widget/JpaWidgetTypeDao.java | 10 +- .../sql/widget/WidgetTypeInfoRepository.java | 15 +- .../dao/sql/rule/JpaRuleNodeDaoTest.java | 1 + .../rule/engine/api/TbContext.java | 7 + .../thingsboard/rule/engine/ai/TbAiNode.java | 121 +++++++++- .../rule/engine/ai/TbAiNodeConfiguration.java | 6 +- .../rule/engine/ai/TbAiNodeTest.java | 223 ++++++++++++++++++ 45 files changed, 965 insertions(+), 82 deletions(-) create mode 100644 application/src/test/java/org/thingsboard/server/service/resource/DefaultResourceDataCacheTest.java create mode 100644 common/dao-api/src/main/java/org/thingsboard/server/dao/resource/TbResourceDataCache.java create mode 100644 common/data/src/main/java/org/thingsboard/server/common/data/GeneralFileDescriptor.java create mode 100644 common/data/src/main/java/org/thingsboard/server/common/data/TbResourceDataInfo.java create mode 100644 dao/src/main/java/org/thingsboard/server/dao/resource/DefaultTbResourceDataCache.java 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 ea46ce86eb..b23015a1fe 100644 --- a/application/src/main/java/org/thingsboard/server/actors/ActorSystemContext.java +++ b/application/src/main/java/org/thingsboard/server/actors/ActorSystemContext.java @@ -97,6 +97,7 @@ import org.thingsboard.server.dao.ota.OtaPackageService; import org.thingsboard.server.dao.queue.QueueService; import org.thingsboard.server.dao.queue.QueueStatsService; import org.thingsboard.server.dao.relation.RelationService; +import org.thingsboard.server.dao.resource.TbResourceDataCache; import org.thingsboard.server.dao.resource.ResourceService; import org.thingsboard.server.dao.rule.RuleChainService; import org.thingsboard.server.dao.rule.RuleNodeStateService; @@ -511,6 +512,10 @@ public class ActorSystemContext { @Getter private ResourceService resourceService; + @Autowired + @Getter + private TbResourceDataCache resourceDataCache; + @Lazy @Autowired(required = false) @Getter diff --git a/application/src/main/java/org/thingsboard/server/actors/ruleChain/DefaultTbContext.java b/application/src/main/java/org/thingsboard/server/actors/ruleChain/DefaultTbContext.java index 6374e4016d..88b04c7613 100644 --- a/application/src/main/java/org/thingsboard/server/actors/ruleChain/DefaultTbContext.java +++ b/application/src/main/java/org/thingsboard/server/actors/ruleChain/DefaultTbContext.java @@ -51,6 +51,7 @@ import org.thingsboard.server.common.data.Device; import org.thingsboard.server.common.data.DeviceProfile; import org.thingsboard.server.common.data.EntityType; import org.thingsboard.server.common.data.HasRuleEngineProfile; +import org.thingsboard.server.common.data.HasTenantId; import org.thingsboard.server.common.data.StringUtils; import org.thingsboard.server.common.data.TenantProfile; import org.thingsboard.server.common.data.alarm.Alarm; @@ -60,6 +61,7 @@ import org.thingsboard.server.common.data.id.AssetId; import org.thingsboard.server.common.data.id.CustomerId; import org.thingsboard.server.common.data.id.DeviceId; import org.thingsboard.server.common.data.id.EntityId; +import org.thingsboard.server.common.data.id.HasId; import org.thingsboard.server.common.data.id.RuleChainId; import org.thingsboard.server.common.data.id.RuleNodeId; import org.thingsboard.server.common.data.id.TenantId; @@ -110,6 +112,7 @@ import org.thingsboard.server.dao.ota.OtaPackageService; import org.thingsboard.server.dao.queue.QueueService; import org.thingsboard.server.dao.queue.QueueStatsService; import org.thingsboard.server.dao.relation.RelationService; +import org.thingsboard.server.dao.resource.TbResourceDataCache; import org.thingsboard.server.dao.resource.ResourceService; import org.thingsboard.server.dao.rule.RuleChainService; import org.thingsboard.server.dao.tenant.TenantService; @@ -770,6 +773,11 @@ public class DefaultTbContext implements TbContext { return mainCtx.getResourceService(); } + @Override + public TbResourceDataCache getTbResourceDataCache() { + return mainCtx.getResourceDataCache(); + } + @Override public OtaPackageService getOtaPackageService() { return mainCtx.getOtaPackageService(); @@ -1054,7 +1062,18 @@ public class DefaultTbContext implements TbContext { @Override public void checkTenantEntity(EntityId entityId) throws TbNodeException { - if (!this.getTenantId().equals(TenantIdLoader.findTenantId(this, entityId))) { + TenantId actualTenantId = TenantIdLoader.findTenantId(this, entityId); + assertSameTenantId(actualTenantId, entityId); + } + + @Override + public & HasTenantId, I extends EntityId> void checkTenantEntity(E entity) throws TbNodeException { + TenantId actualTenantId = entity.getTenantId(); + assertSameTenantId(actualTenantId, entity.getId()); + } + + private void assertSameTenantId(TenantId tenantId, EntityId entityId) throws TbNodeException { + if (!getTenantId().equals(tenantId)) { throw new TbNodeException("Entity with id: '" + entityId + "' specified in the configuration doesn't belong to the current tenant.", true); } } diff --git a/application/src/main/java/org/thingsboard/server/controller/TbResourceController.java b/application/src/main/java/org/thingsboard/server/controller/TbResourceController.java index b23603f6a2..54d2494679 100644 --- a/application/src/main/java/org/thingsboard/server/controller/TbResourceController.java +++ b/application/src/main/java/org/thingsboard/server/controller/TbResourceController.java @@ -55,14 +55,17 @@ import org.thingsboard.server.common.data.util.ThrowingSupplier; import org.thingsboard.server.config.annotations.ApiOperation; import org.thingsboard.server.queue.util.TbCoreComponent; import org.thingsboard.server.service.resource.TbResourceService; +import org.thingsboard.server.service.security.model.SecurityUser; import org.thingsboard.server.service.security.permission.Operation; import org.thingsboard.server.service.security.permission.Resource; +import java.util.ArrayList; import java.util.Collections; import java.util.EnumSet; import java.util.HashSet; import java.util.List; import java.util.Set; +import java.util.UUID; import static org.thingsboard.server.controller.ControllerConstants.AVAILABLE_FOR_ANY_AUTHORIZED_USER; import static org.thingsboard.server.controller.ControllerConstants.LWM2M_OBJECT_DESCRIPTION; @@ -263,6 +266,20 @@ public class TbResourceController extends BaseController { } } + @ApiOperation(value = "Get Resource Infos by ids (getSystemOrTenantResourcesByIds)") + @PreAuthorize("hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN')") + @GetMapping(value = "/resource", params = {"resourceIds"}) + public List getSystemOrTenantResourcesByIds( + @Parameter(description = "A list of resource ids, separated by comma ','", array = @ArraySchema(schema = @Schema(type = "string"))) + @RequestParam("resourceIds") Set resourceUuids) throws ThingsboardException { + SecurityUser user = getCurrentUser(); + List resourceIds = new ArrayList<>(); + for (UUID resourceId : resourceUuids) { + resourceIds.add(new TbResourceId(resourceId)); + } + return resourceService.findSystemOrTenantResourcesByIds(user.getTenantId(), resourceIds); + } + @ApiOperation(value = "Get All Resource Infos (getAllResources)", notes = "Returns a page of Resource Info objects owned by tenant. " + PAGE_DATA_PARAMETERS + RESOURCE_INFO_DESCRIPTION + TENANT_AUTHORITY_PARAGRAPH) diff --git a/application/src/main/java/org/thingsboard/server/service/queue/DefaultTbCalculatedFieldConsumerService.java b/application/src/main/java/org/thingsboard/server/service/queue/DefaultTbCalculatedFieldConsumerService.java index 8d4ab25578..acb36449e8 100644 --- a/application/src/main/java/org/thingsboard/server/service/queue/DefaultTbCalculatedFieldConsumerService.java +++ b/application/src/main/java/org/thingsboard/server/service/queue/DefaultTbCalculatedFieldConsumerService.java @@ -35,6 +35,7 @@ import org.thingsboard.server.common.msg.plugin.ComponentLifecycleMsg; import org.thingsboard.server.common.msg.queue.ServiceType; import org.thingsboard.server.common.msg.queue.TbCallback; import org.thingsboard.server.common.msg.queue.TopicPartitionInfo; +import org.thingsboard.server.dao.resource.TbResourceDataCache; import org.thingsboard.server.dao.tenant.TbTenantProfileCache; import org.thingsboard.server.gen.transport.TransportProtos.CalculatedFieldLinkedTelemetryMsgProto; import org.thingsboard.server.gen.transport.TransportProtos.CalculatedFieldTelemetryMsgProto; @@ -83,6 +84,7 @@ public class DefaultTbCalculatedFieldConsumerService extends AbstractPartitionBa ActorSystemContext actorContext, TbDeviceProfileCache deviceProfileCache, TbAssetProfileCache assetProfileCache, + TbResourceDataCache tbResourceDataCache, TbTenantProfileCache tenantProfileCache, TbApiUsageStateService apiUsageStateService, PartitionService partitionService, @@ -90,7 +92,7 @@ public class DefaultTbCalculatedFieldConsumerService extends AbstractPartitionBa JwtSettingsService jwtSettingsService, CalculatedFieldCache calculatedFieldCache, CalculatedFieldStateService stateService) { - super(actorContext, tenantProfileCache, deviceProfileCache, assetProfileCache, calculatedFieldCache, apiUsageStateService, partitionService, + super(actorContext, tenantProfileCache, deviceProfileCache, assetProfileCache, tbResourceDataCache, calculatedFieldCache, apiUsageStateService, partitionService, eventPublisher, jwtSettingsService); this.queueFactory = tbQueueFactory; this.stateService = stateService; diff --git a/application/src/main/java/org/thingsboard/server/service/queue/DefaultTbClusterService.java b/application/src/main/java/org/thingsboard/server/service/queue/DefaultTbClusterService.java index 265f14c4e2..86d00c77ca 100644 --- a/application/src/main/java/org/thingsboard/server/service/queue/DefaultTbClusterService.java +++ b/application/src/main/java/org/thingsboard/server/service/queue/DefaultTbClusterService.java @@ -48,6 +48,7 @@ import org.thingsboard.server.common.data.id.DeviceProfileId; import org.thingsboard.server.common.data.id.EdgeId; import org.thingsboard.server.common.data.id.EntityId; import org.thingsboard.server.common.data.id.RuleChainId; +import org.thingsboard.server.common.data.id.TbResourceId; import org.thingsboard.server.common.data.id.TenantId; import org.thingsboard.server.common.data.msg.TbMsgType; import org.thingsboard.server.common.data.page.PageData; @@ -435,8 +436,9 @@ public class DefaultTbClusterService implements TbClusterService { @Override public void onResourceChange(TbResourceInfo resource, TbQueueCallback callback) { + TenantId tenantId = resource.getTenantId(); + TbResourceId resourceId = resource.getId(); if (resource.getResourceType() == ResourceType.LWM2M_MODEL) { - TenantId tenantId = resource.getTenantId(); log.trace("[{}][{}][{}] Processing change resource", tenantId, resource.getResourceType(), resource.getResourceKey()); ResourceUpdateMsg resourceUpdateMsg = ResourceUpdateMsg.newBuilder() .setTenantIdMSB(tenantId.getId().getMostSignificantBits()) @@ -447,6 +449,7 @@ public class DefaultTbClusterService implements TbClusterService { ToTransportMsg transportMsg = ToTransportMsg.newBuilder().setResourceUpdateMsg(resourceUpdateMsg).build(); broadcast(transportMsg, DataConstants.LWM2M_TRANSPORT_NAME, callback); } + broadcastEntityStateChangeEvent(tenantId, resourceId, ComponentLifecycleEvent.UPDATED); } @Override @@ -462,6 +465,7 @@ public class DefaultTbClusterService implements TbClusterService { ToTransportMsg transportMsg = ToTransportMsg.newBuilder().setResourceDeleteMsg(resourceDeleteMsg).build(); broadcast(transportMsg, DataConstants.LWM2M_TRANSPORT_NAME, callback); } + broadcastEntityStateChangeEvent(resource.getTenantId(), resource.getId(), ComponentLifecycleEvent.DELETED); } private void broadcastEntityChangeToTransport(TenantId tenantId, EntityId entityid, T entity, TbQueueCallback callback) { @@ -592,7 +596,8 @@ public class DefaultTbClusterService implements TbClusterService { EntityType.TENANT_PROFILE, EntityType.DEVICE_PROFILE, EntityType.ASSET_PROFILE, - EntityType.JOB) + EntityType.JOB, + EntityType.TB_RESOURCE) || (entityType == EntityType.ASSET && msg.getEvent() == ComponentLifecycleEvent.UPDATED) || (entityType == EntityType.DEVICE && msg.getEvent() == ComponentLifecycleEvent.UPDATED) ) { diff --git a/application/src/main/java/org/thingsboard/server/service/queue/DefaultTbCoreConsumerService.java b/application/src/main/java/org/thingsboard/server/service/queue/DefaultTbCoreConsumerService.java index f0b1a4d7d2..9ab5a062eb 100644 --- a/application/src/main/java/org/thingsboard/server/service/queue/DefaultTbCoreConsumerService.java +++ b/application/src/main/java/org/thingsboard/server/service/queue/DefaultTbCoreConsumerService.java @@ -55,6 +55,7 @@ import org.thingsboard.server.common.stats.StatsFactory; import org.thingsboard.server.common.util.KvProtoUtil; import org.thingsboard.server.common.util.ProtoUtils; import org.thingsboard.server.dao.resource.ImageCacheKey; +import org.thingsboard.server.dao.resource.TbResourceDataCache; import org.thingsboard.server.dao.tenant.TbTenantProfileCache; import org.thingsboard.server.gen.transport.TransportProtos; import org.thingsboard.server.gen.transport.TransportProtos.DeviceStateServiceMsgProto; @@ -176,10 +177,11 @@ public class DefaultTbCoreConsumerService extends AbstractConsumerService>>() { + var widgetTypeInfos = JacksonUtil.readValue(referenceValues.toString(), new TypeReference>>() { }); Assert.assertNotNull(widgetTypeInfos); Assert.assertFalse(widgetTypeInfos.isEmpty()); Assert.assertEquals(1, widgetTypeInfos.size()); - var dashboardInfo = widgetTypeInfos.get(EntityType.WIDGET_TYPE.name()).get(0); - Assert.assertNotNull(dashboardInfo); - - WidgetTypeInfo foundedWidgetType = doGet("/api/widgetTypeInfo/" + savedWidgetType.getId().getId().toString(), WidgetTypeInfo.class); - Assert.assertNotNull(foundedWidgetType); - Assert.assertEquals(foundedWidgetType, dashboardInfo); + var widgetTypeInfo = widgetTypeInfos.get(EntityType.WIDGET_TYPE.name()).get(0); + Assert.assertNotNull(widgetTypeInfo); + Assert.assertEquals(new EntityInfo(savedWidgetType.getId(), savedWidgetType.getName()), widgetTypeInfo); } @Test @@ -372,7 +370,7 @@ public class TbResourceControllerTest extends AbstractControllerTest { Assert.assertTrue(isSuccess); var referenceValues = JacksonUtil.toJsonNode(deleteResponse).get("references"); - var widgetTypeInfos = JacksonUtil.readValue(referenceValues.toString(), new TypeReference>>() { + var widgetTypeInfos = JacksonUtil.readValue(referenceValues.toString(), new TypeReference>>() { }); Assert.assertNull(widgetTypeInfos); } @@ -417,7 +415,7 @@ public class TbResourceControllerTest extends AbstractControllerTest { var referenceValues = JacksonUtil.toJsonNode(deleteResponse).get("references"); Assert.assertNotNull(referenceValues); - var dashboardInfos = JacksonUtil.readValue(referenceValues.toString(), new TypeReference>>() { + var dashboardInfos = JacksonUtil.readValue(referenceValues.toString(), new TypeReference>>() { }); Assert.assertNotNull(dashboardInfos); Assert.assertFalse(dashboardInfos.isEmpty()); @@ -425,10 +423,7 @@ public class TbResourceControllerTest extends AbstractControllerTest { var dashboardInfo = dashboardInfos.get(EntityType.DASHBOARD.name()).get(0); Assert.assertNotNull(dashboardInfo); - - DashboardInfo foundDashboard = doGet("/api/dashboard/info/" + savedDashboard.getId().getId().toString(), DashboardInfo.class); - Assert.assertNotNull(foundDashboard); - Assert.assertEquals(foundDashboard, dashboardInfo); + Assert.assertEquals(new EntityInfo(savedDashboard.getId(), savedDashboard.getName()), dashboardInfo); } @Test @@ -469,7 +464,7 @@ public class TbResourceControllerTest extends AbstractControllerTest { Assert.assertTrue(isSuccess); var referenceValues = JacksonUtil.toJsonNode(deleteResponse).get("references"); - var dashboardInfos = JacksonUtil.readValue(referenceValues.toString(), new TypeReference>>() { + var dashboardInfos = JacksonUtil.readValue(referenceValues.toString(), new TypeReference>>() { }); Assert.assertNull(dashboardInfos); } diff --git a/application/src/test/java/org/thingsboard/server/service/resource/DefaultResourceDataCacheTest.java b/application/src/test/java/org/thingsboard/server/service/resource/DefaultResourceDataCacheTest.java new file mode 100644 index 0000000000..f12a8d3c5d --- /dev/null +++ b/application/src/test/java/org/thingsboard/server/service/resource/DefaultResourceDataCacheTest.java @@ -0,0 +1,83 @@ +/** + * 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.resource; + +import org.junit.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.test.context.bean.override.mockito.MockitoSpyBean; +import org.thingsboard.common.util.JacksonUtil; +import org.thingsboard.server.common.data.GeneralFileDescriptor; +import org.thingsboard.server.common.data.ResourceType; +import org.thingsboard.server.common.data.TbResource; +import org.thingsboard.server.common.data.TbResourceDataInfo; +import org.thingsboard.server.common.data.TbResourceInfo; +import org.thingsboard.server.controller.AbstractControllerTest; +import org.thingsboard.server.dao.resource.ResourceService; +import org.thingsboard.server.dao.resource.TbResourceDataCache; +import org.thingsboard.server.dao.service.DaoSqlTest; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.clearInvocations; +import static org.mockito.Mockito.timeout; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoMoreInteractions; + +@DaoSqlTest +public class DefaultResourceDataCacheTest extends AbstractControllerTest { + + @MockitoSpyBean + private ResourceService resourceService; + @Autowired + private TbResourceService tbResourceService; + @MockitoSpyBean + private TbResourceDataCache resourceDataCache; + + @Test + public void testGetCachedResourceData() throws Exception { + loginTenantAdmin(); + + TbResource resource = new TbResource(); + resource.setTenantId(tenantId); + resource.setTitle("File for AI request"); + resource.setResourceType(ResourceType.GENERAL); + resource.setFileName("myTestJson.json"); + GeneralFileDescriptor descriptor = new GeneralFileDescriptor("application/json"); + resource.setDescriptorValue(descriptor); + byte[] data = "This is a test prompt for AI request.".getBytes(); + resource.setData(data); + TbResourceInfo savedResource = tbResourceService.save(resource); + verify(resourceDataCache, timeout(2000).times(1)).evictResourceData(tenantId, savedResource.getId()); + + TbResourceDataInfo cachedData = resourceDataCache.getResourceDataInfoAsync(tenantId, savedResource.getId()).get(); + assertThat(cachedData.getData()).isEqualTo(data); + assertThat(JacksonUtil.treeToValue(cachedData.getDescriptor(), GeneralFileDescriptor.class)).isEqualTo(descriptor); + verify(resourceService).getResourceDataInfo(tenantId, savedResource.getId()); + + // retrieve resource data second time + clearInvocations(resourceService); + TbResourceDataInfo cachedData2 = resourceDataCache.getResourceDataInfoAsync(tenantId, savedResource.getId()).get(); + assertThat(cachedData2.getData()).isEqualTo(data); + verifyNoMoreInteractions(resourceService); + + // delete resource, check cache + TbResource resourceById = resourceService.findResourceById(tenantId, savedResource.getId()); + tbResourceService.delete(resourceById, true, null); + verify(resourceDataCache, timeout(2000).times(2)).evictResourceData(tenantId, savedResource.getId()); + TbResourceDataInfo cachedDataAfterDeletion = resourceDataCache.getResourceDataInfoAsync(tenantId, savedResource.getId()).get(); + assertThat(cachedDataAfterDeletion).isEqualTo(null); + } + +} diff --git a/application/src/test/java/org/thingsboard/server/service/resource/sql/BaseTbResourceServiceTest.java b/application/src/test/java/org/thingsboard/server/service/resource/sql/BaseTbResourceServiceTest.java index fe416eacd5..da20b9489c 100644 --- a/application/src/test/java/org/thingsboard/server/service/resource/sql/BaseTbResourceServiceTest.java +++ b/application/src/test/java/org/thingsboard/server/service/resource/sql/BaseTbResourceServiceTest.java @@ -17,6 +17,7 @@ package org.thingsboard.server.service.resource.sql; import com.datastax.oss.driver.api.core.uuid.Uuids; import com.fasterxml.jackson.databind.node.ObjectNode; +import com.fasterxml.jackson.databind.node.TextNode; import org.junit.After; import org.junit.Assert; import org.junit.Before; @@ -24,8 +25,10 @@ import org.junit.Test; import org.junit.jupiter.api.Assertions; import org.springframework.beans.factory.annotation.Autowired; import org.thingsboard.common.util.JacksonUtil; +import org.thingsboard.rule.engine.ai.TbAiNode; +import org.thingsboard.rule.engine.ai.TbAiNodeConfiguration; +import org.thingsboard.rule.engine.ai.TbResponseFormat; import org.thingsboard.server.common.data.Dashboard; -import org.thingsboard.server.common.data.DashboardInfo; import org.thingsboard.server.common.data.DataConstants; import org.thingsboard.server.common.data.EntityInfo; import org.thingsboard.server.common.data.EntityType; @@ -37,26 +40,40 @@ import org.thingsboard.server.common.data.TbResourceInfoFilter; import org.thingsboard.server.common.data.Tenant; import org.thingsboard.server.common.data.TenantProfile; import org.thingsboard.server.common.data.User; +import org.thingsboard.server.common.data.ai.AiModel; +import org.thingsboard.server.common.data.ai.model.chat.OpenAiChatModelConfig; +import org.thingsboard.server.common.data.ai.provider.OpenAiProviderConfig; +import org.thingsboard.server.common.data.debug.DebugSettings; import org.thingsboard.server.common.data.exception.ThingsboardException; +import org.thingsboard.server.common.data.id.RuleChainId; +import org.thingsboard.server.common.data.id.TbResourceId; import org.thingsboard.server.common.data.id.TenantId; import org.thingsboard.server.common.data.page.PageData; import org.thingsboard.server.common.data.page.PageLink; +import org.thingsboard.server.common.data.rule.RuleChain; +import org.thingsboard.server.common.data.rule.RuleChainMetaData; +import org.thingsboard.server.common.data.rule.RuleChainType; +import org.thingsboard.server.common.data.rule.RuleNode; import org.thingsboard.server.common.data.security.Authority; import org.thingsboard.server.common.data.tenant.profile.DefaultTenantProfileConfiguration; import org.thingsboard.server.common.data.widget.WidgetTypeDetails; -import org.thingsboard.server.common.data.widget.WidgetTypeInfo; import org.thingsboard.server.controller.AbstractControllerTest; +import org.thingsboard.server.dao.ai.AiModelService; import org.thingsboard.server.dao.dashboard.DashboardService; import org.thingsboard.server.dao.exception.DataValidationException; import org.thingsboard.server.dao.resource.ResourceService; +import org.thingsboard.server.dao.rule.RuleChainService; import org.thingsboard.server.dao.service.DaoSqlTest; import org.thingsboard.server.dao.widget.WidgetTypeService; import org.thingsboard.server.service.resource.TbResourceService; import java.util.ArrayList; +import java.util.Arrays; import java.util.Base64; import java.util.Collections; import java.util.List; +import java.util.Set; +import java.util.function.Function; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; @@ -135,6 +152,10 @@ public class BaseTbResourceServiceTest extends AbstractControllerTest { private WidgetTypeService widgetTypeService; @Autowired private DashboardService dashboardService; + @Autowired + private RuleChainService ruleChainService; + @Autowired + private AiModelService aiModelService; private Tenant savedTenant; private User tenantAdmin; @@ -453,11 +474,9 @@ public class BaseTbResourceServiceTest extends AbstractControllerTest { Assert.assertFalse(result.getReferences().isEmpty()); Assert.assertEquals(1, result.getReferences().size()); - WidgetTypeInfo widgetTypeInfo = (WidgetTypeInfo) result.getReferences().get(EntityType.WIDGET_TYPE.name()).get(0); - WidgetTypeInfo foundWidgetTypeInfo = new WidgetTypeInfo(foundWidgetType); + EntityInfo widgetTypeInfo = (EntityInfo) result.getReferences().get(EntityType.WIDGET_TYPE.name()).get(0); Assert.assertNotNull(widgetTypeInfo); - Assert.assertNotNull(foundWidgetTypeInfo); - Assert.assertEquals(widgetTypeInfo, foundWidgetTypeInfo); + Assert.assertEquals(widgetTypeInfo, new EntityInfo(foundWidgetType.getId(), foundWidgetType.getName())); TbResourceInfo foundResourceInfo = resourceService.findResourceInfoById(savedTenant.getId(), savedResource.getId()); Assert.assertNotNull(foundResource); @@ -546,11 +565,9 @@ public class BaseTbResourceServiceTest extends AbstractControllerTest { Assert.assertNotNull(result.getReferences()); Assert.assertEquals(1, result.getReferences().size()); - DashboardInfo dashboardInfo = (DashboardInfo) result.getReferences().get(EntityType.DASHBOARD.name()).get(0); - DashboardInfo foundDashboardInfo = dashboardService.findDashboardInfoById(savedTenant.getId(), savedDashboard.getId()); + EntityInfo dashboardInfo = (EntityInfo) result.getReferences().get(EntityType.DASHBOARD.name()).get(0); Assert.assertNotNull(dashboardInfo); - Assert.assertNotNull(foundDashboardInfo); - Assert.assertEquals(foundDashboardInfo, dashboardInfo); + Assert.assertEquals(new EntityInfo(savedDashboard.getId(), savedDashboard.getName()), dashboardInfo); foundResource = resourceService.findResourceById(savedTenant.getId(), savedResource.getId()); Assert.assertNotNull(foundResource); @@ -598,6 +615,90 @@ public class BaseTbResourceServiceTest extends AbstractControllerTest { Assert.assertNull(foundResource); } + @Test + public void testShouldNotDeleteResourceIfUsedInAiNode() throws Exception { + TbResource resource = new TbResource(); + resource.setResourceType(ResourceType.GENERAL); + resource.setTitle("My resource"); + resource.setFileName("test.json"); + resource.setTenantId(savedTenant.getId()); + resource.setData("".getBytes()); + TbResourceInfo savedResource = tbResourceService.save(resource); + RuleChainMetaData ruleChain = createRuleChainReferringResource(savedResource.getId()); + + TbResourceDeleteResult result = tbResourceService.delete(savedResource, false, null); + assertThat(result).isNotNull(); + assertThat(result.isSuccess()).isFalse(); + assertThat(result.getReferences()).isNotEmpty().hasSize(1); + EntityInfo entityInfo = (EntityInfo) result.getReferences().get(EntityType.RULE_CHAIN.name()).get(0); + assertThat(entityInfo).isEqualTo(new EntityInfo(ruleChain.getRuleChainId(), "Test")); + + TbResource foundResource = resourceService.findResourceById(savedTenant.getId(), savedResource.getId()); + assertThat(foundResource).isNotNull(); + + // force delete + TbResourceDeleteResult deleteResult = tbResourceService.delete(savedResource, true, null); + assertThat(deleteResult).isNotNull(); + assertThat(deleteResult.isSuccess()).isTrue(); + + TbResource resourceAfterDeletion = resourceService.findResourceById(savedTenant.getId(), savedResource.getId()); + assertThat(resourceAfterDeletion).isNull(); + } + + private RuleChainMetaData createRuleChainReferringResource(TbResourceId resourceId) { + AiModel model = constructValidOpenAiModel("Test model"); + AiModel saved = aiModelService.save(model); + + RuleChain ruleChain = new RuleChain(); + ruleChain.setTenantId(tenantId); + ruleChain.setName("Test"); + ruleChain.setType(RuleChainType.CORE); + ruleChain.setDebugMode(true); + ruleChain.setConfiguration(JacksonUtil.newObjectNode().set("a", new TextNode("b"))); + ruleChain = ruleChainService.saveRuleChain(ruleChain); + RuleChainId ruleChainId = ruleChain.getId(); + + RuleChainMetaData metaData = new RuleChainMetaData(); + metaData.setRuleChainId(ruleChainId); + + RuleNode aiNode = new RuleNode(); + aiNode.setName("Ai request"); + aiNode.setType(org.thingsboard.rule.engine.ai.TbAiNode.class.getName()); + aiNode.setConfigurationVersion(TbAiNode.class.getAnnotation(org.thingsboard.rule.engine.api.RuleNode.class).version()); + aiNode.setDebugSettings(DebugSettings.all()); + TbAiNodeConfiguration configuration = new TbAiNodeConfiguration(); + configuration.setResourceIds(Set.of(resourceId.getId())); + configuration.setModelId(saved.getId()); + configuration.setResponseFormat(new TbResponseFormat.TbJsonResponseFormat()); + configuration.setTimeoutSeconds(1); + configuration.setUserPrompt("What is temp"); + aiNode.setConfiguration(JacksonUtil.valueToTree(configuration)); + + metaData.setNodes(Arrays.asList(aiNode)); + metaData.setFirstNodeIndex(0); + ruleChainService.saveRuleChainMetaData(tenantId, metaData, Function.identity()); + return ruleChainService.loadRuleChainMetaData(tenantId, ruleChainId); + } + + private AiModel constructValidOpenAiModel(String name) { + var modelConfig = OpenAiChatModelConfig.builder() + .providerConfig(new OpenAiProviderConfig("test-api-key")) + .modelId("gpt-4o") + .temperature(0.5) + .topP(0.3) + .frequencyPenalty(0.1) + .presencePenalty(0.2) + .maxOutputTokens(1000) + .timeoutSeconds(60) + .maxRetries(2) + .build(); + + return AiModel.builder() + .tenantId(tenantId) + .name(name) + .configuration(modelConfig) + .build(); + } @Test public void testFindTenantResourcesByTenantId() throws Exception { loginSysAdmin(); diff --git a/common/dao-api/src/main/java/org/thingsboard/server/dao/resource/ResourceService.java b/common/dao-api/src/main/java/org/thingsboard/server/dao/resource/ResourceService.java index ee187db46b..65211ec17a 100644 --- a/common/dao-api/src/main/java/org/thingsboard/server/dao/resource/ResourceService.java +++ b/common/dao-api/src/main/java/org/thingsboard/server/dao/resource/ResourceService.java @@ -21,6 +21,7 @@ import org.thingsboard.server.common.data.ResourceExportData; import org.thingsboard.server.common.data.ResourceSubType; import org.thingsboard.server.common.data.ResourceType; import org.thingsboard.server.common.data.TbResource; +import org.thingsboard.server.common.data.TbResourceDataInfo; import org.thingsboard.server.common.data.TbResourceDeleteResult; import org.thingsboard.server.common.data.TbResourceInfo; import org.thingsboard.server.common.data.TbResourceInfoFilter; @@ -46,6 +47,8 @@ public interface ResourceService extends EntityDaoService { byte[] getResourceData(TenantId tenantId, TbResourceId resourceId); + TbResourceDataInfo getResourceDataInfo(TenantId tenantId, TbResourceId resourceId); + ResourceExportData exportResource(TbResourceInfo resourceInfo); List exportResources(TenantId tenantId, Collection resources); @@ -90,4 +93,6 @@ public interface ResourceService extends EntityDaoService { TbResource createOrUpdateSystemResource(ResourceType resourceType, ResourceSubType resourceSubType, String resourceKey, byte[] data); + List findSystemOrTenantResourcesByIds(TenantId tenantId, List resourceIds); + } diff --git a/common/dao-api/src/main/java/org/thingsboard/server/dao/resource/TbResourceDataCache.java b/common/dao-api/src/main/java/org/thingsboard/server/dao/resource/TbResourceDataCache.java new file mode 100644 index 0000000000..23485684af --- /dev/null +++ b/common/dao-api/src/main/java/org/thingsboard/server/dao/resource/TbResourceDataCache.java @@ -0,0 +1,28 @@ +/** + * 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.dao.resource; + +import com.google.common.util.concurrent.FluentFuture; +import org.thingsboard.server.common.data.TbResourceDataInfo; +import org.thingsboard.server.common.data.id.TbResourceId; +import org.thingsboard.server.common.data.id.TenantId; + +public interface TbResourceDataCache { + + FluentFuture getResourceDataInfoAsync(TenantId tenantId, TbResourceId resourceId); + + void evictResourceData(TenantId tenantId, TbResourceId resourceId); +} diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/GeneralFileDescriptor.java b/common/data/src/main/java/org/thingsboard/server/common/data/GeneralFileDescriptor.java new file mode 100644 index 0000000000..94edd4fa01 --- /dev/null +++ b/common/data/src/main/java/org/thingsboard/server/common/data/GeneralFileDescriptor.java @@ -0,0 +1,29 @@ +/** + * 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.common.data; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.EqualsAndHashCode; +import lombok.NoArgsConstructor; + +@Data +@EqualsAndHashCode +@AllArgsConstructor +@NoArgsConstructor +public class GeneralFileDescriptor { + private String mediaType; +} diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/ResourceType.java b/common/data/src/main/java/org/thingsboard/server/common/data/ResourceType.java index 77b17198e9..f7579b6878 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/ResourceType.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/ResourceType.java @@ -25,7 +25,8 @@ public enum ResourceType { PKCS_12("application/x-pkcs12", false, false), JS_MODULE("application/javascript", true, true), IMAGE(null, true, true), - DASHBOARD("application/json", true, true); + DASHBOARD("application/json", true, true), + GENERAL(null, false, true); @Getter private final String mediaType; diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/TbResource.java b/common/data/src/main/java/org/thingsboard/server/common/data/TbResource.java index ba37067106..457d30e263 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/TbResource.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/TbResource.java @@ -16,6 +16,7 @@ package org.thingsboard.server.common.data; import com.fasterxml.jackson.annotation.JsonGetter; +import com.fasterxml.jackson.annotation.JsonIgnore; import com.fasterxml.jackson.annotation.JsonSetter; import io.swagger.v3.oas.annotations.media.Schema; import lombok.Data; @@ -86,6 +87,11 @@ public class TbResource extends TbResourceInfo { .orElse(null); } + @JsonIgnore + public TbResourceDataInfo toResourceDataInfo() { + return new TbResourceDataInfo(data, getDescriptor()); + } + @Override public String toString() { return super.toString(); diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/TbResourceDataInfo.java b/common/data/src/main/java/org/thingsboard/server/common/data/TbResourceDataInfo.java new file mode 100644 index 0000000000..039478470d --- /dev/null +++ b/common/data/src/main/java/org/thingsboard/server/common/data/TbResourceDataInfo.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.common.data; + +import com.fasterxml.jackson.databind.JsonNode; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@AllArgsConstructor +@NoArgsConstructor +public class TbResourceDataInfo { + + private byte[] data; + private JsonNode descriptor; + +} diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/TbResourceDeleteResult.java b/common/data/src/main/java/org/thingsboard/server/common/data/TbResourceDeleteResult.java index edc5a2f539..76945a97ed 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/TbResourceDeleteResult.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/TbResourceDeleteResult.java @@ -17,7 +17,6 @@ package org.thingsboard.server.common.data; import lombok.Builder; import lombok.Data; -import org.thingsboard.server.common.data.id.HasId; import java.util.List; import java.util.Map; @@ -27,6 +26,6 @@ import java.util.Map; public class TbResourceDeleteResult { private boolean success; - private Map>> references; + private Map> references; } diff --git a/common/util/src/main/java/org/thingsboard/common/util/DonAsynchron.java b/common/util/src/main/java/org/thingsboard/common/util/DonAsynchron.java index 0f1a56cb17..79b8181548 100644 --- a/common/util/src/main/java/org/thingsboard/common/util/DonAsynchron.java +++ b/common/util/src/main/java/org/thingsboard/common/util/DonAsynchron.java @@ -15,12 +15,15 @@ */ package org.thingsboard.common.util; +import com.google.common.util.concurrent.FluentFuture; import com.google.common.util.concurrent.FutureCallback; import com.google.common.util.concurrent.Futures; import com.google.common.util.concurrent.ListenableFuture; import com.google.common.util.concurrent.MoreExecutors; +import com.google.common.util.concurrent.SettableFuture; import java.util.concurrent.Callable; +import java.util.concurrent.CompletableFuture; import java.util.concurrent.Executor; import java.util.function.Consumer; @@ -71,4 +74,16 @@ public class DonAsynchron { return future; } + public static FluentFuture toFluentFuture(CompletableFuture completable) { + SettableFuture future = SettableFuture.create(); + completable.whenComplete((result, exception) -> { + if (exception != null) { + future.setException(exception); + } else { + future.set(result); + } + }); + return FluentFuture.from(future); + } + } diff --git a/dao/src/main/java/org/thingsboard/server/dao/ResourceContainerDao.java b/dao/src/main/java/org/thingsboard/server/dao/ResourceContainerDao.java index 6a952fd501..93cc64b2db 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/ResourceContainerDao.java +++ b/dao/src/main/java/org/thingsboard/server/dao/ResourceContainerDao.java @@ -15,6 +15,7 @@ */ package org.thingsboard.server.dao; +import org.thingsboard.server.common.data.EntityInfo; import org.thingsboard.server.common.data.id.HasId; import org.thingsboard.server.common.data.id.TenantId; @@ -22,8 +23,8 @@ import java.util.List; public interface ResourceContainerDao> { - List findByTenantIdAndResourceLink(TenantId tenantId, String link, int limit); + List findByTenantIdAndResource(TenantId tenantId, String reference, int limit); - List findByResourceLink(String link, int limit); + List findByResource(String reference, int limit); } diff --git a/dao/src/main/java/org/thingsboard/server/dao/resource/BaseImageService.java b/dao/src/main/java/org/thingsboard/server/dao/resource/BaseImageService.java index 3be7f5b91c..c16e37d30f 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/resource/BaseImageService.java +++ b/dao/src/main/java/org/thingsboard/server/dao/resource/BaseImageService.java @@ -50,6 +50,7 @@ import org.thingsboard.server.dao.ImageContainerDao; import org.thingsboard.server.dao.asset.AssetProfileDao; import org.thingsboard.server.dao.dashboard.DashboardInfoDao; import org.thingsboard.server.dao.device.DeviceProfileDao; +import org.thingsboard.server.dao.rule.RuleChainDao; import org.thingsboard.server.dao.service.Validator; import org.thingsboard.server.dao.service.validator.ResourceDataValidator; import org.thingsboard.server.dao.util.ImageUtils; @@ -109,8 +110,8 @@ public class BaseImageService extends BaseResourceService implements ImageServic public BaseImageService(TbResourceDao resourceDao, TbResourceInfoDao resourceInfoDao, ResourceDataValidator resourceValidator, AssetProfileDao assetProfileDao, DeviceProfileDao deviceProfileDao, WidgetsBundleDao widgetsBundleDao, - WidgetTypeDao widgetTypeDao, DashboardInfoDao dashboardInfoDao) { - super(resourceDao, resourceInfoDao, resourceValidator, widgetTypeDao, dashboardInfoDao); + WidgetTypeDao widgetTypeDao, DashboardInfoDao dashboardInfoDao, RuleChainDao ruleChainDao) { + super(resourceDao, resourceInfoDao, resourceValidator, widgetTypeDao, dashboardInfoDao, ruleChainDao); this.assetProfileDao = assetProfileDao; this.deviceProfileDao = deviceProfileDao; this.widgetsBundleDao = widgetsBundleDao; diff --git a/dao/src/main/java/org/thingsboard/server/dao/resource/BaseResourceService.java b/dao/src/main/java/org/thingsboard/server/dao/resource/BaseResourceService.java index bf941256f5..7f0dd4a6bf 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/resource/BaseResourceService.java +++ b/dao/src/main/java/org/thingsboard/server/dao/resource/BaseResourceService.java @@ -35,11 +35,13 @@ import org.thingsboard.server.cache.resourceInfo.ResourceInfoCacheKey; import org.thingsboard.server.cache.resourceInfo.ResourceInfoEvictEvent; import org.thingsboard.server.common.data.Dashboard; import org.thingsboard.server.common.data.DataConstants; +import org.thingsboard.server.common.data.EntityInfo; import org.thingsboard.server.common.data.EntityType; import org.thingsboard.server.common.data.ResourceExportData; import org.thingsboard.server.common.data.ResourceSubType; import org.thingsboard.server.common.data.ResourceType; import org.thingsboard.server.common.data.TbResource; +import org.thingsboard.server.common.data.TbResourceDataInfo; import org.thingsboard.server.common.data.TbResourceDeleteResult; import org.thingsboard.server.common.data.TbResourceInfo; import org.thingsboard.server.common.data.TbResourceInfoFilter; @@ -56,6 +58,7 @@ import org.thingsboard.server.dao.entity.AbstractCachedEntityService; import org.thingsboard.server.dao.eventsourcing.DeleteEntityEvent; import org.thingsboard.server.dao.eventsourcing.SaveEntityEvent; import org.thingsboard.server.dao.exception.DataValidationException; +import org.thingsboard.server.dao.rule.RuleChainDao; import org.thingsboard.server.dao.service.PaginatedRemover; import org.thingsboard.server.dao.service.Validator; import org.thingsboard.server.dao.service.validator.ResourceDataValidator; @@ -92,13 +95,16 @@ public class BaseResourceService extends AbstractCachedEntityService> resourceContainerDaoMap = new HashMap<>(); + protected final RuleChainDao ruleChainDao; + private final Map> resourceLinkContainerDaoMap = new HashMap<>(); + private final Map> generalResourceContainerDaoMap = new HashMap<>(); protected static final int MAX_ENTITIES_TO_FIND = 10; @PostConstruct public void init() { - resourceContainerDaoMap.put(EntityType.WIDGET_TYPE, widgetTypeDao); - resourceContainerDaoMap.put(EntityType.DASHBOARD, dashboardInfoDao); + resourceLinkContainerDaoMap.put(EntityType.WIDGET_TYPE, widgetTypeDao); + resourceLinkContainerDaoMap.put(EntityType.DASHBOARD, dashboardInfoDao); + generalResourceContainerDaoMap.put(EntityType.RULE_CHAIN, ruleChainDao); } @Autowired @Lazy @@ -206,6 +212,12 @@ public class BaseResourceService extends AbstractCachedEntityService>> affectedEntities = new HashMap<>(); - - resourceContainerDaoMap.forEach((entityType, resourceContainerDao) -> { - var entities = tenantId.isSysTenantId() ? resourceContainerDao.findByResourceLink(link, MAX_ENTITIES_TO_FIND) : - resourceContainerDao.findByTenantIdAndResourceLink(tenantId, link, MAX_ENTITIES_TO_FIND); - if (!entities.isEmpty()) { - affectedEntities.put(entityType.name(), entities); - } - }); - - if (!affectedEntities.isEmpty()) { - success = false; - result.references(affectedEntities); - } + Map> references = findResourceReferences(tenantId, resource); + if (!references.isEmpty()) { + success = false; + result.references(references); } } if (success) { resourceDao.removeById(tenantId, resourceId.getId()); + publishEvictEvent(new ResourceInfoEvictEvent(tenantId, resourceId)); eventPublisher.publishEvent(DeleteEntityEvent.builder().tenantId(tenantId).entity(resource).entityId(resourceId).build()); } return result.success(success).build(); } + private Map> findResourceReferences(TenantId tenantId, TbResourceInfo resource) { + Map> references = new HashMap<>(); + + if (resource.getResourceType() == ResourceType.JS_MODULE) { + var ref = resource.getLink(); + findReferences(tenantId, references, ref, resourceLinkContainerDaoMap); + } + + if (resource.getResourceType() == ResourceType.GENERAL) { + var ref = resource.getId().getId().toString(); + findReferences(tenantId, references, ref, generalResourceContainerDaoMap); + } + + return references; + } + + private void findReferences(TenantId tenantId, Map> references, String ref, Map> resourceLinkContainerDaoMap) { + resourceLinkContainerDaoMap.forEach((entityType, dao) -> { + List entities = tenantId.isSysTenantId() + ? dao.findByResource(ref, MAX_ENTITIES_TO_FIND) + : dao.findByTenantIdAndResource(tenantId, ref, MAX_ENTITIES_TO_FIND); + if (!entities.isEmpty()) { + references.put(entityType.name(), entities); + } + }); + } + @Override public void deleteEntity(TenantId tenantId, EntityId id, boolean force) { deleteResource(tenantId, (TbResourceId) id, force); @@ -663,6 +691,12 @@ public class BaseResourceService extends AbstractCachedEntityService findSystemOrTenantResourcesByIds(TenantId tenantId, List resourceIds) { + log.trace("Executing findSystemOrTenantResourcesByIds, tenantId [{}], resourceIds [{}]", tenantId, resourceIds); + return resourceInfoDao.findSystemOrTenantResourcesByIds(tenantId, resourceIds); + } + @Override public String calculateEtag(byte[] data) { return Hashing.sha256().hashBytes(data).toString(); diff --git a/dao/src/main/java/org/thingsboard/server/dao/resource/DefaultTbResourceDataCache.java b/dao/src/main/java/org/thingsboard/server/dao/resource/DefaultTbResourceDataCache.java new file mode 100644 index 0000000000..452f86b1a6 --- /dev/null +++ b/dao/src/main/java/org/thingsboard/server/dao/resource/DefaultTbResourceDataCache.java @@ -0,0 +1,72 @@ +/** + * 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.dao.resource; + +import com.github.benmanes.caffeine.cache.AsyncLoadingCache; +import com.github.benmanes.caffeine.cache.Caffeine; +import com.google.common.util.concurrent.FluentFuture; +import jakarta.annotation.PostConstruct; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; +import org.thingsboard.common.util.DonAsynchron; +import org.thingsboard.server.common.data.TbResourceDataInfo; +import org.thingsboard.server.common.data.id.TbResourceId; +import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.dao.sql.JpaExecutorService; + +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.TimeUnit; + +@Service +@RequiredArgsConstructor +@Slf4j +public class DefaultTbResourceDataCache implements TbResourceDataCache { + + private final ResourceService resourceService; + private final JpaExecutorService executorService; + + @Value("${cache.tbResourceData.maxSize:100000}") + private int cacheMaxSize; + @Value("${cache.tbResourceData.timeToLiveInMinutes:44640}") + private int cacheValueTtl; + private AsyncLoadingCache cache; + + @PostConstruct + private void init() { + cache = Caffeine.newBuilder() + .maximumSize(cacheMaxSize) + .expireAfterAccess(cacheValueTtl, TimeUnit.MINUTES) + .executor(executorService) + .buildAsync((key, executor) -> CompletableFuture.supplyAsync(() -> resourceService.getResourceDataInfo(key.tenantId(), key.resourceId()), executor)); + } + + @Override + public FluentFuture getResourceDataInfoAsync(TenantId tenantId, TbResourceId resourceId) { + log.trace("Retrieving resource data info by id [{}], tenant id [{}] from cache", resourceId, tenantId); + return DonAsynchron.toFluentFuture(cache.get(new ResourceDataKey(tenantId, resourceId))); + } + + @Override + public void evictResourceData(TenantId tenantId, TbResourceId resourceId) { + cache.asMap().remove(new ResourceDataKey(tenantId, resourceId)); + log.trace("Evicted resource data info with id [{}], tenant id [{}]", resourceId, tenantId); + } + + record ResourceDataKey (TenantId tenantId, TbResourceId resourceId) {} + +} diff --git a/dao/src/main/java/org/thingsboard/server/dao/resource/TbResourceDao.java b/dao/src/main/java/org/thingsboard/server/dao/resource/TbResourceDao.java index 23b59b5658..1b9f250521 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/resource/TbResourceDao.java +++ b/dao/src/main/java/org/thingsboard/server/dao/resource/TbResourceDao.java @@ -18,6 +18,7 @@ package org.thingsboard.server.dao.resource; import org.thingsboard.server.common.data.ResourceSubType; import org.thingsboard.server.common.data.ResourceType; import org.thingsboard.server.common.data.TbResource; +import org.thingsboard.server.common.data.TbResourceDataInfo; import org.thingsboard.server.common.data.id.TbResourceId; import org.thingsboard.server.common.data.id.TenantId; import org.thingsboard.server.common.data.page.PageData; @@ -51,4 +52,5 @@ public interface TbResourceDao extends Dao, TenantEntityWithDataDao, long getResourceSize(TenantId tenantId, TbResourceId resourceId); + TbResourceDataInfo getResourceDataInfo(TenantId tenantId, TbResourceId resourceId); } diff --git a/dao/src/main/java/org/thingsboard/server/dao/resource/TbResourceInfoDao.java b/dao/src/main/java/org/thingsboard/server/dao/resource/TbResourceInfoDao.java index 8e97738501..f4fe02843d 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/resource/TbResourceInfoDao.java +++ b/dao/src/main/java/org/thingsboard/server/dao/resource/TbResourceInfoDao.java @@ -18,6 +18,7 @@ package org.thingsboard.server.dao.resource; import org.thingsboard.server.common.data.ResourceType; import org.thingsboard.server.common.data.TbResourceInfo; import org.thingsboard.server.common.data.TbResourceInfoFilter; +import org.thingsboard.server.common.data.id.TbResourceId; import org.thingsboard.server.common.data.id.TenantId; import org.thingsboard.server.common.data.page.PageData; import org.thingsboard.server.common.data.page.PageLink; @@ -46,4 +47,5 @@ public interface TbResourceInfoDao extends Dao { TbResourceInfo findPublicResourceByKey(ResourceType resourceType, String publicResourceKey); + List findSystemOrTenantResourcesByIds(TenantId tenantId, List resourceIds); } diff --git a/dao/src/main/java/org/thingsboard/server/dao/rule/RuleChainDao.java b/dao/src/main/java/org/thingsboard/server/dao/rule/RuleChainDao.java index 5b09eec42a..ac716bb4fc 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/rule/RuleChainDao.java +++ b/dao/src/main/java/org/thingsboard/server/dao/rule/RuleChainDao.java @@ -21,8 +21,10 @@ import org.thingsboard.server.common.data.page.PageData; import org.thingsboard.server.common.data.page.PageLink; import org.thingsboard.server.common.data.rule.RuleChain; import org.thingsboard.server.common.data.rule.RuleChainType; +import org.thingsboard.server.common.data.rule.RuleNode; import org.thingsboard.server.dao.Dao; import org.thingsboard.server.dao.ExportableEntityDao; +import org.thingsboard.server.dao.ResourceContainerDao; import org.thingsboard.server.dao.TenantEntityDao; import java.util.Collection; @@ -31,7 +33,7 @@ import java.util.UUID; /** * Created by igor on 3/12/18. */ -public interface RuleChainDao extends Dao, TenantEntityDao, ExportableEntityDao { +public interface RuleChainDao extends Dao, TenantEntityDao, ExportableEntityDao, ResourceContainerDao { /** * Find rule chains by tenantId and page link. diff --git a/dao/src/main/java/org/thingsboard/server/dao/sql/dashboard/DashboardInfoRepository.java b/dao/src/main/java/org/thingsboard/server/dao/sql/dashboard/DashboardInfoRepository.java index 7624ddc738..32ac596562 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/sql/dashboard/DashboardInfoRepository.java +++ b/dao/src/main/java/org/thingsboard/server/dao/sql/dashboard/DashboardInfoRepository.java @@ -20,6 +20,7 @@ import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; +import org.thingsboard.server.common.data.EntityInfo; import org.thingsboard.server.dao.model.sql.DashboardInfoEntity; import java.util.List; @@ -87,12 +88,15 @@ public interface DashboardInfoRepository extends JpaRepository findByImageLink(@Param("imageLink") String imageLink, @Param("limit") int limit); - @Query(value = "SELECT * FROM dashboard d WHERE d.tenant_id = :tenantId and d.configuration ILIKE CONCAT('%', :link, '%') limit :limit", - nativeQuery = true) - List findDashboardInfosByTenantIdAndResourceLink(@Param("tenantId") UUID tenantId, @Param("link") String link, @Param("limit") int limit); + @Query("SELECT new org.thingsboard.server.common.data.EntityInfo(d.id, 'DASHBOARD', d.title) " + + "FROM DashboardEntity d WHERE d.tenantId = :tenantId AND ilike(cast(d.configuration as string), CONCAT('%', :link, '%')) = true") + List findDashboardInfosByTenantIdAndResourceLink(@Param("tenantId") UUID tenantId, + @Param("link") String link, + Pageable pageable); - @Query(value = "SELECT * FROM dashboard d WHERE d.configuration ILIKE CONCAT('%', :link, '%') limit :limit", - nativeQuery = true) - List findDashboardInfosByResourceLink(@Param("link") String link, @Param("limit") int limit); + @Query("SELECT new org.thingsboard.server.common.data.EntityInfo(d.id, 'DASHBOARD', d.title) " + + "FROM DashboardEntity d WHERE ilike(cast(d.configuration as string), CONCAT('%', :link, '%')) = true") + List findDashboardInfosByResourceLink(@Param("link") String link, + Pageable pageable); } diff --git a/dao/src/main/java/org/thingsboard/server/dao/sql/dashboard/JpaDashboardInfoDao.java b/dao/src/main/java/org/thingsboard/server/dao/sql/dashboard/JpaDashboardInfoDao.java index bc07139725..0e04c94a46 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/sql/dashboard/JpaDashboardInfoDao.java +++ b/dao/src/main/java/org/thingsboard/server/dao/sql/dashboard/JpaDashboardInfoDao.java @@ -17,9 +17,11 @@ package org.thingsboard.server.dao.sql.dashboard; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.data.domain.PageRequest; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.stereotype.Component; import org.thingsboard.server.common.data.DashboardInfo; +import org.thingsboard.server.common.data.EntityInfo; import org.thingsboard.server.common.data.id.TenantId; import org.thingsboard.server.common.data.page.PageData; import org.thingsboard.server.common.data.page.PageLink; @@ -135,13 +137,13 @@ public class JpaDashboardInfoDao extends JpaAbstractDao findByTenantIdAndResourceLink(TenantId tenantId, String url, int limit) { - return DaoUtil.convertDataList(dashboardInfoRepository.findDashboardInfosByTenantIdAndResourceLink(tenantId.getId(), url, limit)); + public List findByTenantIdAndResource(TenantId tenantId, String reference, int limit) { + return dashboardInfoRepository.findDashboardInfosByTenantIdAndResourceLink(tenantId.getId(), reference, PageRequest.of(0, limit)); } @Override - public List findByResourceLink(String link, int limit) { - return DaoUtil.convertDataList(dashboardInfoRepository.findDashboardInfosByResourceLink(link, limit)); + public List findByResource(String reference, int limit) { + return dashboardInfoRepository.findDashboardInfosByResourceLink(reference, PageRequest.of(0, limit)); } } diff --git a/dao/src/main/java/org/thingsboard/server/dao/sql/resource/JpaTbResourceDao.java b/dao/src/main/java/org/thingsboard/server/dao/sql/resource/JpaTbResourceDao.java index 6cce9d76c2..48e41c8553 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/sql/resource/JpaTbResourceDao.java +++ b/dao/src/main/java/org/thingsboard/server/dao/sql/resource/JpaTbResourceDao.java @@ -22,6 +22,7 @@ import org.thingsboard.server.common.data.EntityType; import org.thingsboard.server.common.data.ResourceSubType; import org.thingsboard.server.common.data.ResourceType; import org.thingsboard.server.common.data.TbResource; +import org.thingsboard.server.common.data.TbResourceDataInfo; import org.thingsboard.server.common.data.id.TbResourceId; import org.thingsboard.server.common.data.id.TenantId; import org.thingsboard.server.common.data.page.PageData; @@ -115,6 +116,11 @@ public class JpaTbResourceDao extends JpaAbstractDao findSystemOrTenantResourcesByIds(TenantId tenantId, List resourceIds) { + return DaoUtil.convertDataList(resourceInfoRepository.findSystemOrTenantResourcesByIdIn(tenantId.getId(), TenantId.NULL_UUID, toUUIDs(resourceIds))); + } } diff --git a/dao/src/main/java/org/thingsboard/server/dao/sql/resource/TbResourceInfoRepository.java b/dao/src/main/java/org/thingsboard/server/dao/sql/resource/TbResourceInfoRepository.java index 6eea20a287..97b1e56527 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/sql/resource/TbResourceInfoRepository.java +++ b/dao/src/main/java/org/thingsboard/server/dao/sql/resource/TbResourceInfoRepository.java @@ -79,4 +79,10 @@ public interface TbResourceInfoRepository extends JpaRepository findSystemOrTenantResourcesByIdIn(@Param("tenantId") UUID tenantId, + @Param("systemTenantId") UUID systemTenantId, + @Param("resourceIds") List resourceIds); + } diff --git a/dao/src/main/java/org/thingsboard/server/dao/sql/resource/TbResourceRepository.java b/dao/src/main/java/org/thingsboard/server/dao/sql/resource/TbResourceRepository.java index 1c642d2069..4aa699174f 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/sql/resource/TbResourceRepository.java +++ b/dao/src/main/java/org/thingsboard/server/dao/sql/resource/TbResourceRepository.java @@ -20,6 +20,7 @@ import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; +import org.thingsboard.server.common.data.TbResourceDataInfo; import org.thingsboard.server.dao.ExportableEntityRepository; import org.thingsboard.server.dao.model.sql.TbResourceEntity; @@ -101,4 +102,6 @@ public interface TbResourceRepository extends JpaRepository findIdsByTenantId(@Param("tenantId") UUID tenantId, Pageable pageable); + @Query("SELECT new org.thingsboard.server.common.data.TbResourceDataInfo(r.data, r.descriptor) FROM TbResourceEntity r WHERE r.id = :id") + TbResourceDataInfo getDataInfoById(UUID id); } diff --git a/dao/src/main/java/org/thingsboard/server/dao/sql/rule/JpaRuleChainDao.java b/dao/src/main/java/org/thingsboard/server/dao/sql/rule/JpaRuleChainDao.java index 77044d41dc..4a6427a7e5 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/sql/rule/JpaRuleChainDao.java +++ b/dao/src/main/java/org/thingsboard/server/dao/sql/rule/JpaRuleChainDao.java @@ -18,8 +18,10 @@ package org.thingsboard.server.dao.sql.rule; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.data.domain.Limit; +import org.springframework.data.domain.PageRequest; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.stereotype.Component; +import org.thingsboard.server.common.data.EntityInfo; import org.thingsboard.server.common.data.EntityType; import org.thingsboard.server.common.data.edqs.fields.RuleChainFields; import org.thingsboard.server.common.data.id.RuleChainId; @@ -141,6 +143,16 @@ public class JpaRuleChainDao extends JpaAbstractDao return findRuleChainsByTenantId(tenantId.getId(), pageLink); } + @Override + public List findByTenantIdAndResource(TenantId tenantId, String reference, int limit) { + return ruleChainRepository.findRuleChainsByTenantIdAndResource(tenantId.getId(), reference, PageRequest.of(0, limit)); + } + + @Override + public List findByResource(String reference, int limit) { + return ruleChainRepository.findRuleChainsByResource(reference, PageRequest.of(0, limit)); + } + @Override public List findNextBatch(UUID id, int batchSize) { return ruleChainRepository.findNextBatch(id, Limit.of(batchSize)); diff --git a/dao/src/main/java/org/thingsboard/server/dao/sql/rule/RuleChainRepository.java b/dao/src/main/java/org/thingsboard/server/dao/sql/rule/RuleChainRepository.java index cfa06caf14..4bf648cbbd 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/sql/rule/RuleChainRepository.java +++ b/dao/src/main/java/org/thingsboard/server/dao/sql/rule/RuleChainRepository.java @@ -17,10 +17,12 @@ package org.thingsboard.server.dao.sql.rule; import org.springframework.data.domain.Limit; import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; +import org.thingsboard.server.common.data.EntityInfo; import org.thingsboard.server.common.data.edqs.fields.RuleChainFields; import org.thingsboard.server.common.data.rule.RuleChainType; import org.thingsboard.server.dao.ExportableEntityRepository; @@ -72,6 +74,19 @@ public interface RuleChainRepository extends JpaRepository findRuleChainsByTenantIdAndResource(@Param("tenantId") UUID tenantId, + @Param("resourceId") String resourceId, + PageRequest of); + + @Query("SELECT new org.thingsboard.server.common.data.EntityInfo(rc.id, 'RULE_CHAIN', rc.name) " + + "FROM RuleChainEntity rc WHERE EXISTS " + + "(SELECT 1 FROM RuleNodeEntity rn WHERE rn.ruleChainId = rc.id AND cast(rn.configuration as string) LIKE CONCAT('%', :resourceId, '%'))") + List findRuleChainsByResource(@Param("resourceId") String resourceId, + Pageable pageable); + @Query("SELECT new org.thingsboard.server.common.data.edqs.fields.RuleChainFields(r.id, r.createdTime, r.tenantId," + "r.name, r.version, r.additionalInfo) FROM RuleChainEntity r WHERE r.id > :id ORDER BY r.id") List findNextBatch(@Param("id") UUID id, Limit limit); diff --git a/dao/src/main/java/org/thingsboard/server/dao/sql/widget/JpaWidgetTypeDao.java b/dao/src/main/java/org/thingsboard/server/dao/sql/widget/JpaWidgetTypeDao.java index c728f5d006..18fb544dcb 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/sql/widget/JpaWidgetTypeDao.java +++ b/dao/src/main/java/org/thingsboard/server/dao/sql/widget/JpaWidgetTypeDao.java @@ -17,8 +17,10 @@ package org.thingsboard.server.dao.sql.widget; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.data.domain.Limit; +import org.springframework.data.domain.PageRequest; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.stereotype.Component; +import org.thingsboard.server.common.data.EntityInfo; import org.thingsboard.server.common.data.EntityType; import org.thingsboard.server.common.data.edqs.fields.WidgetTypeFields; import org.thingsboard.server.common.data.id.TenantId; @@ -269,13 +271,13 @@ public class JpaWidgetTypeDao extends JpaAbstractDao findByTenantIdAndResourceLink(TenantId tenantId, String link, int limit) { - return DaoUtil.convertDataList(widgetTypeInfoRepository.findWidgetTypeInfosByTenantIdAndResourceLink(tenantId.getId(), link, limit)); + public List findByTenantIdAndResource(TenantId tenantId, String reference, int limit) { + return widgetTypeInfoRepository.findWidgetTypeInfosByTenantIdAndResourceLink(tenantId.getId(), reference, PageRequest.of(0, limit)); } @Override - public List findByResourceLink(String link, int limit) { - return DaoUtil.convertDataList(widgetTypeInfoRepository.findWidgetTypeInfosByResourceLink(link, limit)); + public List findByResource(String reference, int limit) { + return widgetTypeInfoRepository.findWidgetTypeInfosByResourceLink(reference, PageRequest.of(0, limit)); } @Override diff --git a/dao/src/main/java/org/thingsboard/server/dao/sql/widget/WidgetTypeInfoRepository.java b/dao/src/main/java/org/thingsboard/server/dao/sql/widget/WidgetTypeInfoRepository.java index dc79280bcf..b97b42a6b9 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/sql/widget/WidgetTypeInfoRepository.java +++ b/dao/src/main/java/org/thingsboard/server/dao/sql/widget/WidgetTypeInfoRepository.java @@ -20,6 +20,7 @@ import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; +import org.thingsboard.server.common.data.EntityInfo; import org.thingsboard.server.dao.model.sql.WidgetTypeInfoEntity; import java.util.List; @@ -214,10 +215,14 @@ public interface WidgetTypeInfoRepository extends JpaRepository findByImageUrl(@Param("imageLink") String imageLink, @Param("limit") int limit); - @Query(value = "SELECT * FROM widget_type_info_view w WHERE w.tenant_id = :tenantId AND w.descriptor ILIKE CONCAT('%', :link, '%') LIMIT :limit ", nativeQuery = true) - List findWidgetTypeInfosByTenantIdAndResourceLink(@Param("tenantId") UUID tenantId, @Param("link") String link, @Param("limit") int limit); - - @Query(value = "SELECT * FROM widget_type_info_view w WHERE w.descriptor ILIKE CONCAT('%', :link, '%') LIMIT :limit ", nativeQuery = true) - List findWidgetTypeInfosByResourceLink(@Param("link") String link, @Param("limit") int limit); + @Query("SELECT new org.thingsboard.server.common.data.EntityInfo(w.id, 'WIDGET_TYPE', w.name) " + + "FROM WidgetTypeEntity w WHERE w.tenantId = :tenantId AND ilike(cast(w.descriptor as string), CONCAT('%', :link, '%')) = true") + List findWidgetTypeInfosByTenantIdAndResourceLink(@Param("tenantId") UUID tenantId, + @Param("link") String link, + Pageable pageable); + @Query("SELECT new org.thingsboard.server.common.data.EntityInfo(w.id, 'WIDGET_TYPE', w.name) " + + "FROM WidgetTypeEntity w WHERE ilike(cast(w.descriptor as string), CONCAT('%', :link, '%')) = true") + List findWidgetTypeInfosByResourceLink(@Param("link") String link, + Pageable pageable); } diff --git a/dao/src/test/java/org/thingsboard/server/dao/sql/rule/JpaRuleNodeDaoTest.java b/dao/src/test/java/org/thingsboard/server/dao/sql/rule/JpaRuleNodeDaoTest.java index f5aa8a3af5..59c0db9eee 100644 --- a/dao/src/test/java/org/thingsboard/server/dao/sql/rule/JpaRuleNodeDaoTest.java +++ b/dao/src/test/java/org/thingsboard/server/dao/sql/rule/JpaRuleNodeDaoTest.java @@ -24,6 +24,7 @@ import org.springframework.beans.factory.annotation.Autowired; import org.thingsboard.common.util.JacksonUtil; import org.thingsboard.server.common.data.id.RuleChainId; import org.thingsboard.server.common.data.id.RuleNodeId; +import org.thingsboard.server.common.data.id.TbResourceId; import org.thingsboard.server.common.data.id.TenantId; import org.thingsboard.server.common.data.page.PageData; import org.thingsboard.server.common.data.page.PageLink; diff --git a/rule-engine/rule-engine-api/src/main/java/org/thingsboard/rule/engine/api/TbContext.java b/rule-engine/rule-engine-api/src/main/java/org/thingsboard/rule/engine/api/TbContext.java index d2687a1b10..920f00ed27 100644 --- a/rule-engine/rule-engine-api/src/main/java/org/thingsboard/rule/engine/api/TbContext.java +++ b/rule-engine/rule-engine-api/src/main/java/org/thingsboard/rule/engine/api/TbContext.java @@ -24,6 +24,7 @@ import org.thingsboard.server.cluster.TbClusterService; import org.thingsboard.server.common.data.Customer; import org.thingsboard.server.common.data.Device; import org.thingsboard.server.common.data.DeviceProfile; +import org.thingsboard.server.common.data.HasTenantId; import org.thingsboard.server.common.data.TenantProfile; import org.thingsboard.server.common.data.alarm.Alarm; import org.thingsboard.server.common.data.asset.Asset; @@ -32,6 +33,7 @@ import org.thingsboard.server.common.data.id.AssetId; import org.thingsboard.server.common.data.id.CustomerId; import org.thingsboard.server.common.data.id.DeviceId; import org.thingsboard.server.common.data.id.EntityId; +import org.thingsboard.server.common.data.id.HasId; import org.thingsboard.server.common.data.id.RuleChainId; import org.thingsboard.server.common.data.id.RuleNodeId; import org.thingsboard.server.common.data.id.TenantId; @@ -78,6 +80,7 @@ import org.thingsboard.server.dao.queue.QueueService; import org.thingsboard.server.dao.queue.QueueStatsService; import org.thingsboard.server.dao.relation.RelationService; import org.thingsboard.server.dao.resource.ResourceService; +import org.thingsboard.server.dao.resource.TbResourceDataCache; import org.thingsboard.server.dao.rule.RuleChainService; import org.thingsboard.server.dao.tenant.TenantService; import org.thingsboard.server.dao.timeseries.TimeseriesService; @@ -252,6 +255,8 @@ public interface TbContext { void checkTenantEntity(EntityId entityId) throws TbNodeException; + & HasTenantId, I extends EntityId> void checkTenantEntity(E entity) throws TbNodeException; + boolean isLocalEntity(EntityId entityId); RuleNodeId getSelfId(); @@ -308,6 +313,8 @@ public interface TbContext { ResourceService getResourceService(); + TbResourceDataCache getTbResourceDataCache(); + OtaPackageService getOtaPackageService(); RuleEngineDeviceProfileCache getDeviceProfileCache(); diff --git a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/ai/TbAiNode.java b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/ai/TbAiNode.java index 3497795771..bd02089204 100644 --- a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/ai/TbAiNode.java +++ b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/ai/TbAiNode.java @@ -18,12 +18,20 @@ package org.thingsboard.rule.engine.ai; import com.fasterxml.jackson.databind.JsonNode; import com.google.common.util.concurrent.FluentFuture; import com.google.common.util.concurrent.FutureCallback; +import com.google.common.util.concurrent.Futures; +import com.google.common.util.concurrent.ListenableFuture; +import com.google.common.util.concurrent.MoreExecutors; import dev.langchain4j.data.message.ChatMessage; +import dev.langchain4j.data.message.Content; +import dev.langchain4j.data.message.ImageContent; +import dev.langchain4j.data.message.PdfFileContent; import dev.langchain4j.data.message.SystemMessage; +import dev.langchain4j.data.message.TextContent; import dev.langchain4j.data.message.UserMessage; import dev.langchain4j.model.chat.request.ChatRequest; import dev.langchain4j.model.chat.request.ResponseFormat; import dev.langchain4j.model.chat.response.ChatResponse; +import lombok.extern.slf4j.Slf4j; import org.checkerframework.checker.nullness.qual.NonNull; import org.thingsboard.common.util.JacksonUtil; import org.thingsboard.rule.engine.api.RuleNode; @@ -33,24 +41,38 @@ import org.thingsboard.rule.engine.api.TbNodeConfiguration; import org.thingsboard.rule.engine.api.TbNodeException; import org.thingsboard.rule.engine.api.util.TbNodeUtils; import org.thingsboard.rule.engine.external.TbAbstractExternalNode; +import org.thingsboard.server.common.data.GeneralFileDescriptor; +import org.thingsboard.server.common.data.ResourceType; +import org.thingsboard.server.common.data.TbResourceDataInfo; +import org.thingsboard.server.common.data.TbResourceInfo; import org.thingsboard.server.common.data.ai.AiModel; import org.thingsboard.server.common.data.ai.model.AiModelType; import org.thingsboard.server.common.data.ai.model.chat.AiChatModelConfig; import org.thingsboard.server.common.data.id.AiModelId; +import org.thingsboard.server.common.data.id.TbResourceId; +import org.thingsboard.server.common.data.id.TenantId; import org.thingsboard.server.common.data.plugin.ComponentType; import org.thingsboard.server.common.data.rule.RuleChainType; import org.thingsboard.server.common.msg.TbMsg; import org.thingsboard.server.dao.exception.DataValidationException; +import org.thingsboard.server.dao.resource.TbResourceDataCache; +import java.nio.charset.StandardCharsets; import java.util.ArrayList; +import java.util.Base64; +import java.util.HashSet; import java.util.List; import java.util.NoSuchElementException; +import java.util.Objects; import java.util.Optional; +import java.util.Set; +import java.util.UUID; import static com.google.common.util.concurrent.MoreExecutors.directExecutor; import static org.thingsboard.rule.engine.ai.TbResponseFormat.TbResponseFormatType; import static org.thingsboard.server.dao.service.ConstraintValidator.validateFields; +@Slf4j @RuleNode( type = ComponentType.EXTERNAL, name = "AI request", @@ -77,6 +99,7 @@ public final class TbAiNode extends TbAbstractExternalNode implements TbNode { private String systemPrompt; private String userPrompt; + private Set resourceIds; private ResponseFormat responseFormat; private int timeoutSeconds; private AiModelId modelId; @@ -111,6 +134,14 @@ public final class TbAiNode extends TbAbstractExternalNode implements TbNode { // LangChain4j AnthropicChatModel rejects requests with non-null ResponseFormat even if ResponseFormatType is TEXT responseFormat = config.getResponseFormat().toLangChainResponseFormat(); } + if (config.getResourceIds() != null && !config.getResourceIds().isEmpty()) { + resourceIds = new HashSet<>(config.getResourceIds().size()); + for (UUID resourceId : config.getResourceIds()) { + TbResourceId tbResourceId = new TbResourceId(resourceId); + validateResource(ctx, tbResourceId); + resourceIds.add(tbResourceId); + } + } systemPrompt = config.getSystemPrompt(); userPrompt = config.getUserPrompt(); @@ -126,12 +157,42 @@ public final class TbAiNode extends TbAbstractExternalNode implements TbNode { @Override public void onMsg(TbContext ctx, TbMsg msg) { var ackedMsg = ackIfNeeded(ctx, msg); + final String processedUserPrompt = TbNodeUtils.processPattern(this.userPrompt, ackedMsg); + + final ListenableFuture userMessageFuture = + resourceIds == null + ? Futures.immediateFuture(UserMessage.from(processedUserPrompt)) + : Futures.transform( + loadResources(ctx), + resources -> UserMessage.from(buildContents(processedUserPrompt, resources)), + ctx.getDbCallbackExecutor() + ); + + Futures.addCallback( + userMessageFuture, + new FutureCallback<>() { + @Override + public void onSuccess(UserMessage userMessage) { + buildAndSendRequest(ctx, ackedMsg, userMessage); + } + @Override + public void onFailure(Throwable t) { + tellFailure(ctx, ackedMsg, t); + } + }, + MoreExecutors.directExecutor() + ); + } + + private void buildAndSendRequest(TbContext ctx, TbMsg ackedMsg, UserMessage userMessage) { List chatMessages = new ArrayList<>(2); - if (systemPrompt != null) { + + if (systemPrompt != null && !systemPrompt.isBlank()) { chatMessages.add(SystemMessage.from(TbNodeUtils.processPattern(systemPrompt, ackedMsg))); } - chatMessages.add(UserMessage.from(TbNodeUtils.processPattern(userPrompt, ackedMsg))); + + chatMessages.add(userMessage); var chatRequest = ChatRequest.builder() .messages(chatMessages) @@ -192,11 +253,67 @@ public final class TbAiNode extends TbAbstractExternalNode implements TbNode { return JacksonUtil.newObjectNode().put("response", response).toString(); } + private void validateResource(TbContext ctx, TbResourceId tbResourceId) throws TbNodeException { + TbResourceInfo resource = ctx.getResourceService().findResourceInfoById(ctx.getTenantId(), tbResourceId); + if (resource == null) { + throw new TbNodeException("[" + ctx.getTenantId() + "] Resource with ID: [" + tbResourceId + "] was not found", true); + } + if (!ResourceType.GENERAL.equals(resource.getResourceType())) { + throw new TbNodeException("[" + ctx.getTenantId() + "] Resource with ID: [" + tbResourceId + "] has unsupported resource type: " + resource.getResourceType(), true); + } + ctx.checkTenantEntity(resource); + } + + private ListenableFuture> loadResources(TbContext ctx) { + final TenantId tenantId = ctx.getTenantId(); + final TbResourceDataCache cache = ctx.getTbResourceDataCache(); + List> futures = resourceIds.stream() + .map(id -> cache.getResourceDataInfoAsync(tenantId, id)) + .toList(); + return Futures.allAsList(futures); + } + + private List buildContents(String userPrompt, List resources) { + List contents = new ArrayList<>(1 + resources.size()); + contents.add(new TextContent(userPrompt)); // user prompt first + + resources.stream() + .filter(Objects::nonNull) + .map(this::toContent) + .forEach(contents::add); + + return contents; + } + + private Content toContent(TbResourceDataInfo resource) { + if (resource.getDescriptor() == null) { + throw new RuntimeException("Missing descriptor for resource"); + } + GeneralFileDescriptor descriptor = JacksonUtil.treeToValue(resource.getDescriptor(), GeneralFileDescriptor.class); + String mediaType = descriptor.getMediaType(); + if (mediaType == null) { + throw new RuntimeException("Missing mediaType in resource descriptor " + resource.getDescriptor()); + } + byte[] data = resource.getData(); + if (mediaType.startsWith("text/")) { + return new TextContent(new String(data, StandardCharsets.UTF_8)); + } + if (mediaType.equals("application/pdf")) { + return new PdfFileContent(Base64.getEncoder().encodeToString(data), mediaType); + } + if (mediaType.startsWith("image/")) { + return new ImageContent(Base64.getEncoder().encodeToString(data), mediaType); + } + log.debug("Trying to create text content for {}", resource.getDescriptor()); + return new TextContent(new String(data, StandardCharsets.UTF_8)); + } + @Override public void destroy() { super.destroy(); systemPrompt = null; userPrompt = null; + resourceIds = null; responseFormat = null; modelId = null; } diff --git a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/ai/TbAiNodeConfiguration.java b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/ai/TbAiNodeConfiguration.java index 48392aa76e..f51983ecb1 100644 --- a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/ai/TbAiNodeConfiguration.java +++ b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/ai/TbAiNodeConfiguration.java @@ -20,12 +20,14 @@ import jakarta.validation.constraints.Max; import jakarta.validation.constraints.Min; import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.NotNull; -import jakarta.validation.constraints.Pattern; import lombok.Data; import org.thingsboard.rule.engine.api.NodeConfiguration; import org.thingsboard.server.common.data.id.AiModelId; import org.thingsboard.server.common.data.validation.Length; +import java.util.Set; +import java.util.UUID; + import static org.thingsboard.rule.engine.ai.TbResponseFormat.TbJsonResponseFormat; @Data @@ -41,6 +43,8 @@ public class TbAiNodeConfiguration implements NodeConfiguration resourceIds; + @NotNull @Valid private TbResponseFormat responseFormat; diff --git a/rule-engine/rule-engine-components/src/test/java/org/thingsboard/rule/engine/ai/TbAiNodeTest.java b/rule-engine/rule-engine-components/src/test/java/org/thingsboard/rule/engine/ai/TbAiNodeTest.java index f21aa559d4..c5b7f2c44b 100644 --- a/rule-engine/rule-engine-components/src/test/java/org/thingsboard/rule/engine/ai/TbAiNodeTest.java +++ b/rule-engine/rule-engine-components/src/test/java/org/thingsboard/rule/engine/ai/TbAiNodeTest.java @@ -17,8 +17,11 @@ package org.thingsboard.rule.engine.ai; import com.fasterxml.jackson.databind.node.ObjectNode; import com.google.common.util.concurrent.FluentFuture; +import com.google.common.util.concurrent.Futures; import dev.langchain4j.data.message.AiMessage; +import dev.langchain4j.data.message.ImageContent; import dev.langchain4j.data.message.SystemMessage; +import dev.langchain4j.data.message.TextContent; import dev.langchain4j.data.message.UserMessage; import dev.langchain4j.model.chat.request.ResponseFormat; import dev.langchain4j.model.chat.request.ResponseFormatType; @@ -32,6 +35,7 @@ import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.Arguments; import org.junit.jupiter.params.provider.MethodSource; import org.junit.jupiter.params.provider.ValueSource; +import org.mockito.ArgumentCaptor; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; import org.thingsboard.common.util.JacksonUtil; @@ -43,6 +47,10 @@ import org.thingsboard.rule.engine.api.RuleEngineAiChatModelService; import org.thingsboard.rule.engine.api.TbContext; import org.thingsboard.rule.engine.api.TbNodeConfiguration; import org.thingsboard.rule.engine.api.TbNodeException; +import org.thingsboard.server.common.data.GeneralFileDescriptor; +import org.thingsboard.server.common.data.ResourceType; +import org.thingsboard.server.common.data.TbResource; +import org.thingsboard.server.common.data.TbResourceDataInfo; import org.thingsboard.server.common.data.ai.AiModel; import org.thingsboard.server.common.data.ai.model.AiModelConfig; import org.thingsboard.server.common.data.ai.model.chat.AnthropicChatModelConfig; @@ -52,6 +60,7 @@ import org.thingsboard.server.common.data.ai.provider.OpenAiProviderConfig; import org.thingsboard.server.common.data.id.AiModelId; import org.thingsboard.server.common.data.id.DeviceId; import org.thingsboard.server.common.data.id.RuleNodeId; +import org.thingsboard.server.common.data.id.TbResourceId; import org.thingsboard.server.common.data.id.TenantId; import org.thingsboard.server.common.data.msg.TbNodeConnectionType; import org.thingsboard.server.common.data.rule.RuleNode; @@ -59,9 +68,14 @@ import org.thingsboard.server.common.msg.TbMsg; import org.thingsboard.server.common.msg.TbMsgMetaData; import org.thingsboard.server.dao.ai.AiModelService; import org.thingsboard.server.dao.exception.DataValidationException; +import org.thingsboard.server.dao.resource.ResourceService; +import org.thingsboard.server.dao.resource.TbResourceDataCache; +import java.util.Base64; +import java.util.List; import java.util.Map; import java.util.Optional; +import java.util.Set; import java.util.UUID; import java.util.stream.Stream; @@ -76,16 +90,23 @@ import static org.mockito.BDDMockito.given; import static org.mockito.BDDMockito.then; import static org.mockito.Mockito.lenient; import static org.mockito.Mockito.never; +import static org.thingsboard.server.common.data.ResourceType.GENERAL; @ExtendWith(MockitoExtension.class) class TbAiNodeTest { + private static final byte[] PNG_IMAGE = Base64.getDecoder().decode("iVBORw0KGgoAAAANSUhEUgAAAMgAAACgCAMAAAB+IdObAAAC9FBMVEUAAAABAQEBAgICAgICAwMCBAQDAwMDBQUDBgYEBAQEBwcECAgFCQkFCgoGBgYGCwsGDAwHBwcHDQ0HDg4ICAgIDw8IEBAJCQkJEREKEhIKExMLFBQLFRUMFhYMFxcNDQ0NGBgNGRkODg4OGhoOGxsPDw8PHBwPHR0QEBAQHh4QHx8RERERICARISESEhISIiITExMTIyMTJCQUJSUUJiYVKCgWFhYWKSkXFxcXGhwYGBgYLC0ZGRkaMDEaMTIbGxsbMjMcMzQdNTYfOTogICAgOzwiP0AiQEEjIyMjQkMkQ0QnJycnSEkoS0wpKSkrUFErUVIsLCwvV1gvWFkwWlszMzMzYGE1NTU2NjY3Zmc4aWo5OTk5ams5a2w6Ojo6bG07bm88cXI9cnM9c3Q/dndAQEBAeHlBeXpCQkJCe3xCfH1DQ0NEREREf4FFRUVFgIJGg4VHhYdISEhIhohJh4lLi41LjI5MTExMjpBNj5FNkJJOkpRQUFBQlZdRUVFSUlJTU1NTmpxUVFRUnZ9VVVVVnqBWVlZYpadZWVlZp6laqKpbW1tbqatbqqxcXFxcrK5dra9drrBeXl5er7FfsbNfsrRgs7VhYWFiYmJiuLpjubtku71lvL5lvb9mvsBnwcNowsRpxMZpxcdra2tryctsysxubm5uzc9vb29vz9Fw0dNx0tVy1Ndy1dhz1tlz19p0dHR02Nt02dx12t1229523N93d3d33eB33uF5eXl54eR6enp64+Z65Od75eh75ul8fHx85+p86Ot96ex96u2AgICA7vGA7/KB8fSC8/aD9PeD9fiEhISE9/qF+PuF+fyGhoaG+v2G+/6Hh4eH/P+IiIiMjIyNjY2Ojo6QkJCRkZGSkpKTk5Obm5ucnJyfn5+lpaWnp6eoqKipqamqqqqwsLCzs7O1tbW4uLi5ubm6urq7u7u8vLy/v7/BwcHCwsLFxcXGxsbPz8/Y2Nji4uLj4+Pv7+/4+Pj5+fn+/v7/75T///+GLm1tAAAAAWJLR0T7omo23AAABJtJREFUeNrt3Wd8E3UYB/CH0oqm1dJaS5N0IKu0qQSVinXG4gKlKFi3uMC9FVwoVQnQqCBgBVxFnKCoFFFExFGhliWt/zoYLuIMKEpB7b3xuf9dQu+MvAjXcsTf7/PJk/ul1/S+TS53r3KkNFfk0V6evDHbFGruQ3EQTzNVUFxkHOXFB6QbIQiCIAiC/GeSs/QkR6vkCPeUaNUeSUjkkdR1npCp6a7VV7U6P1dbKfNFrS89rJNas/T6rlZtkUS/i2evhw99Q92y9/r7nVzzw7VfeDX3y2qv893plTVb1uW+uw6xiyNpspAQ8bjLy8l5REiImOlUq3Pniunyxw8Ib+vqF7aB5AgdItLVmit0iOgc9W0owhDt1RSAABL3EGeDDqmXhwRXgw6pj3qESFhtgHC1DYSGrJCQjweFq4SEqzkD67zGah8Inay+p1yl4XqKWt2lF69UDxQrzzevXZprrDn2gfTIUs85Iv/oHpny8HKHdugeVZhpXNudu6u6J1P8lmpIX1ys10X6myVfPeLl919UZFi74JXjWtfCecfa5sj+odx908XSg9Taqdaw+3I1QuYLA6RG2AbiEDpE9JJnvcYP1BRhgiw3QuoAASTuIQnP6JCF8hQlcbYBwrWIKgPDIg9UGSGP2QdCnZ+QkDneKQs4swqe1CDJ09RaXfBUETWKm3a+gFMMEMc0+0AoJVX9nM1+VDsCznLurz64b5VWq7nWLLi81QfygYZfNlU7nAUP0nOwrLnGiiAIgiAIgiAIgiDI/zstLS3tMEtKSiycgAACCCCAAAIIIIAAAggggAACCCCAAAIIIIAAAggggAACCCCAAAIIIIAAAggggAACCCCAAAIIIIBYAkEQBEEQBEEQBEEQBGmrdLwuyLmhg703km8Z63k7N2Tw0jnqFt/f0bROn69WBYOfbuxiyR+8MXC9vB8QCBTQkEAgMOG2gVyvDmTzdAWuifFp077m8f503vwZr/PSd28Hg+uaTjVDlOFEIxVrINVijfwi4glCHE1XioXPz6kX9xHNFIUkvyM/xqeduIPHup95bGni8edYotOUqJCrrII0iMv4LnNFg4Sczd/9/Zw4abchD0Ygv0pIBVFZG0Nq587lu/PE02EIXSQuaSfI92l88bfNFkHqLxUnEM1+bXQEMloMY8hgn893esyQIzbzWHtveXn51GW89AtfTeyATWZIWm919s6wBtLYdfXdVCyuuEdCHhoxwr/mAzdDtMQKoaP4duQmRVG+kUtyu83X3OuylX09f+9r0c6eOvkjx82fdPdLiHrdjsrD1Z39LP5W06ExQ475g8eqSR6PZ+oXvLSVNWk/nmmGKNcSXaBYBXEPFkMXV1GlhFyYlSof3t19ZOxfPJp+4/HTeh47JhGdqLQxJDtpyRJxBgUi+0g7QkYSlVsHoVtFrcNiyO0SsoXHDxIykej4v/8F+XxDKLRxmXWQfo2jyGJIh894PDs9FArNeIGXvlwbCn37Upl5rXObOMPtf1K4z5u8ne/sx0tl6hbfgtNkBEGQPZs4uUBwTxoTH5DxtM0TD46+20lpHrfXX7e52/jtyj9kFKbIT2L3FQAAAABJRU5ErkJggg=="); + @Mock TbContext ctxMock; @Mock AiModelService aiModelServiceMock; @Mock RuleEngineAiChatModelService aiChatModelServiceMock; + @Mock + TbResourceDataCache tbResourceDataCacheMock; + @Mock + ResourceService resourceServiceMock; TbAiNode aiNode; TbAiNodeConfiguration config; @@ -141,6 +162,8 @@ class TbAiNodeTest { lenient().when(ctxMock.getAiModelService()).thenReturn(aiModelServiceMock); lenient().when(ctxMock.getAiChatModelService()).thenReturn(aiChatModelServiceMock); lenient().when(ctxMock.getDbCallbackExecutor()).thenReturn(new TestDbCallbackExecutor()); + lenient().when(ctxMock.getTbResourceDataCache()).thenReturn(tbResourceDataCacheMock); + lenient().when(ctxMock.getResourceService()).thenReturn(resourceServiceMock); } @Test @@ -158,6 +181,7 @@ class TbAiNodeTest { assertThat(config.getResponseFormat()).isEqualTo(new TbJsonResponseFormat()); assertThat(config.getTimeoutSeconds()).isEqualTo(60); assertThat(config.isForceAck()).isTrue(); + assertThat(config.getResourceIds()).isNull(); } /* -- Node initialization tests -- */ @@ -373,6 +397,36 @@ class TbAiNodeTest { .matches(e -> ((TbNodeException) e).isUnrecoverable()); } + @Test + void givenNotExistingResources_whenInit_thenThrowsException() { + // GIVEN + config = constructValidConfig(); + UUID resourceId = UUID.randomUUID(); + config.setResourceIds(Set.of(resourceId)); + + // WHEN-THEN + assertThatThrownBy(() -> aiNode.init(ctxMock, new TbNodeConfiguration(JacksonUtil.valueToTree(config)))) + .isInstanceOf(TbNodeException.class) + .hasMessageContaining("[" + tenantId + "] Resource with ID: [" + resourceId + "] was not found"); + } + + @Test + void givenResourceOfWrongType_whenInit_thenThrowsException() { + // GIVEN + config = constructValidConfig(); + UUID resourceId = UUID.randomUUID(); + config.setResourceIds(Set.of(resourceId)); + + // WHEN-THEN + TbResource tbResource = new TbResource(); + tbResource.setResourceType(ResourceType.DASHBOARD); + given(resourceServiceMock.findResourceInfoById(any(), any())).willReturn(tbResource); + + assertThatThrownBy(() -> aiNode.init(ctxMock, new TbNodeConfiguration(JacksonUtil.valueToTree(config)))) + .isInstanceOf(TbNodeException.class) + .hasMessageContaining("[" + tenantId + "] Resource with ID: [" + resourceId + "] has unsupported resource type: " + ResourceType.DASHBOARD); + } + /* -- Message processing tests -- */ @Test @@ -560,6 +614,166 @@ class TbAiNodeTest { ); } + @Test + void givenSystemPromptAndUserPromptAndResourcesConfigured_whenOnMsg_thenRequestContainsSystemAndUserAndResourceContent() throws TbNodeException { + String systemPrompt = "Respond with valid JSON"; + String userPrompt = "Tell me a joke"; + String textData = "Text resource content for AI request."; + String xmlData = ""; + + // GIVEN + config = constructValidConfig(); + config.setSystemPrompt(systemPrompt); + config.setUserPrompt(userPrompt); + UUID resourceId = UUID.randomUUID(); + UUID resourceId2 = UUID.randomUUID(); + UUID resourceId3 = UUID.randomUUID(); + + config.setResourceIds(Set.of(resourceId, resourceId2, resourceId3)); + + // WHEN-THEN + TbResource textResource = buildGeneralResource(textData.getBytes(), "text/plain"); + TbResource xmlResource = buildGeneralResource(xmlData.getBytes(), "application/xml"); + TbResource imageResource = buildGeneralResource(PNG_IMAGE, "image/png"); + + given(resourceServiceMock.findResourceInfoById(any(), eq(new TbResourceId(resourceId)))).willReturn(textResource); + given(resourceServiceMock.findResourceInfoById(any(), eq(new TbResourceId(resourceId2)))).willReturn(xmlResource); + given(resourceServiceMock.findResourceInfoById(any(), eq(new TbResourceId(resourceId3)))).willReturn(imageResource); + + given(tbResourceDataCacheMock.getResourceDataInfoAsync(any(), eq(new TbResourceId(resourceId)))).willReturn(FluentFuture.from(Futures.immediateFuture(textResource.toResourceDataInfo()))); + given(tbResourceDataCacheMock.getResourceDataInfoAsync(any(), eq(new TbResourceId(resourceId2)))).willReturn(FluentFuture.from(Futures.immediateFuture(xmlResource.toResourceDataInfo()))); + given(tbResourceDataCacheMock.getResourceDataInfoAsync(any(), eq(new TbResourceId(resourceId3)))).willReturn(FluentFuture.from(Futures.immediateFuture(imageResource.toResourceDataInfo()))); + + aiNode.init(ctxMock, new TbNodeConfiguration(JacksonUtil.valueToTree(config))); + + var msg = TbMsg.newMsg() + .originator(deviceId) + .data(TbMsg.EMPTY_JSON_OBJECT) + .metaData(TbMsgMetaData.EMPTY) + .build(); + + var chatResponse = ChatResponse.builder() + .aiMessage(AiMessage.from("{\"type\":\"joke\",\"setup\":\"Why did the scarecrow win an award?\",\"punchline\":\"Because he was outstanding in his field.\"}")) + .build(); + + given(aiChatModelServiceMock.sendChatRequestAsync(any(), any())).willReturn(FluentFuture.from(immediateFuture(chatResponse))); + + // WHEN + aiNode.onMsg(ctxMock, msg); + + // THEN + then(aiChatModelServiceMock).should().sendChatRequestAsync(any(), + argThat(actualChatRequest -> { + assertThat(actualChatRequest.messages()).hasSize(2); + assertThat(actualChatRequest.messages().get(0)).isEqualTo(SystemMessage.from(systemPrompt)); + assertThat(((UserMessage)actualChatRequest.messages().get(1)).contents()) + .containsAll(List.of(new TextContent(userPrompt), new TextContent(textData), + new TextContent(xmlData), new ImageContent(Base64.getEncoder().encodeToString(PNG_IMAGE), "image/png"))); + return true; + }) + ); + } + + @Test + void givenNullResource_whenOnMsg_thenRequestContainsSystemAndUserPrompt() throws TbNodeException { + // GIVEN + config = constructValidConfig(); + UUID resourceId = UUID.randomUUID(); + config.setResourceIds(Set.of(resourceId)); + + // WHEN-THEN + TbResource tbResource = buildGeneralResource("Text resource content for AI request.".getBytes(), "text/plain"); + + given(resourceServiceMock.findResourceInfoById(any(), eq(new TbResourceId(resourceId)))).willReturn(tbResource); + given(tbResourceDataCacheMock.getResourceDataInfoAsync(any(), eq(new TbResourceId(resourceId)))).willReturn(FluentFuture.from(Futures.immediateFuture(null))); + + aiNode.init(ctxMock, new TbNodeConfiguration(JacksonUtil.valueToTree(config))); + + var msg = TbMsg.newMsg() + .originator(deviceId) + .data(TbMsg.EMPTY_JSON_OBJECT) + .metaData(TbMsgMetaData.EMPTY) + .build(); + + // WHEN + aiNode.onMsg(ctxMock, msg); + + // THEN + then(aiChatModelServiceMock).should().sendChatRequestAsync(any(), + argThat(actualChatRequest -> { + assertThat(actualChatRequest.messages()).hasSize(2); + assertThat(actualChatRequest.messages().get(0)).isEqualTo(SystemMessage.from(config.getSystemPrompt())); + assertThat(((UserMessage)actualChatRequest.messages().get(1)).contents()) + .containsAll(List.of(new TextContent(config.getUserPrompt()))); + return true; + }) + ); + } + + @Test + void givenResourceWithNoDescriptor_whenOnMsg_thenEnqueueForTellFailure() throws TbNodeException { + // GIVEN + config = constructValidConfig(); + UUID resourceId = UUID.randomUUID(); + config.setResourceIds(Set.of(resourceId)); + + // WHEN-THEN + TbResource tbResource = buildGeneralResource("Text resource content for AI request.".getBytes(), "text/plain"); + TbResourceDataInfo resourceDataInfo = new TbResourceDataInfo(tbResource.getData(), null); + + given(resourceServiceMock.findResourceInfoById(any(), eq(new TbResourceId(resourceId)))).willReturn(tbResource); + given(tbResourceDataCacheMock.getResourceDataInfoAsync(any(), eq(new TbResourceId(resourceId)))).willReturn(FluentFuture.from(Futures.immediateFuture(resourceDataInfo))); + + aiNode.init(ctxMock, new TbNodeConfiguration(JacksonUtil.valueToTree(config))); + + var msg = TbMsg.newMsg() + .originator(deviceId) + .data(TbMsg.EMPTY_JSON_OBJECT) + .metaData(TbMsgMetaData.EMPTY) + .build(); + + // WHEN + aiNode.onMsg(ctxMock, msg); + + // THEN + var exceptionCaptor = ArgumentCaptor.forClass(Throwable.class); + then(ctxMock).should().enqueueForTellFailure(any(), exceptionCaptor.capture()); + Throwable actualException = exceptionCaptor.getValue(); + assertThat(actualException.getMessage()).isEqualTo("Missing descriptor for resource"); + } + + @Test + void givenResourceWithNoMediaType_whenOnMsg_thenEnqueueForTellFailure() throws TbNodeException { + // GIVEN + config = constructValidConfig(); + UUID resourceId = UUID.randomUUID(); + config.setResourceIds(Set.of(resourceId)); + + // WHEN-THEN + TbResource tbResource = buildGeneralResource("Text resource content for AI request.".getBytes(), "text/plain"); + TbResourceDataInfo resourceDataInfo = new TbResourceDataInfo(tbResource.getData(), JacksonUtil.newObjectNode()); + + given(resourceServiceMock.findResourceInfoById(any(), eq(new TbResourceId(resourceId)))).willReturn(tbResource); + given(tbResourceDataCacheMock.getResourceDataInfoAsync(any(), eq(new TbResourceId(resourceId)))).willReturn(FluentFuture.from(Futures.immediateFuture(resourceDataInfo))); + + aiNode.init(ctxMock, new TbNodeConfiguration(JacksonUtil.valueToTree(config))); + + var msg = TbMsg.newMsg() + .originator(deviceId) + .data(TbMsg.EMPTY_JSON_OBJECT) + .metaData(TbMsgMetaData.EMPTY) + .build(); + + // WHEN + aiNode.onMsg(ctxMock, msg); + + // THEN + var exceptionCaptor = ArgumentCaptor.forClass(Throwable.class); + then(ctxMock).should().enqueueForTellFailure(any(), exceptionCaptor.capture()); + Throwable actualException = exceptionCaptor.getValue(); + assertThat(actualException.getMessage()).isEqualTo("Missing mediaType in resource descriptor {}"); + } + @Test void givenTemplatedPrompts_whenOnMsg_thenRequestContainsSubstitutedMessages() throws TbNodeException { // GIVEN @@ -950,4 +1164,13 @@ class TbAiNodeTest { then(ctxMock).should(never()).tellFailure(any(), any()); } + private TbResource buildGeneralResource(byte[] data, String mediaType) { + TbResource tbResource = new TbResource(); + tbResource.setResourceType(GENERAL); + GeneralFileDescriptor descriptor = new GeneralFileDescriptor(mediaType); + tbResource.setDescriptorValue(descriptor); + tbResource.setData(data); + return tbResource; + } + } From b9a9348eac7251d7e53b941c0caf6e945dfd82df Mon Sep 17 00:00:00 2001 From: ArtemDzhereleiko Date: Tue, 2 Sep 2025 14:30:09 +0300 Subject: [PATCH 044/102] UI: Add new resource type text --- ui-ngx/src/app/core/http/entity.service.ts | 8 +- ui-ngx/src/app/core/http/resource.service.ts | 8 +- .../home/components/home-components.module.ts | 6 + .../resources/resources-dialog.component.html | 54 +++++++++ .../resources/resources-dialog.component.scss | 24 ++++ .../resources/resources-dialog.component.ts | 113 ++++++++++++++++++ .../resources-library.component.html | 2 +- .../resources}/resources-library.component.ts | 18 ++- .../external/ai-config.component.html | 10 ++ .../rule-node/external/ai-config.component.ts | 24 ++++ .../modules/home/pages/admin/admin.module.ts | 2 - .../resources-library-table-config.resolve.ts | 2 +- .../resources-table-header.component.ts | 2 +- .../entity/entity-list.component.html | 11 ++ .../entity/entity-list.component.ts | 29 ++++- .../src/app/shared/models/resource.models.ts | 12 +- .../assets/locale/locale.constant-en_US.json | 6 +- 17 files changed, 310 insertions(+), 21 deletions(-) create mode 100644 ui-ngx/src/app/modules/home/components/resources/resources-dialog.component.html create mode 100644 ui-ngx/src/app/modules/home/components/resources/resources-dialog.component.scss create mode 100644 ui-ngx/src/app/modules/home/components/resources/resources-dialog.component.ts rename ui-ngx/src/app/modules/home/{pages/admin/resource => components/resources}/resources-library.component.html (98%) rename ui-ngx/src/app/modules/home/{pages/admin/resource => components/resources}/resources-library.component.ts (89%) diff --git a/ui-ngx/src/app/core/http/entity.service.ts b/ui-ngx/src/app/core/http/entity.service.ts index 572d6cc473..53a55bfe7f 100644 --- a/ui-ngx/src/app/core/http/entity.service.ts +++ b/ui-ngx/src/app/core/http/entity.service.ts @@ -100,6 +100,7 @@ import { OAuth2Service } from '@core/http/oauth2.service'; import { MobileAppService } from '@core/http/mobile-app.service'; import { PlatformType } from '@shared/models/oauth2.models'; import { AiModelService } from '@core/http/ai-model.service'; +import { ResourceType } from "@shared/models/resource.models"; @Injectable({ providedIn: 'root' @@ -297,6 +298,11 @@ export class EntityService { (id) => this.ruleChainService.getRuleChain(id, config), entityIds); break; + case EntityType.TB_RESOURCE: + observable = this.getEntitiesByIdsObservable( + (id) => this.resourceService.getResource(id, config), + entityIds); + break; } return observable; } @@ -472,7 +478,7 @@ export class EntityService { break; case EntityType.TB_RESOURCE: pageLink.sortOrder.property = 'title'; - entitiesObservable = this.resourceService.getTenantResources(pageLink, config); + entitiesObservable = this.resourceService.getTenantResources(pageLink, subType as ResourceType, config); break; case EntityType.QUEUE_STATS: pageLink.sortOrder.property = 'createdTime'; diff --git a/ui-ngx/src/app/core/http/resource.service.ts b/ui-ngx/src/app/core/http/resource.service.ts index 615b721b97..52c92d96e4 100644 --- a/ui-ngx/src/app/core/http/resource.service.ts +++ b/ui-ngx/src/app/core/http/resource.service.ts @@ -47,8 +47,12 @@ export class ResourceService { return this.http.get>(url, defaultHttpOptionsFromConfig(config)); } - public getTenantResources(pageLink: PageLink, config?: RequestConfig): Observable> { - return this.http.get>(`/api/resource/tenant${pageLink.toQuery()}`, defaultHttpOptionsFromConfig(config)); + public getTenantResources(pageLink: PageLink, resourceType?: ResourceType, config?: RequestConfig): Observable> { + let url = `/api/resource${pageLink.toQuery()}`; + if (isNotEmptyStr(resourceType)) { + url += `&resourceType=${resourceType}`; + } + return this.http.get>(url, defaultHttpOptionsFromConfig(config)); } public getResource(resourceId: string, config?: RequestConfig): Observable { diff --git a/ui-ngx/src/app/modules/home/components/home-components.module.ts b/ui-ngx/src/app/modules/home/components/home-components.module.ts index 31a3066edf..060f804cdf 100644 --- a/ui-ngx/src/app/modules/home/components/home-components.module.ts +++ b/ui-ngx/src/app/modules/home/components/home-components.module.ts @@ -205,6 +205,8 @@ import { } from '@home/components/calculated-fields/components/test-arguments/calculated-field-test-arguments.component'; import { CheckConnectivityDialogComponent } from '@home/components/ai-model/check-connectivity-dialog.component'; import { AIModelDialogComponent } from '@home/components/ai-model/ai-model-dialog.component'; +import { ResourcesDialogComponent } from "@home/components/resources/resources-dialog.component"; +import { ResourcesLibraryComponent } from "@home/components/resources/resources-library.component"; @NgModule({ declarations: @@ -358,6 +360,8 @@ import { AIModelDialogComponent } from '@home/components/ai-model/ai-model-dialo CalculatedFieldTestArgumentsComponent, CheckConnectivityDialogComponent, AIModelDialogComponent, + ResourcesDialogComponent, + ResourcesLibraryComponent, ], imports: [ CommonModule, @@ -505,6 +509,8 @@ import { AIModelDialogComponent } from '@home/components/ai-model/ai-model-dialo CalculatedFieldTestArgumentsComponent, CheckConnectivityDialogComponent, AIModelDialogComponent, + ResourcesDialogComponent, + ResourcesLibraryComponent, ], providers: [ WidgetComponentService, diff --git a/ui-ngx/src/app/modules/home/components/resources/resources-dialog.component.html b/ui-ngx/src/app/modules/home/components/resources/resources-dialog.component.html new file mode 100644 index 0000000000..c6813ff0f2 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/resources/resources-dialog.component.html @@ -0,0 +1,54 @@ + +
+ +

{{ 'resource.add' | translate }}

+ + +
+ + +
+
+ + +
+
+ + +
+
diff --git a/ui-ngx/src/app/modules/home/components/resources/resources-dialog.component.scss b/ui-ngx/src/app/modules/home/components/resources/resources-dialog.component.scss new file mode 100644 index 0000000000..b32c3933c5 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/resources/resources-dialog.component.scss @@ -0,0 +1,24 @@ +/** + * 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. + */ + +:host ::ng-deep { + .mat-mdc-dialog-content { + display: flex; + flex-direction: column; + height: 100%; + padding: 0 !important; + } +} diff --git a/ui-ngx/src/app/modules/home/components/resources/resources-dialog.component.ts b/ui-ngx/src/app/modules/home/components/resources/resources-dialog.component.ts new file mode 100644 index 0000000000..a06c72827a --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/resources/resources-dialog.component.ts @@ -0,0 +1,113 @@ +/// +/// 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. +/// + +import { AfterViewInit, Component, Inject, SkipSelf, ViewChild } from '@angular/core'; +import { DialogComponent } from '@shared/components/dialog.component'; +import { Store } from '@ngrx/store'; +import { AppState } from '@core/core.state'; +import { Router } from '@angular/router'; +import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog'; +import { FormGroupDirective, NgForm, UntypedFormControl } from '@angular/forms'; +import { EntityType } from '@shared/models/entity-type.models'; +import { map } from 'rxjs/operators'; +import { ResourcesLibraryComponent } from "@home/components/resources/resources-library.component"; +import { ErrorStateMatcher } from "@angular/material/core"; +import { Resource, ResourceType } from "@shared/models/resource.models"; +import { ResourceService } from "@core/http/resource.service"; + +export interface ResourcesDialogData { + resources?: Resource; + isAdd?: boolean; +} + +@Component({ + selector: 'tb-resources-dialog', + templateUrl: './resources-dialog.component.html', + providers: [{provide: ErrorStateMatcher, useExisting: ResourcesDialogComponent}], + styleUrls: ['./resources-dialog.component.scss'] +}) +export class ResourcesDialogComponent extends DialogComponent implements ErrorStateMatcher, AfterViewInit { + + readonly entityType = EntityType; + + ResourceType = ResourceType; + + isAdd = false; + + submitted = false; + + resources: Resource; + + @ViewChild('resourcesComponent', {static: true}) resourcesComponent: ResourcesLibraryComponent; + + constructor(protected store: Store, + protected router: Router, + protected dialogRef: MatDialogRef, + @Inject(MAT_DIALOG_DATA) public data: ResourcesDialogData, + @SkipSelf() private errorStateMatcher: ErrorStateMatcher, + private resourceService: ResourceService) { + super(store, router, dialogRef); + + if (this.data.isAdd) { + this.isAdd = true; + } + + if (this.data.resources) { + this.resources = this.data.resources; + } + } + + ngAfterViewInit(): void { + if (this.isAdd) { + setTimeout(() => { + this.resourcesComponent.entityForm.markAsDirty(); + }, 0); + } + } + + isErrorState(control: UntypedFormControl | null, form: FormGroupDirective | NgForm | null): boolean { + const originalErrorState = this.errorStateMatcher.isErrorState(control, form); + const customErrorState = !!(control && control.invalid && this.submitted); + return originalErrorState || customErrorState; + } + + cancel(): void { + this.dialogRef.close(null); + } + + save(): void { + this.submitted = true; + if (this.resourcesComponent.entityForm.valid) { + const resource = {...this.resourcesComponent.entityFormValue()}; + if (Array.isArray(resource.data)) { + const resources = []; + resource.data.forEach((data, index) => { + resources.push({ + resourceType: resource.resourceType, + data, + fileName: resource.fileName[index], + title: resource.title + }); + }); + this.resourceService.saveResources(resources, {resendRequest: true}).pipe( + map((response) => response[0]) + ).subscribe(result => this.dialogRef.close(result)); + } else { + this.resourceService.saveResource(resource).subscribe(result => this.dialogRef.close(result)); + } + } + } +} diff --git a/ui-ngx/src/app/modules/home/pages/admin/resource/resources-library.component.html b/ui-ngx/src/app/modules/home/components/resources/resources-library.component.html similarity index 98% rename from ui-ngx/src/app/modules/home/pages/admin/resource/resources-library.component.html rename to ui-ngx/src/app/modules/home/components/resources/resources-library.component.html index ebb946ccbc..c602906be1 100644 --- a/ui-ngx/src/app/modules/home/pages/admin/resource/resources-library.component.html +++ b/ui-ngx/src/app/modules/home/components/resources/resources-library.component.html @@ -15,7 +15,7 @@ limitations under the License. --> -
+
diff --git a/ui-ngx/src/app/modules/home/components/rule-node/external/ai-config.component.ts b/ui-ngx/src/app/modules/home/components/rule-node/external/ai-config.component.ts index bfba25afa1..a4d973e998 100644 --- a/ui-ngx/src/app/modules/home/components/rule-node/external/ai-config.component.ts +++ b/ui-ngx/src/app/modules/home/components/rule-node/external/ai-config.component.ts @@ -24,6 +24,8 @@ import { AiModel, AiRuleNodeResponseFormatTypeOnlyText, ResponseFormat } from '@ import { deepTrim } from '@core/utils'; import { TranslateService } from '@ngx-translate/core'; import { jsonRequired } from '@shared/components/json-object-edit.component'; +import { Resource, ResourceType } from "@shared/models/resource.models"; +import { ResourcesDialogComponent, ResourcesDialogData } from "@home/components/resources/resources-dialog.component"; @Component({ selector: 'tb-external-node-ai-config', @@ -38,6 +40,9 @@ export class AiConfigComponent extends RuleNodeConfigurationComponent { responseFormat = ResponseFormat; + EntityType = EntityType; + ResourceType = ResourceType; + constructor(private fb: UntypedFormBuilder, private translate: TranslateService, private dialog: MatDialog) { @@ -53,6 +58,7 @@ export class AiConfigComponent extends RuleNodeConfigurationComponent { modelId: [configuration?.modelId ?? null, [Validators.required]], systemPrompt: [configuration?.systemPrompt ?? '', [Validators.maxLength(500_000), Validators.pattern(/.*\S.*/)]], userPrompt: [configuration?.userPrompt ?? '', [Validators.required, Validators.maxLength(500_000), Validators.pattern(/.*\S.*/)]], + resourceIds: [configuration?.resourceIds ?? []], responseFormat: this.fb.group({ type: [configuration?.responseFormat?.type ?? ResponseFormat.JSON, []], schema: [configuration?.responseFormat?.schema ?? null, [jsonRequired]], @@ -116,5 +122,23 @@ export class AiConfigComponent extends RuleNodeConfigurationComponent { this.aiConfigForm.get(formControl).markAsDirty(); } }); + }; + + createAiResources(name: string, formControl: string) { + this.dialog.open(ResourcesDialogComponent, { + disableClose: true, + panelClass: ['tb-dialog', 'tb-fullscreen-dialog'], + data: { + resources: {title: name, resourceType: ResourceType.TEXT}, + isAdd: true + } + }).afterClosed() + .subscribe((resource) => { + if (resource) { + const resourceIds = [...(this.aiConfigForm.get(formControl).value || []), resource.id.id]; + this.aiConfigForm.get(formControl).patchValue(resourceIds); + this.aiConfigForm.get(formControl).markAsDirty(); + } + }); } } diff --git a/ui-ngx/src/app/modules/home/pages/admin/admin.module.ts b/ui-ngx/src/app/modules/home/pages/admin/admin.module.ts index 10721ade5a..60790edd74 100644 --- a/ui-ngx/src/app/modules/home/pages/admin/admin.module.ts +++ b/ui-ngx/src/app/modules/home/pages/admin/admin.module.ts @@ -26,7 +26,6 @@ import { HomeComponentsModule } from '@modules/home/components/home-components.m import { SmsProviderComponent } from '@home/pages/admin/sms-provider.component'; import { SendTestSmsDialogComponent } from '@home/pages/admin/send-test-sms-dialog.component'; import { HomeSettingsComponent } from '@home/pages/admin/home-settings.component'; -import { ResourcesLibraryComponent } from '@home/pages/admin/resource/resources-library.component'; import { ResourceTabsComponent } from '@home/pages/admin/resource/resource-tabs.component'; import { ResourcesTableHeaderComponent } from '@home/pages/admin/resource/resources-table-header.component'; import { QueueComponent } from '@home/pages/admin/queue/queue.component'; @@ -49,7 +48,6 @@ import { ResourceLibraryTabsComponent } from '@home/pages/admin/resource/resourc SendTestSmsDialogComponent, SecuritySettingsComponent, HomeSettingsComponent, - ResourcesLibraryComponent, ResourceTabsComponent, ResourceLibraryTabsComponent, ResourcesTableHeaderComponent, diff --git a/ui-ngx/src/app/modules/home/pages/admin/resource/resources-library-table-config.resolve.ts b/ui-ngx/src/app/modules/home/pages/admin/resource/resources-library-table-config.resolve.ts index f39ed9ff7b..dc85ca4914 100644 --- a/ui-ngx/src/app/modules/home/pages/admin/resource/resources-library-table-config.resolve.ts +++ b/ui-ngx/src/app/modules/home/pages/admin/resource/resources-library-table-config.resolve.ts @@ -32,7 +32,7 @@ import { getCurrentAuthUser } from '@core/auth/auth.selectors'; import { Store } from '@ngrx/store'; import { AppState } from '@core/core.state'; import { Authority } from '@shared/models/authority.enum'; -import { ResourcesLibraryComponent } from '@home/pages/admin/resource/resources-library.component'; +import { ResourcesLibraryComponent } from '@home/components/resources/resources-library.component'; import { PageLink } from '@shared/models/page/page-link'; import { EntityAction } from '@home/models/entity/entity-component.models'; import { map } from 'rxjs/operators'; diff --git a/ui-ngx/src/app/modules/home/pages/admin/resource/resources-table-header.component.ts b/ui-ngx/src/app/modules/home/pages/admin/resource/resources-table-header.component.ts index 80c760c6ca..d4b5d18493 100644 --- a/ui-ngx/src/app/modules/home/pages/admin/resource/resources-table-header.component.ts +++ b/ui-ngx/src/app/modules/home/pages/admin/resource/resources-table-header.component.ts @@ -28,7 +28,7 @@ import { PageLink } from '@shared/models/page/page-link'; }) export class ResourcesTableHeaderComponent extends EntityTableHeaderComponent { - readonly resourceTypes = [ResourceType.LWM2M_MODEL, ResourceType.PKCS_12, ResourceType.JKS]; + readonly resourceTypes = [ResourceType.LWM2M_MODEL, ResourceType.PKCS_12, ResourceType.JKS, ResourceType.TEXT]; readonly resourceTypesTranslationMap = ResourceTypeTranslationMap; constructor(protected store: Store) { diff --git a/ui-ngx/src/app/shared/components/entity/entity-list.component.html b/ui-ngx/src/app/shared/components/entity/entity-list.component.html index 6bf7cdb78d..e0e7dfe3f7 100644 --- a/ui-ngx/src/app/shared/components/entity/entity-list.component.html +++ b/ui-ngx/src/app/shared/components/entity/entity-list.component.html @@ -40,6 +40,12 @@ [matAutocompleteConnectedTo]="origin" [matAutocomplete]="entityAutocomplete" [matChipInputFor]="chipList"> + {{ 'entity.no-entities-matching' | translate: {entity: searchText} }} + @if (allowCreateNew) { + + entity.create-new-key + + }
diff --git a/ui-ngx/src/app/shared/components/entity/entity-list.component.ts b/ui-ngx/src/app/shared/components/entity/entity-list.component.ts index 552c4f1f71..9d1de180e9 100644 --- a/ui-ngx/src/app/shared/components/entity/entity-list.component.ts +++ b/ui-ngx/src/app/shared/components/entity/entity-list.component.ts @@ -14,7 +14,18 @@ /// limitations under the License. /// -import { Component, ElementRef, forwardRef, Input, OnChanges, OnInit, SimpleChanges, ViewChild } from '@angular/core'; +import { + Component, + ElementRef, + EventEmitter, + forwardRef, + Input, + OnChanges, + OnInit, + Output, + SimpleChanges, + ViewChild +} from '@angular/core'; import { ControlValueAccessor, NG_VALIDATORS, @@ -93,6 +104,7 @@ export class EntityListComponent implements ControlValueAccessor, OnInit, OnChan } @Input() + @coerceBoolean() disabled: boolean; @Input() @@ -109,6 +121,13 @@ export class EntityListComponent implements ControlValueAccessor, OnInit, OnChan @coerceBoolean() inlineField: boolean; + @Input() + @coerceBoolean() + allowCreateNew: boolean; + + @Output() + createNew = new EventEmitter(); + @ViewChild('entityInput') entityInput: ElementRef; @ViewChild('entityAutocomplete') matAutocomplete: MatAutocomplete; @ViewChild('chipList', {static: true}) chipList: MatChipGrid; @@ -136,6 +155,11 @@ export class EntityListComponent implements ControlValueAccessor, OnInit, OnChan this.entityListFormGroup.get('entities').updateValueAndValidity(); } + createNewEntity($event: Event, searchText?: string) { + $event.stopPropagation(); + this.createNew.emit(searchText); + } + registerOnChange(fn: any): void { this.propagateChange = fn; } @@ -201,6 +225,9 @@ export class EntityListComponent implements ControlValueAccessor, OnInit, OnChan this.modelValue = null; } this.dirty = true; + if (this.entityInput) { + this.entityInput.nativeElement.value = ''; + } } validate(): ValidationErrors | null { diff --git a/ui-ngx/src/app/shared/models/resource.models.ts b/ui-ngx/src/app/shared/models/resource.models.ts index 0419e40a7c..3495bf9eac 100644 --- a/ui-ngx/src/app/shared/models/resource.models.ts +++ b/ui-ngx/src/app/shared/models/resource.models.ts @@ -24,7 +24,8 @@ export enum ResourceType { LWM2M_MODEL = 'LWM2M_MODEL', PKCS_12 = 'PKCS_12', JKS = 'JKS', - JS_MODULE = 'JS_MODULE' + JS_MODULE = 'JS_MODULE', + TEXT = 'TEXT', } export enum ResourceSubType { @@ -57,7 +58,8 @@ export const ResourceTypeTranslationMap = new Map( [ResourceType.LWM2M_MODEL, 'resource.type.lwm2m-model'], [ResourceType.PKCS_12, 'resource.type.pkcs-12'], [ResourceType.JKS, 'resource.type.jks'], - [ResourceType.JS_MODULE, 'resource.type.js-module'] + [ResourceType.JS_MODULE, 'resource.type.js-module'], + [ResourceType.TEXT, 'resource.type.text'], ] ); @@ -76,8 +78,8 @@ export interface TbResourceInfo extends Omit, 'name' | title?: string; resourceType: ResourceType; resourceSubType?: ResourceSubType; - fileName: string; - public: boolean; + fileName?: string; + public?: boolean; publicResourceKey?: string; readonly link?: string; readonly publicLink?: string; @@ -87,7 +89,7 @@ export interface TbResourceInfo extends Omit, 'name' | export type ResourceInfo = TbResourceInfo; export interface Resource extends ResourceInfo { - data: string; + data?: string; name?: string; } 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 80328181f6..d4bfd4ea06 100644 --- a/ui-ngx/src/assets/locale/locale.constant-en_US.json +++ b/ui-ngx/src/assets/locale/locale.constant-en_US.json @@ -4488,7 +4488,8 @@ "jks": "JKS", "js-module": "JS module", "lwm2m-model": "LWM2M model", - "pkcs-12": "PKCS #12" + "pkcs-12": "PKCS #12", + "text": "Text" }, "resource-sub-type": "Sub-type", "sub-type": { @@ -5467,7 +5468,8 @@ "timeout-required": "Timeout is required", "timeout-validation": "Must be from 1 second to 10 minutes.", "force-acknowledgement": "Force acknowledgement", - "force-acknowledgement-hint": "If enabled, the incoming message is acknowledged immediately. The model's response is then enqueued as a separate, new message." + "force-acknowledgement-hint": "If enabled, the incoming message is acknowledged immediately. The model's response is then enqueued as a separate, new message.", + "ai-resources": "AI resources" } }, "timezone": { From 055a1ae56d0361e9356a2a08bc394cfa20f04473 Mon Sep 17 00:00:00 2001 From: ArtemDzhereleiko Date: Wed, 10 Sep 2025 09:41:47 +0300 Subject: [PATCH 045/102] UI: General resources --- ui-ngx/src/app/core/http/entity.service.ts | 6 +- ui-ngx/src/app/core/http/resource.service.ts | 14 +++-- .../resources/resources-dialog.component.html | 4 +- .../resources/resources-dialog.component.ts | 3 + .../resources-library.component.html | 61 ++++++++++--------- .../resources/resources-library.component.ts | 13 +++- .../external/ai-config.component.html | 2 +- .../rule-node/external/ai-config.component.ts | 2 +- .../resources-table-header.component.ts | 2 +- .../shared/components/file-input.component.ts | 14 ++++- .../src/app/shared/models/resource.models.ts | 4 +- .../assets/locale/locale.constant-en_US.json | 2 +- 12 files changed, 77 insertions(+), 50 deletions(-) diff --git a/ui-ngx/src/app/core/http/entity.service.ts b/ui-ngx/src/app/core/http/entity.service.ts index 53a55bfe7f..c652ba39ba 100644 --- a/ui-ngx/src/app/core/http/entity.service.ts +++ b/ui-ngx/src/app/core/http/entity.service.ts @@ -299,9 +299,7 @@ export class EntityService { entityIds); break; case EntityType.TB_RESOURCE: - observable = this.getEntitiesByIdsObservable( - (id) => this.resourceService.getResource(id, config), - entityIds); + observable = this.resourceService.getResourcesByIds(entityIds, config); break; } return observable; @@ -478,7 +476,7 @@ export class EntityService { break; case EntityType.TB_RESOURCE: pageLink.sortOrder.property = 'title'; - entitiesObservable = this.resourceService.getTenantResources(pageLink, subType as ResourceType, config); + entitiesObservable = this.resourceService.getResources(pageLink, subType as ResourceType, null, config); break; case EntityType.QUEUE_STATS: pageLink.sortOrder.property = 'createdTime'; diff --git a/ui-ngx/src/app/core/http/resource.service.ts b/ui-ngx/src/app/core/http/resource.service.ts index 52c92d96e4..90335758dd 100644 --- a/ui-ngx/src/app/core/http/resource.service.ts +++ b/ui-ngx/src/app/core/http/resource.service.ts @@ -24,6 +24,7 @@ import { Resource, ResourceInfo, ResourceSubType, ResourceType, TBResourceScope import { catchError, mergeMap } from 'rxjs/operators'; import { isNotEmptyStr } from '@core/utils'; import { ResourcesService } from '@core/services/resources.service'; +import { NotificationTarget } from "@shared/models/notification.models"; @Injectable({ providedIn: 'root' @@ -47,12 +48,8 @@ export class ResourceService { return this.http.get>(url, defaultHttpOptionsFromConfig(config)); } - public getTenantResources(pageLink: PageLink, resourceType?: ResourceType, config?: RequestConfig): Observable> { - let url = `/api/resource${pageLink.toQuery()}`; - if (isNotEmptyStr(resourceType)) { - url += `&resourceType=${resourceType}`; - } - return this.http.get>(url, defaultHttpOptionsFromConfig(config)); + public getTenantResources(pageLink: PageLink, config?: RequestConfig): Observable> { + return this.http.get>(`/api/resource/tenant${pageLink.toQuery()}`, defaultHttpOptionsFromConfig(config)) } public getResource(resourceId: string, config?: RequestConfig): Observable { @@ -98,4 +95,9 @@ export class ResourceService { return this.http.delete(`/api/resource/${resourceId}?force=${force}`, defaultHttpOptionsFromConfig(config)); } + public getResourcesByIds(ids: string[], config?: RequestConfig): Observable> { + return this.http.get>(`/api/resource?resourceIds=${ids.join(',')}`, + defaultHttpOptionsFromConfig(config)); + } + } diff --git a/ui-ngx/src/app/modules/home/components/resources/resources-dialog.component.html b/ui-ngx/src/app/modules/home/components/resources/resources-dialog.component.html index c6813ff0f2..0062cfa9ac 100644 --- a/ui-ngx/src/app/modules/home/components/resources/resources-dialog.component.html +++ b/ui-ngx/src/app/modules/home/components/resources/resources-dialog.component.html @@ -32,8 +32,8 @@
diff --git a/ui-ngx/src/app/modules/home/components/resources/resources-dialog.component.ts b/ui-ngx/src/app/modules/home/components/resources/resources-dialog.component.ts index a06c72827a..6216f087b1 100644 --- a/ui-ngx/src/app/modules/home/components/resources/resources-dialog.component.ts +++ b/ui-ngx/src/app/modules/home/components/resources/resources-dialog.component.ts @@ -106,6 +106,9 @@ export class ResourcesDialogComponent extends DialogComponent response[0]) ).subscribe(result => this.dialogRef.close(result)); } else { + if (resource.resourceType !== ResourceType.GENERAL) { + delete resource.descriptor; + } this.resourceService.saveResource(resource).subscribe(result => this.dialogRef.close(result)); } } diff --git a/ui-ngx/src/app/modules/home/components/resources/resources-library.component.html b/ui-ngx/src/app/modules/home/components/resources/resources-library.component.html index c602906be1..4737b75ef2 100644 --- a/ui-ngx/src/app/modules/home/components/resources/resources-library.component.html +++ b/ui-ngx/src/app/modules/home/components/resources/resources-library.component.html @@ -48,14 +48,16 @@
- - resource.resource-type - - - {{ resourceTypesTranslationMap.get(resourceType) | translate }} - - - + @if (resourceTypes.length > 1) { + + resource.resource-type + + + {{ resourceTypesTranslationMap.get(resourceType) | translate }} + + + + } resource.title @@ -66,26 +68,29 @@ {{ 'resource.title-max-length' | translate }} - - -
- - resource.file-name - - -
+ @if (isAdd || ((isAdd || isEdit) && entityForm.get('resourceType').value === resourceType.GENERAL)) { + + + } @else { +
+ + resource.file-name + + +
+ }
diff --git a/ui-ngx/src/app/modules/home/components/resources/resources-library.component.ts b/ui-ngx/src/app/modules/home/components/resources/resources-library.component.ts index 0b50e45a7a..e3ad0e15f3 100644 --- a/ui-ngx/src/app/modules/home/components/resources/resources-library.component.ts +++ b/ui-ngx/src/app/modules/home/components/resources/resources-library.component.ts @@ -44,7 +44,7 @@ export class ResourcesLibraryComponent extends EntityComponent impleme standalone = false; @Input() - resourceTypes = [ResourceType.LWM2M_MODEL, ResourceType.PKCS_12, ResourceType.JKS, ResourceType.TEXT]; + resourceTypes = [ResourceType.LWM2M_MODEL, ResourceType.PKCS_12, ResourceType.JKS, ResourceType.GENERAL]; @Input() defaultResourceType = ResourceType.LWM2M_MODEL; @@ -90,10 +90,19 @@ export class ResourcesLibraryComponent extends EntityComponent impleme title: [entity ? entity.title : '', [Validators.required, Validators.maxLength(255)]], resourceType: [entity?.resourceType ? entity.resourceType : ResourceType.LWM2M_MODEL, Validators.required], fileName: [entity ? entity.fileName : null, Validators.required], - data: [entity ? entity.data : null, this.isAdd ? [Validators.required] : []] + data: [entity ? entity.data : null, this.isAdd ? [Validators.required] : []], + descriptor: this.fb.group({ + mediaType: [''] + }) }); } + mediaTypeChange(mediaType: string): void { + if (this.entityForm.get('resourceType').value === ResourceType.GENERAL) { + this.entityForm.get('descriptor').get('mediaType').patchValue(mediaType); + } + } + updateForm(entity: Resource): void { this.entityForm.patchValue(entity); } diff --git a/ui-ngx/src/app/modules/home/components/rule-node/external/ai-config.component.html b/ui-ngx/src/app/modules/home/components/rule-node/external/ai-config.component.html index d259d57ef3..5381fc770b 100644 --- a/ui-ngx/src/app/modules/home/components/rule-node/external/ai-config.component.html +++ b/ui-ngx/src/app/modules/home/components/rule-node/external/ai-config.component.html @@ -76,7 +76,7 @@ placeholderText="{{ 'rule-node-config.ai.ai-resources' | translate }}" [inlineField]="true" [entityType]="EntityType.TB_RESOURCE" - [subType]="ResourceType.TEXT" + [subType]="ResourceType.GENERAL" (createNew)="createAiResources($event, 'resourceIds')" formControlName="resourceIds"> diff --git a/ui-ngx/src/app/modules/home/components/rule-node/external/ai-config.component.ts b/ui-ngx/src/app/modules/home/components/rule-node/external/ai-config.component.ts index a4d973e998..07bcb86fdf 100644 --- a/ui-ngx/src/app/modules/home/components/rule-node/external/ai-config.component.ts +++ b/ui-ngx/src/app/modules/home/components/rule-node/external/ai-config.component.ts @@ -129,7 +129,7 @@ export class AiConfigComponent extends RuleNodeConfigurationComponent { disableClose: true, panelClass: ['tb-dialog', 'tb-fullscreen-dialog'], data: { - resources: {title: name, resourceType: ResourceType.TEXT}, + resources: {title: name, resourceType: ResourceType.GENERAL}, isAdd: true } }).afterClosed() diff --git a/ui-ngx/src/app/modules/home/pages/admin/resource/resources-table-header.component.ts b/ui-ngx/src/app/modules/home/pages/admin/resource/resources-table-header.component.ts index d4b5d18493..136c143e3c 100644 --- a/ui-ngx/src/app/modules/home/pages/admin/resource/resources-table-header.component.ts +++ b/ui-ngx/src/app/modules/home/pages/admin/resource/resources-table-header.component.ts @@ -28,7 +28,7 @@ import { PageLink } from '@shared/models/page/page-link'; }) export class ResourcesTableHeaderComponent extends EntityTableHeaderComponent { - readonly resourceTypes = [ResourceType.LWM2M_MODEL, ResourceType.PKCS_12, ResourceType.JKS, ResourceType.TEXT]; + readonly resourceTypes = [ResourceType.LWM2M_MODEL, ResourceType.PKCS_12, ResourceType.JKS, ResourceType.GENERAL]; readonly resourceTypesTranslationMap = ResourceTypeTranslationMap; constructor(protected store: Store) { diff --git a/ui-ngx/src/app/shared/components/file-input.component.ts b/ui-ngx/src/app/shared/components/file-input.component.ts index bbe68bb9c6..6960db73dd 100644 --- a/ui-ngx/src/app/shared/components/file-input.component.ts +++ b/ui-ngx/src/app/shared/components/file-input.component.ts @@ -129,10 +129,15 @@ export class FileInputComponent extends PageComponent implements AfterViewInit, @Output() fileNameChanged = new EventEmitter(); + @Output() + mediaTypeChanged = new EventEmitter(); + fileName: string | string[]; fileContent: any; files: File[]; + mediaType: string; + @ViewChild('flow', {static: true}) flow: FlowDirective; @@ -180,6 +185,7 @@ export class FileInputComponent extends PageComponent implements AfterViewInit, this.fileContent = files[0].fileContent; this.fileName = files[0].fileName; this.files = files[0].files; + this.mediaType = files[0].mediaType; this.updateModel(); } else if (files.length > 1) { this.fileContent = files.map(content => content.fileContent); @@ -203,6 +209,7 @@ export class FileInputComponent extends PageComponent implements AfterViewInit, let fileName = null; let fileContent = null; let files = null; + let mediaType = null; if (reader.readyState === reader.DONE) { if (!this.workFromFileObj) { fileContent = reader.result; @@ -211,16 +218,18 @@ export class FileInputComponent extends PageComponent implements AfterViewInit, fileContent = this.contentConvertFunction(fileContent); } fileName = fileContent ? file.name : null; + mediaType = file?.file?.type || null; } } else if (file.name || file.file){ files = file.file; fileName = file.name; + mediaType = file.file.type || null; } } - resolve({fileContent, fileName, files}); + resolve({fileContent, fileName, files, mediaType}); }; reader.onerror = () => { - resolve({fileContent: null, fileName: null, files: null}); + resolve({fileContent: null, fileName: null, files: null, mediaType: null}); }; if (this.readAsBinary) { reader.readAsBinaryString(file.file); @@ -283,6 +292,7 @@ export class FileInputComponent extends PageComponent implements AfterViewInit, this.propagateChange(this.files); } else { this.propagateChange(this.fileContent); + this.mediaTypeChanged.emit(this.mediaType); this.fileNameChanged.emit(this.fileName); } } diff --git a/ui-ngx/src/app/shared/models/resource.models.ts b/ui-ngx/src/app/shared/models/resource.models.ts index 3495bf9eac..12590e7608 100644 --- a/ui-ngx/src/app/shared/models/resource.models.ts +++ b/ui-ngx/src/app/shared/models/resource.models.ts @@ -25,7 +25,7 @@ export enum ResourceType { PKCS_12 = 'PKCS_12', JKS = 'JKS', JS_MODULE = 'JS_MODULE', - TEXT = 'TEXT', + GENERAL = 'GENERAL', } export enum ResourceSubType { @@ -59,7 +59,7 @@ export const ResourceTypeTranslationMap = new Map( [ResourceType.PKCS_12, 'resource.type.pkcs-12'], [ResourceType.JKS, 'resource.type.jks'], [ResourceType.JS_MODULE, 'resource.type.js-module'], - [ResourceType.TEXT, 'resource.type.text'], + [ResourceType.GENERAL, 'resource.type.general'], ] ); 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 d4bfd4ea06..d79bb81ae5 100644 --- a/ui-ngx/src/assets/locale/locale.constant-en_US.json +++ b/ui-ngx/src/assets/locale/locale.constant-en_US.json @@ -4489,7 +4489,7 @@ "js-module": "JS module", "lwm2m-model": "LWM2M model", "pkcs-12": "PKCS #12", - "text": "Text" + "general": "General" }, "resource-sub-type": "Sub-type", "sub-type": { From 8a561456b40fba456e0cac5649f0492e0b3b908a Mon Sep 17 00:00:00 2001 From: ArtemDzhereleiko Date: Fri, 12 Sep 2025 16:23:53 +0300 Subject: [PATCH 046/102] UI: Add resources in use dialog with force to delete --- .../resources-library-table-config.resolve.ts | 180 +++++++++++++++++- .../assets/locale/locale.constant-en_US.json | 7 +- 2 files changed, 177 insertions(+), 10 deletions(-) diff --git a/ui-ngx/src/app/modules/home/pages/admin/resource/resources-library-table-config.resolve.ts b/ui-ngx/src/app/modules/home/pages/admin/resource/resources-library-table-config.resolve.ts index dc85ca4914..d92355fbac 100644 --- a/ui-ngx/src/app/modules/home/pages/admin/resource/resources-library-table-config.resolve.ts +++ b/ui-ngx/src/app/modules/home/pages/admin/resource/resources-library-table-config.resolve.ts @@ -22,7 +22,13 @@ import { EntityTableConfig } from '@home/models/entity/entities-table-config.models'; import { Router } from '@angular/router'; -import { Resource, ResourceInfo, ResourceType, ResourceTypeTranslationMap } from '@shared/models/resource.models'; +import { + Resource, + ResourceInfo, ResourceInfoWithReferences, + ResourceType, + ResourceTypeTranslationMap, + toResourceDeleteResult +} from '@shared/models/resource.models'; import { EntityType, entityTypeResources, entityTypeTranslations } from '@shared/models/entity-type.models'; import { NULL_UUID } from '@shared/models/id/has-uuid'; import { DatePipe } from '@angular/common'; @@ -35,9 +41,19 @@ import { Authority } from '@shared/models/authority.enum'; import { ResourcesLibraryComponent } from '@home/components/resources/resources-library.component'; import { PageLink } from '@shared/models/page/page-link'; import { EntityAction } from '@home/models/entity/entity-component.models'; -import { map } from 'rxjs/operators'; +import { catchError, map } from 'rxjs/operators'; import { ResourcesTableHeaderComponent } from '@home/pages/admin/resource/resources-table-header.component'; import { ResourceLibraryTabsComponent } from '@home/pages/admin/resource/resource-library-tabs.component'; +import { forkJoin, of } from "rxjs"; +import { + ResourcesInUseDialogComponent, + ResourcesInUseDialogData +} from "@shared/components/resource/resources-in-use-dialog.component"; +import { parseHttpErrorMessage } from "@core/utils"; +import { ActionNotificationShow } from "@core/notification/notification.actions"; +import { ResourcesDatasource } from "@home/pages/admin/resource/resources-datasource"; +import { MatDialog } from "@angular/material/dialog"; +import { DialogService } from "@core/services/dialog.service"; @Injectable() export class ResourcesLibraryTableConfigResolver { @@ -49,6 +65,8 @@ export class ResourcesLibraryTableConfigResolver { private resourceService: ResourceService, private translate: TranslateService, private router: Router, + private dialog: MatDialog, + private dialogService: DialogService, private datePipe: DatePipe) { this.config.entityType = EntityType.TB_RESOURCE; @@ -76,19 +94,27 @@ export class ResourcesLibraryTableConfigResolver { icon: 'file_download', isEnabled: () => true, onAction: ($event, entity) => this.downloadResource($event, entity) - } + }, + { + name: this.translate.instant('resource.delete'), + icon: 'delete', + isEnabled: (resource) => this.config.deleteEnabled(resource), + onAction: ($event, entity) => this.deleteResource($event, entity) + }, ); - this.config.deleteEntityTitle = resource => this.translate.instant('resource.delete-resource-title', - { resourceTitle: resource.title }); - this.config.deleteEntityContent = () => this.translate.instant('resource.delete-resource-text'); - this.config.deleteEntitiesTitle = count => this.translate.instant('resource.delete-resources-title', {count}); - this.config.deleteEntitiesContent = () => this.translate.instant('resource.delete-resources-text'); + this.config.groupActionDescriptors = [{ + name: this.translate.instant('action.delete'), + icon: 'delete', + isEnabled: true, + onAction: ($event, entities) => this.deleteResources($event, entities) + }]; + + this.config.entitiesDeleteEnabled = false; this.config.entitiesFetchFunction = pageLink => this.resourceService.getResources(pageLink, this.config.componentsData.resourceType); this.config.loadEntity = id => this.resourceService.getResourceInfoById(id.id); this.config.saveEntity = resource => this.saveResource(resource); - this.config.deleteEntity = id => this.resourceService.deleteResource(id.id); this.config.onEntityAction = action => this.onResourceAction(action); } @@ -147,6 +173,8 @@ export class ResourcesLibraryTableConfigResolver { case 'downloadResource': this.downloadResource(action.event, action.entity); return true; + case 'deleteLibrary': + this.deleteResource(action.event, action.entity); } return false; } @@ -165,4 +193,138 @@ export class ResourcesLibraryTableConfigResolver { return authority === Authority.SYS_ADMIN; } } + + private deleteResource($event: Event, resource: ResourceInfo) { + if ($event) { + $event.stopPropagation(); + } + this.dialogService.confirm( + this.translate.instant('resource.delete-resource-title', { resourceTitle: resource.title }), + this.translate.instant('resource.delete-resource-text'), + this.translate.instant('action.no'), + this.translate.instant('action.yes'), + true + ).subscribe((result) => { + if (result) { + this.resourceService.deleteResource(resource.id.id, false, {ignoreErrors: true}).pipe( + map(() => toResourceDeleteResult(resource)), + catchError((err) => of(toResourceDeleteResult(resource, err))) + ).subscribe( + (deleteResult) => { + if (deleteResult.success) { + if (this.config.getEntityDetailsPage()) { + this.config.getEntityDetailsPage().goBack(); + } else { + this.config.updateData(true); + } + } else if (deleteResult.resourceIsReferencedError) { + const resources: ResourceInfoWithReferences[] = [{...resource, ...{references: deleteResult.references}}]; + const data = { + multiple: false, + resources, + configuration: { + title: 'resource.resource-is-in-use', + message: this.translate.instant('resource.resource-is-in-use-text', {title: resources[0].title}), + deleteText: 'resource.delete-resource-in-use-text', + selectedText: 'resource.selected-resources', + columns: ['select', 'title', 'references'] + } + }; + this.dialog.open(ResourcesInUseDialogComponent, { + disableClose: true, + panelClass: ['tb-dialog', 'tb-fullscreen-dialog'], + data + }).afterClosed().subscribe((resources) => { + if (resources) { + this.resourceService.deleteResource(resource.id.id, true).subscribe(() => { + if (this.config.getEntityDetailsPage()) { + this.config.getEntityDetailsPage().goBack(); + } else { + this.config.updateData(true); + } + }); + } + }); + } else { + const errorMessageWithTimeout = parseHttpErrorMessage(deleteResult.error, this.translate); + setTimeout(() => { + this.store.dispatch(new ActionNotificationShow({message: errorMessageWithTimeout.message, type: 'error'})); + }, errorMessageWithTimeout.timeout); + } + } + ); + } + }); + } + + private deleteResources($event: Event, resources: ResourceInfo[]) { + if ($event) { + $event.stopPropagation(); + } + if (resources && resources.length) { + const title = this.translate.instant('resource.delete-resources-title', {count: resources.length}); + const content = this.translate.instant('resource.delete-resources-text'); + this.dialogService.confirm(title, content, + this.translate.instant('action.no'), + this.translate.instant('action.yes')).subscribe((result) => { + if (result) { + const tasks = resources.map((resource) => + this.resourceService.deleteResource(resource.id.id, false, {ignoreErrors: true}).pipe( + map(() => toResourceDeleteResult(resource)), + catchError((err) => of(toResourceDeleteResult(resource, err))) + ) + ); + forkJoin(tasks).subscribe( + (deleteResults) => { + const anySuccess = deleteResults.some(res => res.success); + const referenceErrors = deleteResults.filter(res => res.resourceIsReferencedError); + const otherError = deleteResults.find(res => !res.success); + if (anySuccess) { + this.config.updateData(); + } + if (referenceErrors?.length) { + const resourcesWithReferences: ResourceInfoWithReferences[] = + referenceErrors.map(ref => ({...ref.resource, ...{references: ref.references}})); + const data = { + multiple: true, + resources: resourcesWithReferences, + configuration: { + title: 'resource.resources-are-in-use', + message: this.translate.instant('resource.resources-are-in-use-text'), + deleteText: 'resource.delete-resource-in-use-text', + selectedText: 'resource.selected-resources', + datasource: new ResourcesDatasource(this.resourceService, resourcesWithReferences, () => true), + columns: ['select', 'title', 'references'] + } + }; + this.dialog.open(ResourcesInUseDialogComponent, { + disableClose: true, + panelClass: ['tb-dialog', 'tb-fullscreen-dialog'], + data + }).afterClosed().subscribe((forceDeleteResources) => { + if (forceDeleteResources && forceDeleteResources.length) { + const forceDeleteTasks = forceDeleteResources.map((resource) => + this.resourceService.deleteResource(resource.id.id, true) + ); + forkJoin(forceDeleteTasks).subscribe( + () => { + this.config.updateData(); + } + ); + } + }); + } else if (otherError) { + const errorMessageWithTimeout = parseHttpErrorMessage(otherError.error, this.translate); + setTimeout(() => { + this.store.dispatch(new ActionNotificationShow({message: errorMessageWithTimeout.message, type: 'error'})); + }, errorMessageWithTimeout.timeout); + } + } + ); + } + }); + } + } } 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 d79bb81ae5..4639552658 100644 --- a/ui-ngx/src/assets/locale/locale.constant-en_US.json +++ b/ui-ngx/src/assets/locale/locale.constant-en_US.json @@ -4497,7 +4497,12 @@ "scada-symbol": "Scada symbol", "extension": "Extension", "module": "Module" - } + }, + "resource-is-in-use": "Resource is used by other entities", + "resources-are-in-use": "Resources are used by other entities", + "resource-is-in-use-text": "The Resource '{{title}}' was not deleted because it is used by the following entities:", + "resources-are-in-use-text": "Not all Resources have been deleted because they are used by other entities.
You can view referenced entities by clicking the References button in the corresponding resource row.
If you still want to delete these resources, select them in the table below and click the Delete selected button.", + "delete-resource-in-use-text": "If you still want to delete the resource, click the Delete anyway button." }, "javascript": { "add": "Add JavaScript resource", From e88114de720bf8171500fa0031cea84b0e7f64a3 Mon Sep 17 00:00:00 2001 From: ArtemDzhereleiko Date: Fri, 12 Sep 2025 16:46:38 +0300 Subject: [PATCH 047/102] UI: oprimize import --- ui-ngx/src/app/core/http/resource.service.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/ui-ngx/src/app/core/http/resource.service.ts b/ui-ngx/src/app/core/http/resource.service.ts index 90335758dd..168c63b3b1 100644 --- a/ui-ngx/src/app/core/http/resource.service.ts +++ b/ui-ngx/src/app/core/http/resource.service.ts @@ -24,7 +24,6 @@ import { Resource, ResourceInfo, ResourceSubType, ResourceType, TBResourceScope import { catchError, mergeMap } from 'rxjs/operators'; import { isNotEmptyStr } from '@core/utils'; import { ResourcesService } from '@core/services/resources.service'; -import { NotificationTarget } from "@shared/models/notification.models"; @Injectable({ providedIn: 'root' From 5c7f20a1514124cafbd9f26f6a94adc0fac516b5 Mon Sep 17 00:00:00 2001 From: Dmytro Skarzhynets Date: Mon, 15 Sep 2025 17:32:57 +0300 Subject: [PATCH 048/102] AI models: add support for Ollama --- application/pom.xml | 4 ++ .../Langchain4jChatModelConfigurerImpl.java | 16 ++++++ .../common/data/ai/dto/TbChatResponse.java | 6 +- .../common/data/ai/model/AiModelConfig.java | 8 ++- .../data/ai/model/chat/AiChatModelConfig.java | 2 +- .../chat/Langchain4jChatModelConfigurer.java | 2 + .../ai/model/chat/OllamaChatModelConfig.java | 57 +++++++++++++++++++ .../common/data/ai/provider/AiProvider.java | 3 +- .../data/ai/provider/AiProviderConfig.java | 2 +- .../ai/provider/OllamaProviderConfig.java | 22 +++++++ .../rule/engine/ai/TbResponseFormat.java | 8 +-- .../ai-model/ai-model-dialog.component.html | 9 +++ .../ai-model/ai-model-dialog.component.ts | 1 + .../src/app/shared/models/ai-model.models.ts | 21 +++++-- .../assets/locale/locale.constant-en_US.json | 5 +- 15 files changed, 146 insertions(+), 20 deletions(-) create mode 100644 common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/OllamaChatModelConfig.java create mode 100644 common/data/src/main/java/org/thingsboard/server/common/data/ai/provider/OllamaProviderConfig.java diff --git a/application/pom.xml b/application/pom.xml index 33bc0972d4..0413f7732c 100644 --- a/application/pom.xml +++ b/application/pom.xml @@ -419,6 +419,10 @@ + + dev.langchain4j + langchain4j-ollama + diff --git a/application/src/main/java/org/thingsboard/server/service/ai/Langchain4jChatModelConfigurerImpl.java b/application/src/main/java/org/thingsboard/server/service/ai/Langchain4jChatModelConfigurerImpl.java index 69dd98f47f..7008e866f4 100644 --- a/application/src/main/java/org/thingsboard/server/service/ai/Langchain4jChatModelConfigurerImpl.java +++ b/application/src/main/java/org/thingsboard/server/service/ai/Langchain4jChatModelConfigurerImpl.java @@ -32,6 +32,7 @@ import dev.langchain4j.model.chat.request.ChatRequestParameters; import dev.langchain4j.model.github.GitHubModelsChatModel; import dev.langchain4j.model.googleai.GoogleAiGeminiChatModel; import dev.langchain4j.model.mistralai.MistralAiChatModel; +import dev.langchain4j.model.ollama.OllamaChatModel; import dev.langchain4j.model.openai.OpenAiChatModel; import dev.langchain4j.model.vertexai.gemini.VertexAiGeminiChatModel; import org.springframework.stereotype.Component; @@ -43,6 +44,7 @@ import org.thingsboard.server.common.data.ai.model.chat.GoogleAiGeminiChatModelC import org.thingsboard.server.common.data.ai.model.chat.GoogleVertexAiGeminiChatModelConfig; import org.thingsboard.server.common.data.ai.model.chat.Langchain4jChatModelConfigurer; import org.thingsboard.server.common.data.ai.model.chat.MistralAiChatModelConfig; +import org.thingsboard.server.common.data.ai.model.chat.OllamaChatModelConfig; import org.thingsboard.server.common.data.ai.model.chat.OpenAiChatModelConfig; import org.thingsboard.server.common.data.ai.provider.AmazonBedrockProviderConfig; import org.thingsboard.server.common.data.ai.provider.AzureOpenAiProviderConfig; @@ -262,6 +264,20 @@ class Langchain4jChatModelConfigurerImpl implements Langchain4jChatModelConfigur .build(); } + @Override + public ChatModel configureChatModel(OllamaChatModelConfig chatModelConfig) { + return OllamaChatModel.builder() + .baseUrl(chatModelConfig.providerConfig().baseUrl()) + .modelName(chatModelConfig.modelId()) + .temperature(chatModelConfig.temperature()) + .topP(chatModelConfig.topP()) + .topK(chatModelConfig.topK()) + .numPredict(chatModelConfig.maxOutputTokens()) + .timeout(toDuration(chatModelConfig.timeoutSeconds())) + .maxRetries(chatModelConfig.maxRetries()) + .build(); + } + private static Duration toDuration(Integer timeoutSeconds) { return timeoutSeconds != null ? Duration.ofSeconds(timeoutSeconds) : null; } diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/ai/dto/TbChatResponse.java b/common/data/src/main/java/org/thingsboard/server/common/data/ai/dto/TbChatResponse.java index 2cc17e4553..73e6557fb5 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/ai/dto/TbChatResponse.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/ai/dto/TbChatResponse.java @@ -22,7 +22,7 @@ import io.swagger.v3.oas.annotations.media.Schema; @JsonTypeInfo( use = JsonTypeInfo.Id.NAME, property = "status", - include = JsonTypeInfo.As.PROPERTY, + include = JsonTypeInfo.As.EXISTING_PROPERTY, visible = true ) @JsonSubTypes({ @@ -51,9 +51,7 @@ public sealed interface TbChatResponse permits TbChatResponse.Success, TbChatRes } record Failure( - @Schema( - description = "A string containing details about the failure" - ) + @Schema(description = "A string containing details about the failure") String errorDetails ) implements TbChatResponse { diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/AiModelConfig.java b/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/AiModelConfig.java index bfaa29a6e3..f18429e7cf 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/AiModelConfig.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/AiModelConfig.java @@ -24,6 +24,7 @@ import org.thingsboard.server.common.data.ai.model.chat.GitHubModelsChatModelCon import org.thingsboard.server.common.data.ai.model.chat.GoogleAiGeminiChatModelConfig; import org.thingsboard.server.common.data.ai.model.chat.GoogleVertexAiGeminiChatModelConfig; import org.thingsboard.server.common.data.ai.model.chat.MistralAiChatModelConfig; +import org.thingsboard.server.common.data.ai.model.chat.OllamaChatModelConfig; import org.thingsboard.server.common.data.ai.model.chat.OpenAiChatModelConfig; import org.thingsboard.server.common.data.ai.provider.AiProvider; import org.thingsboard.server.common.data.ai.provider.AiProviderConfig; @@ -34,6 +35,7 @@ import org.thingsboard.server.common.data.ai.provider.GitHubModelsProviderConfig import org.thingsboard.server.common.data.ai.provider.GoogleAiGeminiProviderConfig; import org.thingsboard.server.common.data.ai.provider.GoogleVertexAiGeminiProviderConfig; import org.thingsboard.server.common.data.ai.provider.MistralAiProviderConfig; +import org.thingsboard.server.common.data.ai.provider.OllamaProviderConfig; import org.thingsboard.server.common.data.ai.provider.OpenAiProviderConfig; @JsonTypeInfo( @@ -50,7 +52,8 @@ import org.thingsboard.server.common.data.ai.provider.OpenAiProviderConfig; @JsonSubTypes.Type(value = MistralAiChatModelConfig.class, name = "MISTRAL_AI"), @JsonSubTypes.Type(value = AnthropicChatModelConfig.class, name = "ANTHROPIC"), @JsonSubTypes.Type(value = AmazonBedrockChatModelConfig.class, name = "AMAZON_BEDROCK"), - @JsonSubTypes.Type(value = GitHubModelsChatModelConfig.class, name = "GITHUB_MODELS") + @JsonSubTypes.Type(value = GitHubModelsChatModelConfig.class, name = "GITHUB_MODELS"), + @JsonSubTypes.Type(value = OllamaChatModelConfig.class, name = "OLLAMA") }) public interface AiModelConfig { @@ -69,7 +72,8 @@ public interface AiModelConfig { @JsonSubTypes.Type(value = MistralAiProviderConfig.class, name = "MISTRAL_AI"), @JsonSubTypes.Type(value = AnthropicProviderConfig.class, name = "ANTHROPIC"), @JsonSubTypes.Type(value = AmazonBedrockProviderConfig.class, name = "AMAZON_BEDROCK"), - @JsonSubTypes.Type(value = GitHubModelsProviderConfig.class, name = "GITHUB_MODELS") + @JsonSubTypes.Type(value = GitHubModelsProviderConfig.class, name = "GITHUB_MODELS"), + @JsonSubTypes.Type(value = OllamaProviderConfig.class, name = "OLLAMA") }) AiProviderConfig providerConfig(); diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/AiChatModelConfig.java b/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/AiChatModelConfig.java index 2bc28cfce0..49126c1861 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/AiChatModelConfig.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/AiChatModelConfig.java @@ -24,7 +24,7 @@ public sealed interface AiChatModelConfig> extend permits OpenAiChatModelConfig, AzureOpenAiChatModelConfig, GoogleAiGeminiChatModelConfig, GoogleVertexAiGeminiChatModelConfig, MistralAiChatModelConfig, AnthropicChatModelConfig, - AmazonBedrockChatModelConfig, GitHubModelsChatModelConfig { + AmazonBedrockChatModelConfig, GitHubModelsChatModelConfig, OllamaChatModelConfig { ChatModel configure(Langchain4jChatModelConfigurer configurer); diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/Langchain4jChatModelConfigurer.java b/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/Langchain4jChatModelConfigurer.java index c9c1bc3173..828256dcdc 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/Langchain4jChatModelConfigurer.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/Langchain4jChatModelConfigurer.java @@ -35,4 +35,6 @@ public interface Langchain4jChatModelConfigurer { ChatModel configureChatModel(GitHubModelsChatModelConfig chatModelConfig); + ChatModel configureChatModel(OllamaChatModelConfig chatModelConfig); + } diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/OllamaChatModelConfig.java b/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/OllamaChatModelConfig.java new file mode 100644 index 0000000000..360b514d6d --- /dev/null +++ b/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/OllamaChatModelConfig.java @@ -0,0 +1,57 @@ +/** + * 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.common.data.ai.model.chat; + +import dev.langchain4j.model.chat.ChatModel; +import jakarta.validation.Valid; +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Positive; +import jakarta.validation.constraints.PositiveOrZero; +import lombok.Builder; +import lombok.With; +import org.thingsboard.server.common.data.ai.provider.AiProvider; +import org.thingsboard.server.common.data.ai.provider.OllamaProviderConfig; + +@Builder +public record OllamaChatModelConfig( + @NotNull @Valid OllamaProviderConfig providerConfig, + @NotBlank String modelId, + @PositiveOrZero Double temperature, + @Positive @Max(1) Double topP, + @PositiveOrZero Integer topK, + @Positive Integer maxOutputTokens, + @With @Positive Integer timeoutSeconds, + @With @PositiveOrZero Integer maxRetries +) implements AiChatModelConfig { + + @Override + public AiProvider provider() { + return AiProvider.OLLAMA; + } + + @Override + public ChatModel configure(Langchain4jChatModelConfigurer configurer) { + return configurer.configureChatModel(this); + } + + @Override + public boolean supportsJsonMode() { + return true; + } + +} diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/ai/provider/AiProvider.java b/common/data/src/main/java/org/thingsboard/server/common/data/ai/provider/AiProvider.java index d0a5bd0510..a9a6af4de8 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/ai/provider/AiProvider.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/ai/provider/AiProvider.java @@ -24,6 +24,7 @@ public enum AiProvider { MISTRAL_AI, ANTHROPIC, AMAZON_BEDROCK, - GITHUB_MODELS + GITHUB_MODELS, + OLLAMA } diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/ai/provider/AiProviderConfig.java b/common/data/src/main/java/org/thingsboard/server/common/data/ai/provider/AiProviderConfig.java index bd32c88efb..5423b24410 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/ai/provider/AiProviderConfig.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/ai/provider/AiProviderConfig.java @@ -19,4 +19,4 @@ public sealed interface AiProviderConfig permits OpenAiProviderConfig, AzureOpenAiProviderConfig, GoogleAiGeminiProviderConfig, GoogleVertexAiGeminiProviderConfig, MistralAiProviderConfig, AnthropicProviderConfig, - AmazonBedrockProviderConfig, GitHubModelsProviderConfig {} + AmazonBedrockProviderConfig, GitHubModelsProviderConfig, OllamaProviderConfig {} diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/ai/provider/OllamaProviderConfig.java b/common/data/src/main/java/org/thingsboard/server/common/data/ai/provider/OllamaProviderConfig.java new file mode 100644 index 0000000000..fc0a2d6fd8 --- /dev/null +++ b/common/data/src/main/java/org/thingsboard/server/common/data/ai/provider/OllamaProviderConfig.java @@ -0,0 +1,22 @@ +/** + * 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.common.data.ai.provider; + +import jakarta.validation.constraints.NotBlank; + +public record OllamaProviderConfig( + @NotBlank String baseUrl +) implements AiProviderConfig {} diff --git a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/ai/TbResponseFormat.java b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/ai/TbResponseFormat.java index 5c891a9c74..5107e613a4 100644 --- a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/ai/TbResponseFormat.java +++ b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/ai/TbResponseFormat.java @@ -60,9 +60,7 @@ public sealed interface TbResponseFormat permits TbTextResponseFormat, TbJsonRes @Override public ResponseFormat toLangChainResponseFormat() { - return ResponseFormat.builder() - .type(ResponseFormatType.TEXT) - .build(); + return ResponseFormat.TEXT; } } @@ -76,9 +74,7 @@ public sealed interface TbResponseFormat permits TbTextResponseFormat, TbJsonRes @Override public ResponseFormat toLangChainResponseFormat() { - return ResponseFormat.builder() - .type(ResponseFormatType.JSON) - .build(); + return ResponseFormat.JSON; } } diff --git a/ui-ngx/src/app/modules/home/components/ai-model/ai-model-dialog.component.html b/ui-ngx/src/app/modules/home/components/ai-model/ai-model-dialog.component.html index 5f9c189399..1a3c2bf181 100644 --- a/ui-ngx/src/app/modules/home/components/ai-model/ai-model-dialog.component.html +++ b/ui-ngx/src/app/modules/home/components/ai-model/ai-model-dialog.component.html @@ -150,6 +150,15 @@ } + @if (providerFieldsList.includes('baseUrl')) { + + ai-models.baseurl + + + {{ 'ai-models.baseurl-required' | translate }} + + + } diff --git a/ui-ngx/src/app/modules/home/components/ai-model/ai-model-dialog.component.ts b/ui-ngx/src/app/modules/home/components/ai-model/ai-model-dialog.component.ts index c459d66f12..e6490cd84d 100644 --- a/ui-ngx/src/app/modules/home/components/ai-model/ai-model-dialog.component.ts +++ b/ui-ngx/src/app/modules/home/components/ai-model/ai-model-dialog.component.ts @@ -100,6 +100,7 @@ export class AIModelDialogComponent extends DialogComponent, 'label'>, HasTenantId region?: string; accessKeyId?: string; secretAccessKey?: string; + baseUrl?: string; }; modelId: string; temperature?: number; @@ -57,7 +58,8 @@ export enum AiProvider { MISTRAL_AI = 'MISTRAL_AI', ANTHROPIC = 'ANTHROPIC', AMAZON_BEDROCK = 'AMAZON_BEDROCK', - GITHUB_MODELS = 'GITHUB_MODELS' + GITHUB_MODELS = 'GITHUB_MODELS', + OLLAMA = 'OLLAMA' } export const AiProviderTranslations = new Map( @@ -69,7 +71,8 @@ export const AiProviderTranslations = new Map( [AiProvider.MISTRAL_AI , 'ai-models.ai-providers.mistral-ai'], [AiProvider.ANTHROPIC , 'ai-models.ai-providers.anthropic'], [AiProvider.AMAZON_BEDROCK , 'ai-models.ai-providers.amazon-bedrock'], - [AiProvider.GITHUB_MODELS , 'ai-models.ai-providers.github-models'] + [AiProvider.GITHUB_MODELS , 'ai-models.ai-providers.github-models'], + [AiProvider.OLLAMA , 'ai-models.ai-providers.ollama'] ] ); @@ -84,7 +87,8 @@ export const ProviderFieldsAllList = [ 'serviceVersion', 'region', 'accessKeyId', - 'secretAccessKey' + 'secretAccessKey', + 'baseUrl' ]; export const ModelFieldsAllList = ['temperature', 'topP', 'topK', 'frequencyPenalty', 'presencePenalty', 'maxOutputTokens']; @@ -191,6 +195,14 @@ export const AiModelMap = new Map Date: Tue, 16 Sep 2025 14:41:38 +0300 Subject: [PATCH 049/102] AI models: add context length support for Ollama --- .../Langchain4jChatModelConfigurerImpl.java | 1 + .../chat/AmazonBedrockChatModelConfig.java | 2 +- .../model/chat/AnthropicChatModelConfig.java | 2 +- .../chat/AzureOpenAiChatModelConfig.java | 2 +- .../chat/GitHubModelsChatModelConfig.java | 2 +- .../chat/GoogleAiGeminiChatModelConfig.java | 2 +- .../GoogleVertexAiGeminiChatModelConfig.java | 2 +- .../model/chat/MistralAiChatModelConfig.java | 2 +- .../ai/model/chat/OllamaChatModelConfig.java | 3 ++- .../ai/model/chat/OpenAiChatModelConfig.java | 2 +- .../ai-model/ai-model-dialog.component.html | 23 +++++++++++-------- .../ai-model/ai-model-dialog.component.ts | 3 ++- .../src/app/shared/models/ai-model.models.ts | 5 ++-- .../assets/locale/locale.constant-en_US.json | 3 ++- 14 files changed, 31 insertions(+), 23 deletions(-) diff --git a/application/src/main/java/org/thingsboard/server/service/ai/Langchain4jChatModelConfigurerImpl.java b/application/src/main/java/org/thingsboard/server/service/ai/Langchain4jChatModelConfigurerImpl.java index 7008e866f4..84b09b9188 100644 --- a/application/src/main/java/org/thingsboard/server/service/ai/Langchain4jChatModelConfigurerImpl.java +++ b/application/src/main/java/org/thingsboard/server/service/ai/Langchain4jChatModelConfigurerImpl.java @@ -272,6 +272,7 @@ class Langchain4jChatModelConfigurerImpl implements Langchain4jChatModelConfigur .temperature(chatModelConfig.temperature()) .topP(chatModelConfig.topP()) .topK(chatModelConfig.topK()) + .numCtx(chatModelConfig.contextLength()) .numPredict(chatModelConfig.maxOutputTokens()) .timeout(toDuration(chatModelConfig.timeoutSeconds())) .maxRetries(chatModelConfig.maxRetries()) diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/AmazonBedrockChatModelConfig.java b/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/AmazonBedrockChatModelConfig.java index 2bb4de5aa8..d2ab72086a 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/AmazonBedrockChatModelConfig.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/AmazonBedrockChatModelConfig.java @@ -33,7 +33,7 @@ public record AmazonBedrockChatModelConfig( @NotBlank String modelId, @PositiveOrZero Double temperature, @Positive @Max(1) Double topP, - @Positive Integer maxOutputTokens, + Integer maxOutputTokens, @With @Positive Integer timeoutSeconds, @With @PositiveOrZero Integer maxRetries ) implements AiChatModelConfig { diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/AnthropicChatModelConfig.java b/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/AnthropicChatModelConfig.java index 69b5578fb3..6d505f75a6 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/AnthropicChatModelConfig.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/AnthropicChatModelConfig.java @@ -34,7 +34,7 @@ public record AnthropicChatModelConfig( @PositiveOrZero Double temperature, @Positive @Max(1) Double topP, @PositiveOrZero Integer topK, - @Positive Integer maxOutputTokens, + Integer maxOutputTokens, @With @Positive Integer timeoutSeconds, @With @PositiveOrZero Integer maxRetries ) implements AiChatModelConfig { diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/AzureOpenAiChatModelConfig.java b/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/AzureOpenAiChatModelConfig.java index 47e7e96c37..f70f2af539 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/AzureOpenAiChatModelConfig.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/AzureOpenAiChatModelConfig.java @@ -35,7 +35,7 @@ public record AzureOpenAiChatModelConfig( @Positive @Max(1) Double topP, Double frequencyPenalty, Double presencePenalty, - @Positive Integer maxOutputTokens, + Integer maxOutputTokens, @With @Positive Integer timeoutSeconds, @With @PositiveOrZero Integer maxRetries ) implements AiChatModelConfig { diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/GitHubModelsChatModelConfig.java b/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/GitHubModelsChatModelConfig.java index b509254f77..0aafd72197 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/GitHubModelsChatModelConfig.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/GitHubModelsChatModelConfig.java @@ -35,7 +35,7 @@ public record GitHubModelsChatModelConfig( @Positive @Max(1) Double topP, Double frequencyPenalty, Double presencePenalty, - @Positive Integer maxOutputTokens, + Integer maxOutputTokens, @With @Positive Integer timeoutSeconds, @With @PositiveOrZero Integer maxRetries ) implements AiChatModelConfig { diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/GoogleAiGeminiChatModelConfig.java b/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/GoogleAiGeminiChatModelConfig.java index fe11a11460..b5c3d4263d 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/GoogleAiGeminiChatModelConfig.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/GoogleAiGeminiChatModelConfig.java @@ -36,7 +36,7 @@ public record GoogleAiGeminiChatModelConfig( @PositiveOrZero Integer topK, Double frequencyPenalty, Double presencePenalty, - @Positive Integer maxOutputTokens, + Integer maxOutputTokens, @With @Positive Integer timeoutSeconds, @With @PositiveOrZero Integer maxRetries ) implements AiChatModelConfig { diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/GoogleVertexAiGeminiChatModelConfig.java b/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/GoogleVertexAiGeminiChatModelConfig.java index 609e14f86e..944963ee27 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/GoogleVertexAiGeminiChatModelConfig.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/GoogleVertexAiGeminiChatModelConfig.java @@ -36,7 +36,7 @@ public record GoogleVertexAiGeminiChatModelConfig( @PositiveOrZero Integer topK, Double frequencyPenalty, Double presencePenalty, - @Positive Integer maxOutputTokens, + Integer maxOutputTokens, @With @Positive Integer timeoutSeconds, @With @PositiveOrZero Integer maxRetries ) implements AiChatModelConfig { diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/MistralAiChatModelConfig.java b/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/MistralAiChatModelConfig.java index f603e99c53..8f67d93398 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/MistralAiChatModelConfig.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/MistralAiChatModelConfig.java @@ -35,7 +35,7 @@ public record MistralAiChatModelConfig( @Positive @Max(1) Double topP, Double frequencyPenalty, Double presencePenalty, - @Positive Integer maxOutputTokens, + Integer maxOutputTokens, @With @Positive Integer timeoutSeconds, @With @PositiveOrZero Integer maxRetries ) implements AiChatModelConfig { diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/OllamaChatModelConfig.java b/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/OllamaChatModelConfig.java index 360b514d6d..ea48670b63 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/OllamaChatModelConfig.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/OllamaChatModelConfig.java @@ -34,7 +34,8 @@ public record OllamaChatModelConfig( @PositiveOrZero Double temperature, @Positive @Max(1) Double topP, @PositiveOrZero Integer topK, - @Positive Integer maxOutputTokens, + Integer contextLength, + Integer maxOutputTokens, @With @Positive Integer timeoutSeconds, @With @PositiveOrZero Integer maxRetries ) implements AiChatModelConfig { diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/OpenAiChatModelConfig.java b/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/OpenAiChatModelConfig.java index 00b5115d7d..23db9accc2 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/OpenAiChatModelConfig.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/OpenAiChatModelConfig.java @@ -35,7 +35,7 @@ public record OpenAiChatModelConfig( @Positive @Max(1) Double topP, Double frequencyPenalty, Double presencePenalty, - @Positive Integer maxOutputTokens, + Integer maxOutputTokens, @With @Positive Integer timeoutSeconds, @With @PositiveOrZero Integer maxRetries ) implements AiChatModelConfig { diff --git a/ui-ngx/src/app/modules/home/components/ai-model/ai-model-dialog.component.html b/ui-ngx/src/app/modules/home/components/ai-model/ai-model-dialog.component.html index 1a3c2bf181..c730850474 100644 --- a/ui-ngx/src/app/modules/home/components/ai-model/ai-model-dialog.component.html +++ b/ui-ngx/src/app/modules/home/components/ai-model/ai-model-dialog.component.html @@ -151,7 +151,7 @@ } @if (providerFieldsList.includes('baseUrl')) { - + ai-models.baseurl @@ -264,15 +264,18 @@ - - warning - + type="number" step="1" placeholder="{{ 'ai-models.set' | translate }}"> + + + } + @if (modelFieldsList.includes('contextLength')) { +
+
+ {{ 'ai-models.context-length' | translate }} +
+ +
} diff --git a/ui-ngx/src/app/modules/home/components/ai-model/ai-model-dialog.component.ts b/ui-ngx/src/app/modules/home/components/ai-model/ai-model-dialog.component.ts index e6490cd84d..3294c6ac76 100644 --- a/ui-ngx/src/app/modules/home/components/ai-model/ai-model-dialog.component.ts +++ b/ui-ngx/src/app/modules/home/components/ai-model/ai-model-dialog.component.ts @@ -108,7 +108,8 @@ export class AIModelDialogComponent extends DialogComponent, 'label'>, HasTenantId frequencyPenalty?: number; presencePenalty?: number; maxOutputTokens?: number; + contextLength?: number; } } @@ -91,7 +92,7 @@ export const ProviderFieldsAllList = [ 'baseUrl' ]; -export const ModelFieldsAllList = ['temperature', 'topP', 'topK', 'frequencyPenalty', 'presencePenalty', 'maxOutputTokens']; +export const ModelFieldsAllList = ['temperature', 'topP', 'topK', 'frequencyPenalty', 'presencePenalty', 'maxOutputTokens', 'contextLength']; export const AiModelMap = new Map([ [ @@ -200,7 +201,7 @@ export const AiModelMap = new Map Date: Mon, 22 Sep 2025 12:18:06 +0300 Subject: [PATCH 050/102] AI models: add auth support for Ollama --- .../Langchain4jChatModelConfigurerImpl.java | 28 +++++++++++++--- .../ai/provider/OllamaProviderConfig.java | 32 +++++++++++++++++-- 2 files changed, 53 insertions(+), 7 deletions(-) diff --git a/application/src/main/java/org/thingsboard/server/service/ai/Langchain4jChatModelConfigurerImpl.java b/application/src/main/java/org/thingsboard/server/service/ai/Langchain4jChatModelConfigurerImpl.java index 84b09b9188..2cb6c2097f 100644 --- a/application/src/main/java/org/thingsboard/server/service/ai/Langchain4jChatModelConfigurerImpl.java +++ b/application/src/main/java/org/thingsboard/server/service/ai/Langchain4jChatModelConfigurerImpl.java @@ -35,6 +35,7 @@ import dev.langchain4j.model.mistralai.MistralAiChatModel; import dev.langchain4j.model.ollama.OllamaChatModel; import dev.langchain4j.model.openai.OpenAiChatModel; import dev.langchain4j.model.vertexai.gemini.VertexAiGeminiChatModel; +import org.springframework.http.HttpHeaders; import org.springframework.stereotype.Component; import org.thingsboard.server.common.data.ai.model.chat.AmazonBedrockChatModelConfig; import org.thingsboard.server.common.data.ai.model.chat.AnthropicChatModelConfig; @@ -49,6 +50,7 @@ import org.thingsboard.server.common.data.ai.model.chat.OpenAiChatModelConfig; import org.thingsboard.server.common.data.ai.provider.AmazonBedrockProviderConfig; import org.thingsboard.server.common.data.ai.provider.AzureOpenAiProviderConfig; import org.thingsboard.server.common.data.ai.provider.GoogleVertexAiGeminiProviderConfig; +import org.thingsboard.server.common.data.ai.provider.OllamaProviderConfig; import software.amazon.awssdk.auth.credentials.AwsBasicCredentials; import software.amazon.awssdk.auth.credentials.StaticCredentialsProvider; import software.amazon.awssdk.regions.Region; @@ -56,7 +58,11 @@ import software.amazon.awssdk.services.bedrockruntime.BedrockRuntimeClient; import java.io.ByteArrayInputStream; import java.io.IOException; +import java.nio.charset.StandardCharsets; import java.time.Duration; +import java.util.Base64; + +import static java.util.Collections.singletonMap; @Component class Langchain4jChatModelConfigurerImpl implements Langchain4jChatModelConfigurer { @@ -136,7 +142,7 @@ class Langchain4jChatModelConfigurerImpl implements Langchain4jChatModelConfigur // set request timeout from model config if (chatModelConfig.timeoutSeconds() != null) { - retrySettings.setTotalTimeout(org.threeten.bp.Duration.ofSeconds(chatModelConfig.timeoutSeconds())); + retrySettings.setTotalTimeoutDuration(Duration.ofSeconds(chatModelConfig.timeoutSeconds())); } // set updated retry settings @@ -266,7 +272,7 @@ class Langchain4jChatModelConfigurerImpl implements Langchain4jChatModelConfigur @Override public ChatModel configureChatModel(OllamaChatModelConfig chatModelConfig) { - return OllamaChatModel.builder() + var builder = OllamaChatModel.builder() .baseUrl(chatModelConfig.providerConfig().baseUrl()) .modelName(chatModelConfig.modelId()) .temperature(chatModelConfig.temperature()) @@ -275,8 +281,22 @@ class Langchain4jChatModelConfigurerImpl implements Langchain4jChatModelConfigur .numCtx(chatModelConfig.contextLength()) .numPredict(chatModelConfig.maxOutputTokens()) .timeout(toDuration(chatModelConfig.timeoutSeconds())) - .maxRetries(chatModelConfig.maxRetries()) - .build(); + .maxRetries(chatModelConfig.maxRetries()); + + var auth = chatModelConfig.providerConfig().auth(); + if (auth instanceof OllamaProviderConfig.OllamaAuth.Basic basicAuth) { + String credentials = basicAuth.username() + ":" + basicAuth.password(); + String encodedCredentials = Base64.getEncoder().encodeToString(credentials.getBytes(StandardCharsets.UTF_8)); + builder.customHeaders(singletonMap(HttpHeaders.AUTHORIZATION, "Basic " + encodedCredentials)); + } else if (auth instanceof OllamaProviderConfig.OllamaAuth.Token tokenAuth) { + builder.customHeaders(singletonMap(HttpHeaders.AUTHORIZATION, "Bearer " + tokenAuth.token())); + } else if (auth instanceof OllamaProviderConfig.OllamaAuth.None) { + // do nothing + } else { + throw new UnsupportedOperationException("Unknown authentication type: " + auth.getClass().getSimpleName()); + } + + return builder.build(); } private static Duration toDuration(Integer timeoutSeconds) { diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/ai/provider/OllamaProviderConfig.java b/common/data/src/main/java/org/thingsboard/server/common/data/ai/provider/OllamaProviderConfig.java index fc0a2d6fd8..39bb57834c 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/ai/provider/OllamaProviderConfig.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/ai/provider/OllamaProviderConfig.java @@ -15,8 +15,34 @@ */ package org.thingsboard.server.common.data.ai.provider; -import jakarta.validation.constraints.NotBlank; +import com.fasterxml.jackson.annotation.JsonSubTypes; +import com.fasterxml.jackson.annotation.JsonTypeInfo; +import jakarta.validation.Valid; +import jakarta.validation.constraints.NotNull; public record OllamaProviderConfig( - @NotBlank String baseUrl -) implements AiProviderConfig {} + @NotNull String baseUrl, + @NotNull @Valid OllamaAuth auth +) implements AiProviderConfig { + + @JsonTypeInfo( + use = JsonTypeInfo.Id.NAME, + include = JsonTypeInfo.As.PROPERTY, + property = "type" + ) + @JsonSubTypes({ + @JsonSubTypes.Type(value = OllamaAuth.None.class, name = "NONE"), + @JsonSubTypes.Type(value = OllamaAuth.Basic.class, name = "BASIC"), + @JsonSubTypes.Type(value = OllamaAuth.Token.class, name = "TOKEN") + }) + public sealed interface OllamaAuth { + + record None() implements OllamaAuth {} + + record Basic(@NotNull String username, @NotNull String password) implements OllamaAuth {} + + record Token(@NotNull String token) implements OllamaAuth {} + + } + +} From 8a6015f04e7b2b4471f9d801517c9e40da6ef0d3 Mon Sep 17 00:00:00 2001 From: ArtemDzhereleiko Date: Thu, 25 Sep 2025 15:44:50 +0300 Subject: [PATCH 051/102] UI: Add authentication for Ollama model --- .../ai-model/ai-model-dialog.component.html | 87 +++++++++++++++---- .../ai-model/ai-model-dialog.component.ts | 67 +++++++++++--- .../src/app/shared/models/ai-model.models.ts | 11 +++ .../assets/locale/locale.constant-en_US.json | 16 +++- 4 files changed, 153 insertions(+), 28 deletions(-) diff --git a/ui-ngx/src/app/modules/home/components/ai-model/ai-model-dialog.component.html b/ui-ngx/src/app/modules/home/components/ai-model/ai-model-dialog.component.html index c730850474..abfe8500b4 100644 --- a/ui-ngx/src/app/modules/home/components/ai-model/ai-model-dialog.component.html +++ b/ui-ngx/src/app/modules/home/components/ai-model/ai-model-dialog.component.html @@ -55,31 +55,34 @@
-
+
@if (providerFieldsList.includes('personalAccessToken')) { ai-models.personal-access-token - + {{ 'ai-models.personal-access-token-required' | translate }} } @if (providerFieldsList.includes('projectId')) { - + ai-models.project-id - + {{ 'ai-models.project-id-required' | translate }} } @if (providerFieldsList.includes('location')) { - + ai-models.location - + {{ 'ai-models.location-required' | translate }} @@ -98,16 +101,17 @@ } @if (providerFieldsList.includes('endpoint')) { - + ai-models.endpoint - + {{ 'ai-models.endpoint-required' | translate }} } @if (providerFieldsList.includes('serviceVersion')) { - + ai-models.service-version @@ -117,25 +121,28 @@ ai-models.api-key - + {{ 'ai-models.api-key-required' | translate }} } @if (providerFieldsList.includes('region')) { - + ai-models.region - + {{ 'ai-models.region-required' | translate }} } @if (providerFieldsList.includes('accessKeyId')) { - + ai-models.access-key-id - + {{ 'ai-models.access-key-id-required' | translate }} @@ -145,7 +152,8 @@ ai-models.secret-access-key - + {{ 'ai-models.secret-access-key-required' | translate }} @@ -154,11 +162,58 @@ ai-models.baseurl - + {{ 'ai-models.baseurl-required' | translate }} } + @if (provider === aiProvider.OLLAMA) { +
+
+
+ {{ 'ai-models.authentication' | translate }} +
+ + {{ 'ai-models.authentication-type.none' | translate }} + {{ 'ai-models.authentication-type.basic' | translate }} + {{ 'ai-models.authentication-type.token' | translate }} + +
+
+ @if (aiModelForms.get('configuration.providerConfig.auth.type').value === AuthenticationType.BASIC) { + + ai-models.username + + + {{ 'ai-models.username-required' | translate }} + + + + ai-models.password + + + + {{ 'ai-models.password-required' | translate }} + + + } + @if (aiModelForms.get('configuration.providerConfig.auth.type').value === AuthenticationType.TOKEN) { + + ai-models.token + + + + {{ 'ai-models.token-required' | translate }} + + + } +
+
+ }
diff --git a/ui-ngx/src/app/modules/home/components/ai-model/ai-model-dialog.component.ts b/ui-ngx/src/app/modules/home/components/ai-model/ai-model-dialog.component.ts index 3294c6ac76..9d0d28e627 100644 --- a/ui-ngx/src/app/modules/home/components/ai-model/ai-model-dialog.component.ts +++ b/ui-ngx/src/app/modules/home/components/ai-model/ai-model-dialog.component.ts @@ -30,6 +30,7 @@ import { AiModelMap, AiProvider, AiProviderTranslations, + AuthenticationType, ModelType, ProviderFieldsAllList } from '@shared/models/ai-model.models'; @@ -37,6 +38,7 @@ import { AiModelService } from '@core/http/ai-model.service'; import { CheckConnectivityDialogComponent } from '@home/components/ai-model/check-connectivity-dialog.component'; import { map } from 'rxjs/operators'; import { deepTrim } from '@core/utils'; +import { TranslateService } from '@ngx-translate/core'; export interface AIModelDialogData { AIModel?: AiModel; @@ -62,18 +64,23 @@ export class AIModelDialogComponent extends DialogComponent, protected router: Router, protected dialogRef: MatDialogRef, @Inject(MAT_DIALOG_DATA) public data: AIModelDialogData, private fb: FormBuilder, private aiModelService: AiModelService, + private translate: TranslateService, private dialog: MatDialog) { super(store, router, dialogRef); @@ -89,18 +96,24 @@ export class AIModelDialogComponent extends DialogComponent { + this.getAuthenticationHint(type); + this.aiModelForms.get('configuration.providerConfig.auth.username').disable(); + this.aiModelForms.get('configuration.providerConfig.auth.password').disable(); + this.aiModelForms.get('configuration.providerConfig.auth.token').disable(); + if (type === AuthenticationType.BASIC) { + this.aiModelForms.get('configuration.providerConfig.auth.username').enable(); + this.aiModelForms.get('configuration.providerConfig.auth.password').enable(); + } + if (type === AuthenticationType.TOKEN) { + this.aiModelForms.get('configuration.providerConfig.auth.token').enable(); + } + }); this.updateValidation(this.provider); } @@ -132,6 +161,16 @@ export class AIModelDialogComponent extends DialogComponent { if (AiModelMap.get(provider).providerFieldsList.includes(key)) { @@ -139,7 +178,13 @@ export class AIModelDialogComponent extends DialogComponent, 'label'>, HasTenantId accessKeyId?: string; secretAccessKey?: string; baseUrl?: string; + auth?: { + type: AuthenticationType; + username?: string; + password?: string; + token?: string + } }; modelId: string; temperature?: number; @@ -242,3 +248,8 @@ export interface CheckConnectivityResult { status: string; errorDetails: string; } +export enum AuthenticationType { + NONE = 'NONE', + BASIC = 'BASIC', + TOKEN = 'TOKEN' +} 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 c1acd62c00..d39078f56a 100644 --- a/ui-ngx/src/assets/locale/locale.constant-en_US.json +++ b/ui-ngx/src/assets/locale/locale.constant-en_US.json @@ -1169,7 +1169,21 @@ "check-connectivity-failed": "Test request failed", "no-model-matching": "No models matching '{{entity}}' were found.", "model-required": "Model is required.", - "no-model-text": "No models found." + "no-model-text": "No models found.", + "authentication": "Authentication", + "authentication-basic-hint": "Uses standard HTTP Basic authentication. The username and password will be combined, Base64-encoded, and sent in an \"Authorization\" header with each request to the Ollama server.", + "authentication-token-hint": "Uses Bearer token authentication. The provided token will be sent directly in an \"Authorization\" eader with each request to the Ollama server.", + "authentication-type": { + "none": "None", + "basic": "Basic", + "token": "Token" + }, + "username": "Username", + "username-required": "Username is required.", + "password": "Password", + "password-required": "Password is required.", + "token": "Token", + "token-required": "Token is required." }, "confirm-on-exit": { "message": "You have unsaved changes. Are you sure you want to leave this page?", From d989d1e7d75e1fa54f484bea444c2b2d08bc5d5a Mon Sep 17 00:00:00 2001 From: Vladyslav_Prykhodko Date: Tue, 30 Sep 2025 13:52:05 +0300 Subject: [PATCH 052/102] UI: Refactoring show total value in legend --- .../basic/chart/latest-chart-basic-config.component.html | 8 +++++--- .../widget/lib/chart/bar-chart-widget.models.ts | 1 + .../components/widget/lib/chart/doughnut-widget.models.ts | 1 + .../components/widget/lib/chart/latest-chart.component.ts | 5 +---- .../components/widget/lib/chart/latest-chart.models.ts | 5 ++--- .../home/components/widget/lib/chart/latest-chart.ts | 8 +++++--- .../widget/lib/chart/pie-chart-widget.models.ts | 1 + .../widget/lib/chart/polar-area-widget.models.ts | 1 + .../widget/lib/chart/radar-chart-widget.models.ts | 1 + .../chart/latest-chart-widget-settings.component.html | 8 +++++--- 10 files changed, 23 insertions(+), 16 deletions(-) diff --git a/ui-ngx/src/app/modules/home/components/widget/config/basic/chart/latest-chart-basic-config.component.html b/ui-ngx/src/app/modules/home/components/widget/config/basic/chart/latest-chart-basic-config.component.html index b5c1a460fb..31d0e6cd7e 100644 --- a/ui-ngx/src/app/modules/home/components/widget/config/basic/chart/latest-chart-basic-config.component.html +++ b/ui-ngx/src/app/modules/home/components/widget/config/basic/chart/latest-chart-basic-config.component.html @@ -144,9 +144,11 @@
- - {{ 'legend.show-total' | translate }} - +
+ + {{ 'legend.show-total' | translate }} + +
diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/chart/bar-chart-widget.models.ts b/ui-ngx/src/app/modules/home/components/widget/lib/chart/bar-chart-widget.models.ts index 036ba70c04..3f76eb18cf 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/chart/bar-chart-widget.models.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/chart/bar-chart-widget.models.ts @@ -68,6 +68,7 @@ export const barChartWidgetBarsChartSettings = (settings: BarChartWidgetSettings showTotal: false, animation: settings.animation, showLegend: settings.showLegend, + legendShowTotal: settings.legendShowTotal, showTooltip: settings.showTooltip, tooltipValueType: settings.tooltipValueType, tooltipValueDecimals: settings.tooltipValueDecimals, diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/chart/doughnut-widget.models.ts b/ui-ngx/src/app/modules/home/components/widget/lib/chart/doughnut-widget.models.ts index 0d8c1ba9fa..0ef277ea8b 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/chart/doughnut-widget.models.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/chart/doughnut-widget.models.ts @@ -88,6 +88,7 @@ export const doughnutPieChartSettings = (settings: DoughnutWidgetSettings): Deep showTotal: settings.layout === DoughnutLayout.with_total, animation: settings.animation, showLegend: settings.showLegend, + legendShowTotal: settings.legendShowTotal, totalValueFont: settings.totalValueFont, totalValueColor: settings.totalValueColor, showLabel: false, diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/chart/latest-chart.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/chart/latest-chart.component.ts index 80be75d98d..85905dfd3e 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/chart/latest-chart.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/chart/latest-chart.component.ts @@ -82,8 +82,7 @@ export class LatestChartComponent implements OnInit, OnDestroy, AfterViewInit { padding: string; get legendItems(): LatestChartLegendItem[] { - let items = this.latestChart ? this.latestChart.getLegendItems() : []; - return this.legendShowTotal ? items : items.filter(item => !item.total); + return this.latestChart ? this.latestChart.getLegendItems() : []; } legendLabelStyle: ComponentStyle; @@ -93,7 +92,6 @@ export class LatestChartComponent implements OnInit, OnDestroy, AfterViewInit { private shapeResize$: ResizeObserver; private legendHorizontal: boolean; - private legendShowTotal: boolean; private latestChart: TbLatestChart; @@ -121,7 +119,6 @@ export class LatestChartComponent implements OnInit, OnDestroy, AfterViewInit { this.legendValueStyle = textStyle(this.settings.legendValueFont); this.disabledLegendValueStyle = textStyle(this.settings.legendValueFont); this.legendValueStyle.color = this.settings.legendValueColor; - this.legendShowTotal = this.settings.legendShowTotal; } } diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/chart/latest-chart.models.ts b/ui-ngx/src/app/modules/home/components/widget/lib/chart/latest-chart.models.ts index 639938bad6..aa1ac43644 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/chart/latest-chart.models.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/chart/latest-chart.models.ts @@ -87,6 +87,7 @@ export interface LatestChartSettings extends LatestChartTooltipSettings { sortSeries: boolean; showTotal?: boolean; showLegend: boolean; + legendShowTotal: boolean; animation: ChartAnimationSettings; } @@ -96,6 +97,7 @@ export const latestChartDefaultSettings: LatestChartSettings = { sortSeries: false, showTotal: false, showLegend: true, + legendShowTotal: true, animation: mergeDeep({} as ChartAnimationSettings, chartAnimationDefaultSettings) }; @@ -105,14 +107,12 @@ export interface LatestChartWidgetSettings extends LatestChartSettings { legendLabelColor: string; legendValueFont: Font; legendValueColor: string; - legendShowTotal: boolean; background: BackgroundSettings; padding: string; } export const latestChartWidgetDefaultSettings: LatestChartWidgetSettings = { ...latestChartDefaultSettings, - showLegend: true, legendPosition: LegendPosition.bottom, legendLabelFont: { family: 'Roboto', @@ -132,7 +132,6 @@ export const latestChartWidgetDefaultSettings: LatestChartWidgetSettings = { lineHeight: '20px' }, legendValueColor: 'rgba(0, 0, 0, 0.87)', - legendShowTotal: true, background: { type: BackgroundType.color, color: '#fff', diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/chart/latest-chart.ts b/ui-ngx/src/app/modules/home/components/widget/lib/chart/latest-chart.ts index b9f0940b52..9e525cd794 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/chart/latest-chart.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/chart/latest-chart.ts @@ -36,6 +36,7 @@ import { ValueFormatProcessor } from '@shared/models/widget-settings.models'; export abstract class TbLatestChart { private readonly shapeResize$: ResizeObserver; + private showTotalValueInLegend: boolean; protected readonly settings: S; @@ -121,7 +122,8 @@ export abstract class TbLatestChart { this.legendItems.sort((a, b) => a.label.localeCompare(b.label)); } } - if (this.settings.showLegend && !this.settings.showTotal) { + this.showTotalValueInLegend = this.settings.showLegend && !this.settings.showTotal && this.settings.legendShowTotal; + if (this.showTotalValueInLegend) { this.legendItems.push( { value: '--', @@ -252,11 +254,11 @@ export abstract class TbLatestChart { if (this.settings.showTotal || this.settings.showLegend) { if (hasValue) { this.totalText = this.valueFormatter.format(this.total); - if (this.settings.showLegend && !this.settings.showTotal) { + if (this.showTotalValueInLegend) { this.legendItems[this.legendItems.length - 1].hasValue = true; this.legendItems[this.legendItems.length - 1].value = this.totalText; } - } else if (this.settings.showLegend && !this.settings.showTotal) { + } else if (this.showTotalValueInLegend) { this.legendItems[this.legendItems.length - 1].hasValue = false; this.legendItems[this.legendItems.length - 1].value = '--'; } diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/chart/pie-chart-widget.models.ts b/ui-ngx/src/app/modules/home/components/widget/lib/chart/pie-chart-widget.models.ts index a98cdfdd4a..cddf75b9f9 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/chart/pie-chart-widget.models.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/chart/pie-chart-widget.models.ts @@ -64,6 +64,7 @@ export const pieChartWidgetPieChartSettings = (settings: PieChartWidgetSettings) showTotal: false, animation: settings.animation, showLegend: settings.showLegend, + legendShowTotal: settings.legendShowTotal, showLabel: settings.showLabel, labelPosition: settings.labelPosition, labelFont: settings.labelFont, diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/chart/polar-area-widget.models.ts b/ui-ngx/src/app/modules/home/components/widget/lib/chart/polar-area-widget.models.ts index 70b4520159..4b744ce9c3 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/chart/polar-area-widget.models.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/chart/polar-area-widget.models.ts @@ -72,6 +72,7 @@ export const polarAreaChartWidgetBarsChartSettings = (settings: PolarAreaChartWi showTotal: false, animation: settings.animation, showLegend: settings.showLegend, + legendShowTotal: settings.legendShowTotal, showTooltip: settings.showTooltip, tooltipValueType: settings.tooltipValueType, tooltipValueDecimals: settings.tooltipValueDecimals, diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/chart/radar-chart-widget.models.ts b/ui-ngx/src/app/modules/home/components/widget/lib/chart/radar-chart-widget.models.ts index ec42955b4d..b3be129489 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/chart/radar-chart-widget.models.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/chart/radar-chart-widget.models.ts @@ -135,6 +135,7 @@ export const radarChartWidgetRadarChartSettings = (settings: RadarChartWidgetSet showTotal: false, animation: settings.animation, showLegend: settings.showLegend, + legendShowTotal: settings.legendShowTotal, showTooltip: settings.showTooltip, tooltipValueType: settings.tooltipValueType, tooltipValueDecimals: settings.tooltipValueDecimals, diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/chart/latest-chart-widget-settings.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/settings/chart/latest-chart-widget-settings.component.html index fe071f16a0..66d0234914 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/chart/latest-chart-widget-settings.component.html +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/chart/latest-chart-widget-settings.component.html @@ -61,9 +61,11 @@ - - {{ 'legend.show-total' | translate }} - +
+ + {{ 'legend.show-total' | translate }} + +
From 94e098ab4d058fc7f157e3176f3cf43f408f24be Mon Sep 17 00:00:00 2001 From: Dmytro Skarzhynets Date: Tue, 30 Sep 2025 14:50:00 +0300 Subject: [PATCH 053/102] AI models: update model ID autocomplete options --- ui-ngx/src/app/shared/models/ai-model.models.ts | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/ui-ngx/src/app/shared/models/ai-model.models.ts b/ui-ngx/src/app/shared/models/ai-model.models.ts index b0a88d076e..5ba0327dfe 100644 --- a/ui-ngx/src/app/shared/models/ai-model.models.ts +++ b/ui-ngx/src/app/shared/models/ai-model.models.ts @@ -110,6 +110,9 @@ export const AiModelMap = new Map Date: Tue, 30 Sep 2025 17:02:23 +0200 Subject: [PATCH 054/102] Cassandra DAO: Safety trigger to fall back to use_ts_key_value_partitioning_on_read as true if estimated partitions count is greater than safety trigger value. --- .../src/main/resources/thingsboard.yml | 5 +- .../CassandraBaseTimeseriesDao.java | 29 ++++++++- .../dao/timeseries/NoSqlTsPartitionDate.java | 15 ++--- ...aoPartitioningMinutesAlwaysExistsTest.java | 12 ++++ ...DaoPartitioningMonthsAlwaysExistsTest.java | 63 ++++++++++++++++++- ...sDaoPartitioningYearsAlwaysExistsTest.java | 12 ++++ .../timeseries/NoSqlTsPartitionDateTest.java | 41 ++++++++++++ 7 files changed, 162 insertions(+), 15 deletions(-) create mode 100644 dao/src/test/java/org/thingsboard/server/dao/timeseries/NoSqlTsPartitionDateTest.java diff --git a/application/src/main/resources/thingsboard.yml b/application/src/main/resources/thingsboard.yml index 9fcdd0c166..660e9e3a59 100644 --- a/application/src/main/resources/thingsboard.yml +++ b/application/src/main/resources/thingsboard.yml @@ -303,8 +303,11 @@ cassandra: default_fetch_size: "${CASSANDRA_DEFAULT_FETCH_SIZE:2000}" # Specify partitioning size for timestamp key-value storage. Example: MINUTES, HOURS, DAYS, MONTHS, INDEFINITE ts_key_value_partitioning: "${TS_KV_PARTITIONING:MONTHS}" - # Enable/Disable timestamp key-value partioning on read queries + # Enable/Disable timestamp key-value partitioning on read queries use_ts_key_value_partitioning_on_read: "${USE_TS_KV_PARTITIONING_ON_READ:true}" + # Safety trigger to fall back to use_ts_key_value_partitioning_on_read as true if estimated partitions count is greater than safety trigger value. + # It helps to prevent building huge partition list (OOM) for corner cases (like from 0 to infinity) and prefer fewer reads strategy from NoSQL database + use_ts_key_value_partitioning_on_read_max_estimated_partition_count: "${USE_TS_KV_PARTITIONING_ON_READ_MAX_ESTIMATED_PARTITION_COUNT:40}" # The number of partitions that are cached in memory of each service. It is useful to decrease the load of re-inserting the same partitions again ts_key_value_partitions_max_cache_size: "${TS_KV_PARTITIONS_MAX_CACHE_SIZE:100000}" # Timeseries Time To Live (in seconds) for Cassandra Record. 0 - record has never expired diff --git a/dao/src/main/java/org/thingsboard/server/dao/timeseries/CassandraBaseTimeseriesDao.java b/dao/src/main/java/org/thingsboard/server/dao/timeseries/CassandraBaseTimeseriesDao.java index f7bd64bef8..6818994629 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/timeseries/CassandraBaseTimeseriesDao.java +++ b/dao/src/main/java/org/thingsboard/server/dao/timeseries/CassandraBaseTimeseriesDao.java @@ -113,6 +113,10 @@ public class CassandraBaseTimeseriesDao extends AbstractCassandraBaseTimeseriesD @Value("${cassandra.query.use_ts_key_value_partitioning_on_read:true}") private boolean useTsKeyValuePartitioningOnRead; + @Getter + @Value("${cassandra.query.use_ts_key_value_partitioning_on_read_max_estimated_partition_count:40}") // 3+ years for MONTHS + private int useTsKeyValuePartitioningOnReadMaxEstimatedPartitionCount; + @Value("${cassandra.query.ts_key_value_partitions_max_cache_size:100000}") private long partitionsCacheSize; @@ -415,22 +419,41 @@ public class CassandraBaseTimeseriesDao extends AbstractCassandraBaseTimeseriesD readResultsProcessingExecutor); } - private ListenableFuture> getPartitionsFuture(TenantId tenantId, TsKvQuery query, EntityId entityId, long minPartition, long maxPartition) { + ListenableFuture> getPartitionsFuture(TenantId tenantId, TsKvQuery query, EntityId entityId, long minPartition, long maxPartition) { if (isFixedPartitioning()) { //no need to fetch partitions from DB return Futures.immediateFuture(FIXED_PARTITION); } if (!isUseTsKeyValuePartitioningOnRead()) { - return Futures.immediateFuture(calculatePartitions(minPartition, maxPartition)); + final long estimatedPartitionCount = estimatePartitionCount(minPartition, maxPartition); + if (estimatedPartitionCount <= useTsKeyValuePartitioningOnReadMaxEstimatedPartitionCount) { + return Futures.immediateFuture(calculatePartitions(minPartition, maxPartition, (int) estimatedPartitionCount)); + } } + return getPartitionsFromDB(tenantId, query, entityId, minPartition, maxPartition); + } + + ListenableFuture> getPartitionsFromDB(TenantId tenantId, TsKvQuery query, EntityId entityId, long minPartition, long maxPartition) { TbResultSetFuture partitionsFuture = fetchPartitions(tenantId, entityId, query.getKey(), minPartition, maxPartition); return Futures.transformAsync(partitionsFuture, getPartitionsArrayFunction(), readResultsProcessingExecutor); } + // Optimistic estimation of partition count, expected to be never called for infinite partitioning + long estimatePartitionCount(long minPartition, long maxPartition) { + if (maxPartition > minPartition) { + return (maxPartition - minPartition) / tsFormat.getDurationMs() + 2; //at least 2 partitions, at max 2 partitions overestimated + } + return 1; // 1 or 0, but 1 is more optimistic + } + List calculatePartitions(long minPartition, long maxPartition) { + return calculatePartitions(minPartition, maxPartition, 0); + } + + List calculatePartitions(long minPartition, long maxPartition, int estimatedPartitionCount) { if (minPartition == maxPartition) { return Collections.singletonList(minPartition); } - List partitions = new ArrayList<>(); + List partitions = estimatedPartitionCount > 0 ? new ArrayList<>(estimatedPartitionCount) : new ArrayList<>(); long currentPartition = minPartition; LocalDateTime currentPartitionTime = LocalDateTime.ofInstant(Instant.ofEpochMilli(currentPartition), ZoneOffset.UTC); diff --git a/dao/src/main/java/org/thingsboard/server/dao/timeseries/NoSqlTsPartitionDate.java b/dao/src/main/java/org/thingsboard/server/dao/timeseries/NoSqlTsPartitionDate.java index 873c33d4e3..c403a46df1 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/timeseries/NoSqlTsPartitionDate.java +++ b/dao/src/main/java/org/thingsboard/server/dao/timeseries/NoSqlTsPartitionDate.java @@ -15,32 +15,29 @@ */ package org.thingsboard.server.dao.timeseries; +import lombok.Getter; + import java.time.LocalDateTime; import java.time.ZoneOffset; import java.time.temporal.ChronoUnit; import java.time.temporal.TemporalUnit; import java.util.Optional; +import java.util.concurrent.TimeUnit; +@Getter public enum NoSqlTsPartitionDate { MINUTES("yyyy-MM-dd-HH-mm", ChronoUnit.MINUTES), HOURS("yyyy-MM-dd-HH", ChronoUnit.HOURS), DAYS("yyyy-MM-dd", ChronoUnit.DAYS), MONTHS("yyyy-MM", ChronoUnit.MONTHS), YEARS("yyyy", ChronoUnit.YEARS),INDEFINITE("",ChronoUnit.FOREVER); private final String pattern; private final transient TemporalUnit truncateUnit; + private final transient long durationMs; public final static LocalDateTime EPOCH_START = LocalDateTime.ofEpochSecond(0,0, ZoneOffset.UTC); NoSqlTsPartitionDate(String pattern, TemporalUnit truncateUnit) { this.pattern = pattern; this.truncateUnit = truncateUnit; - } - - - public String getPattern() { - return pattern; - } - - public TemporalUnit getTruncateUnit() { - return truncateUnit; + this.durationMs = TimeUnit.SECONDS.toMillis(this.truncateUnit.getDuration().getSeconds()); } public LocalDateTime truncatedTo(LocalDateTime time) { diff --git a/dao/src/test/java/org/thingsboard/server/dao/timeseries/CassandraBaseTimeseriesDaoPartitioningMinutesAlwaysExistsTest.java b/dao/src/test/java/org/thingsboard/server/dao/timeseries/CassandraBaseTimeseriesDaoPartitioningMinutesAlwaysExistsTest.java index cc76484d0b..8ec8c493af 100644 --- a/dao/src/test/java/org/thingsboard/server/dao/timeseries/CassandraBaseTimeseriesDaoPartitioningMinutesAlwaysExistsTest.java +++ b/dao/src/test/java/org/thingsboard/server/dao/timeseries/CassandraBaseTimeseriesDaoPartitioningMinutesAlwaysExistsTest.java @@ -117,4 +117,16 @@ public class CassandraBaseTimeseriesDaoPartitioningMinutesAlwaysExistsTest { ISO_8601_EXTENDED_DATETIME_TIME_ZONE_FORMAT.parse("2022-10-10T00:10:00Z").getTime())); } + @Test + public void testEstimatePartitionCount() throws ParseException { + assertThat(tsDao.estimatePartitionCount(0, Long.MAX_VALUE)).as("centuries").isEqualTo(153_722_867_280_914L); + assertThat(tsDao.estimatePartitionCount(0, 0)).as("single").isEqualTo(1L); + long startTs = tsDao.toPartitionTs( + ISO_8601_EXTENDED_DATETIME_TIME_ZONE_FORMAT.parse("2019-12-12T00:00:00Z").getTime()); + long endTs = tsDao.toPartitionTs( + ISO_8601_EXTENDED_DATETIME_TIME_ZONE_FORMAT.parse("2021-01-31T23:59:59Z").getTime()); + assertThat(tsDao.estimatePartitionCount(startTs, endTs)).as("600,479 minutes + 2 spare periods").isEqualTo(600479 + 2); + assertThat(tsDao.estimatePartitionCount(endTs, startTs)).as("wrong period estimated as 1").isEqualTo(1L); + } + } diff --git a/dao/src/test/java/org/thingsboard/server/dao/timeseries/CassandraBaseTimeseriesDaoPartitioningMonthsAlwaysExistsTest.java b/dao/src/test/java/org/thingsboard/server/dao/timeseries/CassandraBaseTimeseriesDaoPartitioningMonthsAlwaysExistsTest.java index eb60000404..65ed8019c0 100644 --- a/dao/src/test/java/org/thingsboard/server/dao/timeseries/CassandraBaseTimeseriesDaoPartitioningMonthsAlwaysExistsTest.java +++ b/dao/src/test/java/org/thingsboard/server/dao/timeseries/CassandraBaseTimeseriesDaoPartitioningMonthsAlwaysExistsTest.java @@ -15,25 +15,35 @@ */ package org.thingsboard.server.dao.timeseries; +import com.google.common.util.concurrent.ListenableFuture; import lombok.extern.slf4j.Slf4j; import org.junit.Test; import org.junit.runner.RunWith; import org.mockito.Answers; -import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Qualifier; import org.springframework.boot.test.context.SpringBootTest; import org.springframework.boot.test.mock.mockito.MockBean; import org.springframework.test.context.TestPropertySource; +import org.springframework.test.context.bean.override.mockito.MockitoSpyBean; import org.springframework.test.context.junit4.SpringRunner; +import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.common.data.kv.TsKvQuery; import org.thingsboard.server.dao.cassandra.CassandraCluster; import org.thingsboard.server.dao.nosql.CassandraBufferedRateReadExecutor; import org.thingsboard.server.dao.nosql.CassandraBufferedRateWriteExecutor; import java.text.ParseException; import java.util.List; +import java.util.UUID; import static org.apache.commons.lang3.time.DateFormatUtils.ISO_8601_EXTENDED_DATETIME_TIME_ZONE_FORMAT; import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.BDDMockito.willReturn; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; @RunWith(SpringRunner.class) @SpringBootTest(classes = CassandraBaseTimeseriesDao.class) @@ -50,7 +60,7 @@ import static org.assertj.core.api.Assertions.assertThat; @Slf4j public class CassandraBaseTimeseriesDaoPartitioningMonthsAlwaysExistsTest { - @Autowired + @MockitoSpyBean CassandraBaseTimeseriesDao tsDao; @MockBean(answer = Answers.RETURNS_MOCKS) @@ -131,4 +141,53 @@ public class CassandraBaseTimeseriesDaoPartitioningMonthsAlwaysExistsTest { ISO_8601_EXTENDED_DATETIME_TIME_ZONE_FORMAT.parse("2021-01-01T00:00:00Z").getTime())); } + @Test + public void testEstimatePartitionCount() throws ParseException { + assertThat(tsDao.estimatePartitionCount(0, Long.MAX_VALUE)).as("centuries").isEqualTo(3_507_324_297L); + assertThat(tsDao.estimatePartitionCount(0, 0)).as("single").isEqualTo(1L); + long startTs = tsDao.toPartitionTs( + ISO_8601_EXTENDED_DATETIME_TIME_ZONE_FORMAT.parse("2019-12-12T00:00:00Z").getTime()); + long endTs = tsDao.toPartitionTs( + ISO_8601_EXTENDED_DATETIME_TIME_ZONE_FORMAT.parse("2021-01-31T23:59:59Z").getTime()); + assertThat(tsDao.estimatePartitionCount(startTs, endTs)).as("13 month + 2 spare periods").isEqualTo(13 + 2); + assertThat(tsDao.estimatePartitionCount(endTs, startTs)).as("wrong period estimated as 1").isEqualTo(1L); + } + + @Test + public void testGetPartitionsFutureModeratePartitionsCount() throws ParseException { + TenantId tenantId = TenantId.fromUUID(UUID.randomUUID()); + TsKvQuery query = mock(TsKvQuery.class); + long startTs = tsDao.toPartitionTs( + ISO_8601_EXTENDED_DATETIME_TIME_ZONE_FORMAT.parse("2019-12-12T00:00:00Z").getTime()); + long endTs = tsDao.toPartitionTs( + ISO_8601_EXTENDED_DATETIME_TIME_ZONE_FORMAT.parse("2021-01-31T23:59:59Z").getTime()); + + willReturn(mock(ListenableFuture.class)).given(tsDao).getPartitionsFromDB(tenantId, query, tenantId, startTs, endTs); + + tsDao.getPartitionsFuture(tenantId, query, tenantId, startTs, endTs); + + verify(tsDao).estimatePartitionCount(startTs, endTs); + verify(tsDao).calculatePartitions(eq(startTs), eq(endTs), anyInt()); + verify(tsDao, never()).getPartitionsFromDB(tenantId, query, tenantId, startTs, endTs); + } + + @Test + public void testGetPartitionsFutureHugePartitionsCountPreventOOMFallbackToDB() throws ParseException { + + TenantId tenantId = TenantId.fromUUID(UUID.randomUUID()); + TsKvQuery query = mock(TsKvQuery.class); + long startTs = tsDao.toPartitionTs( + ISO_8601_EXTENDED_DATETIME_TIME_ZONE_FORMAT.parse("2000-12-12T00:00:00Z").getTime()); + long endTs = tsDao.toPartitionTs( + ISO_8601_EXTENDED_DATETIME_TIME_ZONE_FORMAT.parse("3000-01-31T23:59:59Z").getTime()); + + willReturn(mock(ListenableFuture.class)).given(tsDao).getPartitionsFromDB(tenantId, query, tenantId, startTs, endTs); + + tsDao.getPartitionsFuture(tenantId, query, tenantId, startTs, endTs); + + verify(tsDao).estimatePartitionCount(startTs, endTs); + verify(tsDao, never()).calculatePartitions(eq(startTs), eq(endTs), anyInt()); + verify(tsDao).getPartitionsFromDB(tenantId, query, tenantId, startTs, endTs); + } + } diff --git a/dao/src/test/java/org/thingsboard/server/dao/timeseries/CassandraBaseTimeseriesDaoPartitioningYearsAlwaysExistsTest.java b/dao/src/test/java/org/thingsboard/server/dao/timeseries/CassandraBaseTimeseriesDaoPartitioningYearsAlwaysExistsTest.java index 0425b23a1e..aa3e48c73f 100644 --- a/dao/src/test/java/org/thingsboard/server/dao/timeseries/CassandraBaseTimeseriesDaoPartitioningYearsAlwaysExistsTest.java +++ b/dao/src/test/java/org/thingsboard/server/dao/timeseries/CassandraBaseTimeseriesDaoPartitioningYearsAlwaysExistsTest.java @@ -112,4 +112,16 @@ public class CassandraBaseTimeseriesDaoPartitioningYearsAlwaysExistsTest { ISO_8601_EXTENDED_DATETIME_TIME_ZONE_FORMAT.parse("2025-01-01T00:00:00Z").getTime())); } + @Test + public void testEstimatePartitionCount() throws ParseException { + assertThat(tsDao.estimatePartitionCount(0, Long.MAX_VALUE)).as("centuries").isEqualTo(292_277_026L); + assertThat(tsDao.estimatePartitionCount(0, 0)).as("single").isEqualTo(1L); + long startTs = tsDao.toPartitionTs( + ISO_8601_EXTENDED_DATETIME_TIME_ZONE_FORMAT.parse("2019-12-12T00:00:00Z").getTime()); + long endTs = tsDao.toPartitionTs( + ISO_8601_EXTENDED_DATETIME_TIME_ZONE_FORMAT.parse("2021-01-31T23:59:59Z").getTime()); + assertThat(tsDao.estimatePartitionCount(startTs, endTs)).as("2 years + 2 spare periods").isEqualTo(2 + 2); + assertThat(tsDao.estimatePartitionCount(endTs, startTs)).as("wrong period estimated as 1").isEqualTo(1L); + } + } diff --git a/dao/src/test/java/org/thingsboard/server/dao/timeseries/NoSqlTsPartitionDateTest.java b/dao/src/test/java/org/thingsboard/server/dao/timeseries/NoSqlTsPartitionDateTest.java new file mode 100644 index 0000000000..f860d6ad51 --- /dev/null +++ b/dao/src/test/java/org/thingsboard/server/dao/timeseries/NoSqlTsPartitionDateTest.java @@ -0,0 +1,41 @@ +/** + * 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.dao.timeseries; + +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.EnumSource; + +import static org.assertj.core.api.Assertions.assertThat; + +class NoSqlTsPartitionDateTest { + + @ParameterizedTest + @EnumSource(NoSqlTsPartitionDate.class) + void getDurationMsTest(NoSqlTsPartitionDate tsPartitionDate) throws Exception { + final Long durationMs = switch (tsPartitionDate) { + case MINUTES -> 60000L; + case HOURS -> 3600000L; + case DAYS -> 86400000L; + case MONTHS -> 2629746000L; + case YEARS -> 31556952000L; + case INDEFINITE -> Long.MAX_VALUE; + default -> null; //should be here in case a new enum value will be added in future + }; + assertThat(durationMs).isNotNull(); + assertThat(tsPartitionDate.getDurationMs()).isEqualTo(durationMs); + } + +} From 2c06aa475f3241bc474a9ce8aee46490eec02bc3 Mon Sep 17 00:00:00 2001 From: dshvaika Date: Tue, 30 Sep 2025 16:26:46 +0300 Subject: [PATCH 055/102] geofencing cf bugfixes --- .../main/data/upgrade/basic/schema_update.sql | 2 +- ...ulatedFieldDynamicArgumentsRefreshMsg.java | 35 -------- .../CalculatedFieldEntityActor.java | 3 - ...CalculatedFieldEntityMessageProcessor.java | 50 +++++------ .../CalculatedFieldManagerActor.java | 3 - ...alculatedFieldManagerMessageProcessor.java | 62 ------------- ...ulatedFieldDynamicArgumentsRefreshMsg.java | 37 -------- ...tractCalculatedFieldProcessingService.java | 4 + .../ctx/state/BaseCalculatedFieldState.java | 4 +- .../cf/ctx/state/CalculatedFieldCtx.java | 42 +++++++-- .../cf/ctx/state/CalculatedFieldState.java | 7 -- .../ctx/state/SimpleCalculatedFieldState.java | 5 +- .../geofencing/GeofencingArgumentEntry.java | 10 ++- .../GeofencingCalculatedFieldState.java | 87 ++++++++----------- .../cf/CalculatedFieldIntegrationTest.java | 10 ++- .../server/controller/AbstractWebTest.java | 12 +-- .../GeofencingCalculatedFieldStateTest.java | 6 +- .../state/SimpleCalculatedFieldStateTest.java | 3 +- ...SupportedCalculatedFieldConfiguration.java | 3 - ...eofencingCalculatedFieldConfiguration.java | 7 +- .../DefaultTenantProfileConfiguration.java | 2 +- ...ncingCalculatedFieldConfigurationTest.java | 27 ------ .../server/common/msg/MsgType.java | 5 +- .../service/CalculatedFieldServiceTest.java | 72 ++++----------- 24 files changed, 148 insertions(+), 350 deletions(-) delete mode 100644 application/src/main/java/org/thingsboard/server/actors/calculatedField/CalculatedFieldDynamicArgumentsRefreshMsg.java delete mode 100644 application/src/main/java/org/thingsboard/server/actors/calculatedField/EntityCalculatedFieldDynamicArgumentsRefreshMsg.java diff --git a/application/src/main/data/upgrade/basic/schema_update.sql b/application/src/main/data/upgrade/basic/schema_update.sql index 320d3e5bdd..0add4c0545 100644 --- a/application/src/main/data/upgrade/basic/schema_update.sql +++ b/application/src/main/data/upgrade/basic/schema_update.sql @@ -27,7 +27,7 @@ SET profile_data = jsonb_set( CASE WHEN (profile_data -> 'configuration') ? 'minAllowedScheduledUpdateIntervalInSecForCF' THEN NULL - ELSE to_jsonb(3600) + ELSE to_jsonb(60) END, 'maxRelationLevelPerCfArgument', CASE diff --git a/application/src/main/java/org/thingsboard/server/actors/calculatedField/CalculatedFieldDynamicArgumentsRefreshMsg.java b/application/src/main/java/org/thingsboard/server/actors/calculatedField/CalculatedFieldDynamicArgumentsRefreshMsg.java deleted file mode 100644 index 301fe22dfb..0000000000 --- a/application/src/main/java/org/thingsboard/server/actors/calculatedField/CalculatedFieldDynamicArgumentsRefreshMsg.java +++ /dev/null @@ -1,35 +0,0 @@ -/** - * 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.actors.calculatedField; - -import lombok.Data; -import org.thingsboard.server.common.data.id.CalculatedFieldId; -import org.thingsboard.server.common.data.id.TenantId; -import org.thingsboard.server.common.msg.MsgType; -import org.thingsboard.server.common.msg.ToCalculatedFieldSystemMsg; - -@Data -public class CalculatedFieldDynamicArgumentsRefreshMsg implements ToCalculatedFieldSystemMsg { - - private final TenantId tenantId; - private final CalculatedFieldId cfId; - - @Override - public MsgType getMsgType() { - return MsgType.CF_DYNAMIC_ARGUMENTS_REFRESH_MSG; - } - -} diff --git a/application/src/main/java/org/thingsboard/server/actors/calculatedField/CalculatedFieldEntityActor.java b/application/src/main/java/org/thingsboard/server/actors/calculatedField/CalculatedFieldEntityActor.java index 2a5f3c3cfd..c57984ef3d 100644 --- a/application/src/main/java/org/thingsboard/server/actors/calculatedField/CalculatedFieldEntityActor.java +++ b/application/src/main/java/org/thingsboard/server/actors/calculatedField/CalculatedFieldEntityActor.java @@ -75,9 +75,6 @@ public class CalculatedFieldEntityActor extends AbstractCalculatedFieldActor { case CF_LINKED_TELEMETRY_MSG: processor.process((EntityCalculatedFieldLinkedTelemetryMsg) msg); break; - case CF_ENTITY_DYNAMIC_ARGUMENTS_REFRESH_MSG: - processor.process((EntityCalculatedFieldDynamicArgumentsRefreshMsg) msg); - break; default: return false; } 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 7513ca41e2..f8b61a082f 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 @@ -49,6 +49,7 @@ import org.thingsboard.server.service.cf.ctx.state.CalculatedFieldCtx; import org.thingsboard.server.service.cf.ctx.state.CalculatedFieldState; import org.thingsboard.server.service.cf.ctx.state.SingleValueArgumentEntry; import org.thingsboard.server.service.cf.ctx.state.geofencing.GeofencingArgumentEntry; +import org.thingsboard.server.service.cf.ctx.state.geofencing.GeofencingCalculatedFieldState; import java.util.ArrayList; import java.util.Collection; @@ -227,18 +228,6 @@ public class CalculatedFieldEntityMessageProcessor extends AbstractContextAwareM } } - public void process(EntityCalculatedFieldDynamicArgumentsRefreshMsg msg) throws CalculatedFieldException { - log.debug("[{}][{}] Processing CF dynamic arguments refresh msg.", entityId, msg.getCfId()); - CalculatedFieldState currentState = states.get(msg.getCfId()); - if (currentState == null) { - log.debug("[{}][{}] Failed to find CF state for entity.", entityId, msg.getCfId()); - } else { - currentState.setDirty(true); - log.debug("[{}][{}] CF state marked as dirty.", entityId, msg.getCfId()); - } - msg.getCallback().onSuccess(); - } - private void processTelemetry(CalculatedFieldCtx ctx, CalculatedFieldTelemetryMsgProto proto, List cfIdList, MultipleTbCallback callback) throws CalculatedFieldException { processArgumentValuesUpdate(ctx, cfIdList, callback, mapToArguments(ctx, proto.getTsDataList()), toTbMsgId(proto), toTbMsgType(proto)); } @@ -266,12 +255,13 @@ public class CalculatedFieldEntityMessageProcessor extends AbstractContextAwareM if (state == null) { state = getOrInitState(ctx); justRestored = true; - } else if (state.isDirty()) { - log.debug("[{}][{}] Going to update dirty CF state.", entityId, ctx.getCfId()); + } else if (ctx.shouldFetchDynamicArgumentsFromDb(state)) { + log.debug("[{}][{}] Going to update dynamic arguments for CF.", entityId, ctx.getCfId()); try { Map dynamicArgsFromDb = cfService.fetchDynamicArgsFromDb(ctx, entityId); dynamicArgsFromDb.forEach(newArgValues::putIfAbsent); - state.setDirty(false); + var geofencingState = (GeofencingCalculatedFieldState) state; + geofencingState.setLastDynamicArgumentsRefreshTs(System.currentTimeMillis()); } catch (Exception e) { throw CalculatedFieldException.builder().ctx(ctx).eventEntity(entityId).cause(e).build(); } @@ -403,7 +393,7 @@ public class CalculatedFieldEntityMessageProcessor extends AbstractContextAwareM return mapToArguments(entityId, argNames, geofencingArgumentNames, scope, attrDataList); } - private Map mapToArguments(EntityId entityId, Map argNames, List geoArgNames, AttributeScopeProto scope, List attrDataList) { + private Map mapToArguments(EntityId entityId, Map argNames, List geofencingArgNames, AttributeScopeProto scope, List attrDataList) { Map arguments = new HashMap<>(); for (AttributeValueProto item : attrDataList) { ReferencedEntityKey key = new ReferencedEntityKey(item.getKey(), ArgumentType.ATTRIBUTE, AttributeScope.valueOf(scope.name())); @@ -411,7 +401,7 @@ public class CalculatedFieldEntityMessageProcessor extends AbstractContextAwareM if (argName == null) { continue; } - if (geoArgNames.contains(argName)) { + if (geofencingArgNames.contains(argName)) { arguments.put(argName, new GeofencingArgumentEntry(entityId, item)); continue; } @@ -425,26 +415,32 @@ public class CalculatedFieldEntityMessageProcessor extends AbstractContextAwareM if (argNames.isEmpty()) { return Collections.emptyMap(); } - return mapToArgumentsWithDefaultValue(argNames, ctx.getArguments(), scope, removedAttrKeys); + List geofencingArgumentNames = ctx.getLinkedEntityGeofencingArgumentNames(); + return mapToArgumentsWithDefaultValue(argNames, ctx.getArguments(), geofencingArgumentNames, scope, removedAttrKeys); } private Map mapToArgumentsWithDefaultValue(CalculatedFieldCtx ctx, AttributeScopeProto scope, List removedAttrKeys) { - return mapToArgumentsWithDefaultValue(ctx.getMainEntityArguments(), ctx.getArguments(), scope, removedAttrKeys); + return mapToArgumentsWithDefaultValue(ctx.getMainEntityArguments(), ctx.getArguments(), ctx.getMainEntityGeofencingArgumentNames(), scope, removedAttrKeys); } - private Map mapToArgumentsWithDefaultValue(Map argNames, Map configArguments, AttributeScopeProto scope, List removedAttrKeys) { + private Map mapToArgumentsWithDefaultValue(Map argNames, Map configArguments, List geofencingArgNames, AttributeScopeProto scope, List removedAttrKeys) { Map arguments = new HashMap<>(); for (String removedKey : removedAttrKeys) { ReferencedEntityKey key = new ReferencedEntityKey(removedKey, ArgumentType.ATTRIBUTE, AttributeScope.valueOf(scope.name())); String argName = argNames.get(key); - if (argName != null) { - Argument argument = configArguments.get(argName); - String defaultValue = (argument != null) ? argument.getDefaultValue() : null; - arguments.put(argName, StringUtils.isNotEmpty(defaultValue) - ? new SingleValueArgumentEntry(System.currentTimeMillis(), new StringDataEntry(removedKey, defaultValue), null) - : new SingleValueArgumentEntry()); - + if (argName == null) { + continue; } + if (geofencingArgNames.contains(argName)) { + arguments.put(argName, new GeofencingArgumentEntry()); + continue; + } + Argument argument = configArguments.get(argName); + String defaultValue = (argument != null) ? argument.getDefaultValue() : null; + arguments.put(argName, StringUtils.isNotEmpty(defaultValue) + ? new SingleValueArgumentEntry(System.currentTimeMillis(), new StringDataEntry(removedKey, defaultValue), null) + : new SingleValueArgumentEntry()); + } return arguments; } diff --git a/application/src/main/java/org/thingsboard/server/actors/calculatedField/CalculatedFieldManagerActor.java b/application/src/main/java/org/thingsboard/server/actors/calculatedField/CalculatedFieldManagerActor.java index ab6cb34176..7d2ae0ff44 100644 --- a/application/src/main/java/org/thingsboard/server/actors/calculatedField/CalculatedFieldManagerActor.java +++ b/application/src/main/java/org/thingsboard/server/actors/calculatedField/CalculatedFieldManagerActor.java @@ -79,9 +79,6 @@ public class CalculatedFieldManagerActor extends AbstractCalculatedFieldActor { case CF_LINKED_TELEMETRY_MSG: processor.onLinkedTelemetryMsg((CalculatedFieldLinkedTelemetryMsg) msg); break; - case CF_DYNAMIC_ARGUMENTS_REFRESH_MSG: - processor.onDynamicArgumentsRefreshMsg((CalculatedFieldDynamicArgumentsRefreshMsg) msg); - break; default: return false; } diff --git a/application/src/main/java/org/thingsboard/server/actors/calculatedField/CalculatedFieldManagerMessageProcessor.java b/application/src/main/java/org/thingsboard/server/actors/calculatedField/CalculatedFieldManagerMessageProcessor.java index 75ca0b4c9b..7a76cb9821 100644 --- a/application/src/main/java/org/thingsboard/server/actors/calculatedField/CalculatedFieldManagerMessageProcessor.java +++ b/application/src/main/java/org/thingsboard/server/actors/calculatedField/CalculatedFieldManagerMessageProcessor.java @@ -27,7 +27,6 @@ import org.thingsboard.server.common.data.EntityType; import org.thingsboard.server.common.data.ProfileEntityIdInfo; import org.thingsboard.server.common.data.cf.CalculatedField; import org.thingsboard.server.common.data.cf.CalculatedFieldLink; -import org.thingsboard.server.common.data.cf.configuration.ScheduledUpdateSupportedCalculatedFieldConfiguration; import org.thingsboard.server.common.data.id.AssetId; import org.thingsboard.server.common.data.id.CalculatedFieldId; import org.thingsboard.server.common.data.id.DeviceId; @@ -57,10 +56,7 @@ import java.util.Collections; import java.util.HashMap; import java.util.List; import java.util.Map; -import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.CopyOnWriteArrayList; -import java.util.concurrent.ScheduledFuture; -import java.util.concurrent.TimeUnit; import java.util.function.BiConsumer; import static org.thingsboard.server.utils.CalculatedFieldUtils.fromProto; @@ -74,7 +70,6 @@ public class CalculatedFieldManagerMessageProcessor extends AbstractContextAware private final Map calculatedFields = new HashMap<>(); private final Map> entityIdCalculatedFields = new HashMap<>(); private final Map> entityIdCalculatedFieldLinks = new HashMap<>(); - private final Map> cfDynamicArgumentsRefreshTasks = new ConcurrentHashMap<>(); private final CalculatedFieldProcessingService cfExecService; private final CalculatedFieldStateService cfStateService; @@ -113,8 +108,6 @@ public class CalculatedFieldManagerMessageProcessor extends AbstractContextAware calculatedFields.clear(); entityIdCalculatedFields.clear(); entityIdCalculatedFieldLinks.clear(); - cfDynamicArgumentsRefreshTasks.values().forEach(future -> future.cancel(true)); - cfDynamicArgumentsRefreshTasks.clear(); ctx.stop(ctx.getSelf()); } @@ -274,7 +267,6 @@ public class CalculatedFieldManagerMessageProcessor extends AbstractContextAware // Alternative approach would be to use any list but avoid modifications to the list (change the complete map value instead) entityIdCalculatedFields.computeIfAbsent(cf.getEntityId(), id -> new CopyOnWriteArrayList<>()).add(cfCtx); addLinks(cf); - scheduleDynamicArgumentsRefreshTaskForCfIfNeeded(cfCtx); applyToTargetCfEntityActors(cfCtx, callback, (id, cb) -> initCfForEntity(id, cfCtx, false, cb)); } } @@ -304,12 +296,6 @@ public class CalculatedFieldManagerMessageProcessor extends AbstractContextAware calculatedFields.put(newCf.getId(), newCfCtx); List oldCfList = entityIdCalculatedFields.get(newCf.getEntityId()); - boolean hasSchedulingConfigChanges = newCfCtx.hasSchedulingConfigChanges(oldCfCtx); - if (hasSchedulingConfigChanges) { - cancelCfDynamicArgumentsRefreshTaskIfExists(cfId, false); - scheduleDynamicArgumentsRefreshTaskForCfIfNeeded(newCfCtx); - } - List newCfList = new CopyOnWriteArrayList<>(); boolean found = false; for (CalculatedFieldCtx oldCtx : oldCfList) { @@ -350,19 +336,9 @@ public class CalculatedFieldManagerMessageProcessor extends AbstractContextAware } entityIdCalculatedFields.get(cfCtx.getEntityId()).remove(cfCtx); deleteLinks(cfCtx); - cancelCfDynamicArgumentsRefreshTaskIfExists(cfId, true); applyToTargetCfEntityActors(cfCtx, callback, (id, cb) -> deleteCfForEntity(id, cfId, cb)); } - private void cancelCfDynamicArgumentsRefreshTaskIfExists(CalculatedFieldId cfId, boolean cfDeleted) { - var existingTask = cfDynamicArgumentsRefreshTasks.remove(cfId); - if (existingTask != null) { - existingTask.cancel(false); - String reason = cfDeleted ? "deletion" : "update"; - log.debug("[{}][{}] Cancelled dynamic arguments refresh task due to CF {}!", tenantId, cfId, reason); - } - } - public void onTelemetryMsg(CalculatedFieldTelemetryMsg msg) { EntityId entityId = msg.getEntityId(); log.debug("Received telemetry msg from entity [{}]", entityId); @@ -442,43 +418,6 @@ public class CalculatedFieldManagerMessageProcessor extends AbstractContextAware return result; } - private void scheduleDynamicArgumentsRefreshTaskForCfIfNeeded(CalculatedFieldCtx cfCtx) { - CalculatedField cf = cfCtx.getCalculatedField(); - if (!(cf.getConfiguration() instanceof ScheduledUpdateSupportedCalculatedFieldConfiguration scheduledCfConfig)) { - return; - } - if (!scheduledCfConfig.isScheduledUpdateEnabled()) { - return; - } - if (cfDynamicArgumentsRefreshTasks.containsKey(cf.getId())) { - log.debug("[{}][{}] Dynamic arguments refresh task for CF already exists!", tenantId, cf.getId()); - return; - } - long refreshDynamicSourceInterval = TimeUnit.SECONDS.toMillis(scheduledCfConfig.getScheduledUpdateInterval()); - var scheduledMsg = new CalculatedFieldDynamicArgumentsRefreshMsg(tenantId, cfCtx.getCfId()); - - ScheduledFuture scheduledFuture = systemContext - .schedulePeriodicMsgWithDelay(ctx, scheduledMsg, refreshDynamicSourceInterval, refreshDynamicSourceInterval); - cfDynamicArgumentsRefreshTasks.put(cf.getId(), scheduledFuture); - log.debug("[{}][{}] Scheduled dynamic arguments refresh task for CF!", tenantId, cf.getId()); - } - - public void onDynamicArgumentsRefreshMsg(CalculatedFieldDynamicArgumentsRefreshMsg msg) { - log.debug("[{}] [{}] Processing CF dynamic arguments refresh task.", tenantId, msg.getCfId()); - CalculatedFieldCtx cfCtx = calculatedFields.get(msg.getCfId()); - if (cfCtx == null) { - log.debug("[{}][{}] Failed to find CF context, going to stop dynamic arguments refresh task for CF.", tenantId, msg.getCfId()); - cancelCfDynamicArgumentsRefreshTaskIfExists(msg.getCfId(), true); - return; - } - applyToTargetCfEntityActors(cfCtx, msg.getCallback(), (id, cb) -> refreshDynamicArgumentsForEntity(id, msg.getCfId(), cb)); - } - - private void refreshDynamicArgumentsForEntity(EntityId entityId, CalculatedFieldId cfId, TbCallback callback) { - log.debug("Pushing CF dynamic arguments refresh msg to specific actor [{}]", entityId); - getOrCreateActor(entityId).tell(new EntityCalculatedFieldDynamicArgumentsRefreshMsg(tenantId, cfId, callback)); - } - private void linkedTelemetryMsgForEntity(EntityId entityId, EntityCalculatedFieldLinkedTelemetryMsg msg) { log.debug("Pushing linked telemetry msg to specific actor [{}]", entityId); getOrCreateActor(entityId).tell(msg); @@ -565,7 +504,6 @@ public class CalculatedFieldManagerMessageProcessor extends AbstractContextAware // We use copy on write lists to safely pass the reference to another actor for the iteration. // Alternative approach would be to use any list but avoid modifications to the list (change the complete map value instead) entityIdCalculatedFields.computeIfAbsent(cf.getEntityId(), id -> new CopyOnWriteArrayList<>()).add(cfCtx); - scheduleDynamicArgumentsRefreshTaskForCfIfNeeded(cfCtx); } private void initCalculatedFieldLink(CalculatedFieldLink link) { diff --git a/application/src/main/java/org/thingsboard/server/actors/calculatedField/EntityCalculatedFieldDynamicArgumentsRefreshMsg.java b/application/src/main/java/org/thingsboard/server/actors/calculatedField/EntityCalculatedFieldDynamicArgumentsRefreshMsg.java deleted file mode 100644 index fdf864611f..0000000000 --- a/application/src/main/java/org/thingsboard/server/actors/calculatedField/EntityCalculatedFieldDynamicArgumentsRefreshMsg.java +++ /dev/null @@ -1,37 +0,0 @@ -/** - * 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.actors.calculatedField; - -import lombok.Data; -import org.thingsboard.server.common.data.id.CalculatedFieldId; -import org.thingsboard.server.common.data.id.TenantId; -import org.thingsboard.server.common.msg.MsgType; -import org.thingsboard.server.common.msg.ToCalculatedFieldSystemMsg; -import org.thingsboard.server.common.msg.queue.TbCallback; - -@Data -public class EntityCalculatedFieldDynamicArgumentsRefreshMsg implements ToCalculatedFieldSystemMsg { - - private final TenantId tenantId; - private final CalculatedFieldId cfId; - private final TbCallback callback; - - @Override - public MsgType getMsgType() { - return MsgType.CF_ENTITY_DYNAMIC_ARGUMENTS_REFRESH_MSG; - } - -} 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 45305ca9e3..8709e0cb68 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 @@ -45,6 +45,7 @@ import org.thingsboard.server.dao.usagerecord.ApiLimitService; import org.thingsboard.server.service.cf.ctx.state.ArgumentEntry; import org.thingsboard.server.service.cf.ctx.state.CalculatedFieldCtx; import org.thingsboard.server.service.cf.ctx.state.CalculatedFieldState; +import org.thingsboard.server.service.cf.ctx.state.geofencing.GeofencingCalculatedFieldState; import java.util.HashMap; import java.util.List; @@ -102,6 +103,9 @@ public abstract class AbstractCalculatedFieldProcessingService { return Futures.whenAllComplete(argFutures.values()).call(() -> { var result = createStateByType(ctx); result.updateState(ctx, resolveArgumentFutures(argFutures)); + if (ctx.hasRelationQueryDynamicArguments() && result instanceof GeofencingCalculatedFieldState geofencingCalculatedFieldState) { + geofencingCalculatedFieldState.setLastDynamicArgumentsRefreshTs(System.currentTimeMillis()); + } return result; }, MoreExecutors.directExecutor()); } 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 9a1d06cf24..6d877331bd 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 @@ -62,7 +62,7 @@ public abstract class BaseCalculatedFieldState implements CalculatedFieldState { boolean entryUpdated; if (existingEntry == null || newEntry.isForceResetPrevious()) { - validateNewEntry(newEntry); + validateNewEntry(key, newEntry); arguments.put(key, newEntry); entryUpdated = true; } else { @@ -93,7 +93,7 @@ public abstract class BaseCalculatedFieldState implements CalculatedFieldState { } } - protected void validateNewEntry(ArgumentEntry newEntry) {} + protected void validateNewEntry(String key, ArgumentEntry newEntry) {} private void updateLastUpdateTimestamp(ArgumentEntry entry) { long newTs = this.latestTimestamp; 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 c2cc083853..13012a028a 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 @@ -43,11 +43,13 @@ import org.thingsboard.server.dao.relation.RelationService; import org.thingsboard.server.dao.usagerecord.ApiLimitService; import org.thingsboard.server.gen.transport.TransportProtos.CalculatedFieldTelemetryMsgProto; import org.thingsboard.server.service.cf.ctx.CalculatedFieldEntityCtxId; +import org.thingsboard.server.service.cf.ctx.state.geofencing.GeofencingCalculatedFieldState; import java.util.ArrayList; import java.util.HashMap; import java.util.List; import java.util.Map; +import java.util.concurrent.TimeUnit; import static org.thingsboard.common.util.ExpressionFunctionsUtil.userDefinedFunctions; @@ -78,9 +80,12 @@ public class CalculatedFieldCtx { private long maxStateSize; private long maxSingleValueArgumentSize; + private boolean relationQueryDynamicArguments; private List mainEntityGeofencingArgumentNames; private List linkedEntityGeofencingArgumentNames; + private long scheduledUpdateIntervalMillis; + public CalculatedFieldCtx(CalculatedField calculatedField, TbelInvokeService tbelInvokeService, ApiLimitService apiLimitService, RelationService relationService) { this.calculatedField = calculatedField; @@ -101,6 +106,7 @@ public class CalculatedFieldCtx { var refId = entry.getValue().getRefEntityId(); var refKey = entry.getValue().getRefEntityKey(); if (refId == null && entry.getValue().hasDynamicSource()) { + relationQueryDynamicArguments = true; continue; } if (refId == null || refId.equals(calculatedField.getEntityId())) { @@ -126,6 +132,9 @@ public class CalculatedFieldCtx { }); } } + if (calculatedField.getConfiguration() instanceof ScheduledUpdateSupportedCalculatedFieldConfiguration scheduledConfig) { + this.scheduledUpdateIntervalMillis = scheduledConfig.isScheduledUpdateEnabled() ? TimeUnit.SECONDS.toMillis(scheduledConfig.getScheduledUpdateInterval()) : -1L; + } this.tbelInvokeService = tbelInvokeService; this.relationService = relationService; @@ -329,25 +338,42 @@ public class CalculatedFieldCtx { public boolean hasOtherSignificantChanges(CalculatedFieldCtx other) { boolean expressionChanged = calculatedField.getConfiguration() instanceof ExpressionBasedCalculatedFieldConfiguration && !expression.equals(other.expression); boolean outputChanged = !output.equals(other.output); - return expressionChanged || outputChanged; + boolean scheduledUpdatesConfigChanged = scheduledUpdateIntervalMillis != other.scheduledUpdateIntervalMillis; + return expressionChanged || outputChanged || scheduledUpdatesConfigChanged; } public boolean hasStateChanges(CalculatedFieldCtx other) { boolean typeChanged = !cfType.equals(other.cfType); boolean argumentsChanged = !arguments.equals(other.arguments); - return typeChanged || argumentsChanged; + boolean geoZoneGroupsConfigChanged = hasGeofencingZoneGroupConfigurationChanges(other); + return typeChanged || argumentsChanged || geoZoneGroupsConfigChanged; } - public boolean hasSchedulingConfigChanges(CalculatedFieldCtx other) { - if (calculatedField.getConfiguration() instanceof ScheduledUpdateSupportedCalculatedFieldConfiguration thisConfig - && other.calculatedField.getConfiguration() instanceof ScheduledUpdateSupportedCalculatedFieldConfiguration otherConfig) { - boolean refreshTriggerChanged = thisConfig.isScheduledUpdateEnabled() != otherConfig.isScheduledUpdateEnabled(); - boolean refreshIntervalChanged = thisConfig.getScheduledUpdateInterval() != otherConfig.getScheduledUpdateInterval(); - return refreshTriggerChanged || refreshIntervalChanged; + private boolean hasGeofencingZoneGroupConfigurationChanges(CalculatedFieldCtx other) { + if (calculatedField.getConfiguration() instanceof GeofencingCalculatedFieldConfiguration thisConfig + && other.calculatedField.getConfiguration() instanceof GeofencingCalculatedFieldConfiguration otherConfig) { + return !thisConfig.getZoneGroups().equals(otherConfig.getZoneGroups()); } return false; } + public boolean hasRelationQueryDynamicArguments() { + return relationQueryDynamicArguments && scheduledUpdateIntervalMillis != -1; + } + + public boolean shouldFetchDynamicArgumentsFromDb(CalculatedFieldState state) { + if (!hasRelationQueryDynamicArguments()) { + return false; + } + if (!(state instanceof GeofencingCalculatedFieldState geofencingState)) { + return false; + } + if (geofencingState.getLastDynamicArgumentsRefreshTs() == -1L) { + return true; + } + return geofencingState.getLastDynamicArgumentsRefreshTs() < System.currentTimeMillis() - scheduledUpdateIntervalMillis; + } + public String getSizeExceedsLimitMessage() { return "Failed to init CF state. State size exceeds limit of " + (maxStateSize / 1024) + "Kb!"; } 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 5f8e7538c4..3e0964bfd2 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 @@ -50,13 +50,6 @@ public interface CalculatedFieldState { long getLatestTimestamp(); - default void setDirty(boolean dirty) { - } - - default boolean isDirty() { - return false; - } - void setRequiredArguments(List requiredArguments); boolean updateState(CalculatedFieldCtx ctx, Map argumentValues); diff --git a/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/SimpleCalculatedFieldState.java b/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/SimpleCalculatedFieldState.java index 80b650fc7c..5a437b52f3 100644 --- a/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/SimpleCalculatedFieldState.java +++ b/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/SimpleCalculatedFieldState.java @@ -48,9 +48,10 @@ public class SimpleCalculatedFieldState extends BaseCalculatedFieldState { } @Override - protected void validateNewEntry(ArgumentEntry newEntry) { + protected void validateNewEntry(String key, ArgumentEntry newEntry) { if (newEntry instanceof TsRollingArgumentEntry) { - throw new IllegalArgumentException("Rolling argument entry is not supported for simple calculated fields."); + throw new IllegalArgumentException("Unsupported argument type detected for argument: " + key + ". " + + "Rolling argument entry is not supported for simple calculated fields."); } } diff --git a/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/geofencing/GeofencingArgumentEntry.java b/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/geofencing/GeofencingArgumentEntry.java index f526cc00ab..53e5c19e72 100644 --- a/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/geofencing/GeofencingArgumentEntry.java +++ b/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/geofencing/GeofencingArgumentEntry.java @@ -41,7 +41,7 @@ public class GeofencingArgumentEntry implements ArgumentEntry { } public GeofencingArgumentEntry(EntityId entityId, TransportProtos.AttributeValueProto entry) { - this.zoneStates = toZones(Map.of(entityId, ProtoUtils.fromProto(entry))); + this(Map.of(entityId, ProtoUtils.fromProto(entry))); } public GeofencingArgumentEntry(Map entityIdkvEntryMap) { @@ -63,6 +63,10 @@ public class GeofencingArgumentEntry implements ArgumentEntry { if (!(entry instanceof GeofencingArgumentEntry geofencingArgumentEntry)) { throw new IllegalArgumentException("Unsupported argument entry type for geofencing argument entry: " + entry.getType()); } + if (geofencingArgumentEntry.isEmpty()) { + zoneStates.clear(); + return true; + } boolean updated = false; for (var zoneEntry : geofencingArgumentEntry.getZoneStates().entrySet()) { if (updateZone(zoneEntry)) { @@ -97,6 +101,10 @@ public class GeofencingArgumentEntry implements ArgumentEntry { zoneStates.put(zoneId, newZoneState); return true; } + if (newZoneState.getPerimeterDefinition() == null) { + zoneStates.remove(zoneId); + return true; + } return existingZoneState.update(newZoneState); } 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 506ddcff78..398de0c20b 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 @@ -15,16 +15,19 @@ */ package org.thingsboard.server.service.cf.ctx.state.geofencing; +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; import com.google.common.util.concurrent.MoreExecutors; import lombok.Data; import lombok.EqualsAndHashCode; +import lombok.NoArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.thingsboard.common.util.JacksonUtil; 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.GeofencingReportStrategy; import org.thingsboard.server.common.data.cf.configuration.geofencing.GeofencingTransitionEvent; @@ -39,7 +42,6 @@ import org.thingsboard.server.service.cf.ctx.state.CalculatedFieldCtx; import org.thingsboard.server.service.cf.ctx.state.SingleValueArgumentEntry; import java.util.ArrayList; -import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.stream.Collectors; @@ -51,18 +53,14 @@ import static org.thingsboard.server.common.data.cf.configuration.geofencing.Geo @Data @Slf4j +@NoArgsConstructor @EqualsAndHashCode(callSuper = true) public class GeofencingCalculatedFieldState extends BaseCalculatedFieldState { - private boolean dirty; + private long lastDynamicArgumentsRefreshTs = -1; - public GeofencingCalculatedFieldState() { - super(new ArrayList<>(), new HashMap<>(), false, -1); - this.dirty = false; - } - - public GeofencingCalculatedFieldState(List argNames) { - super(argNames); + public GeofencingCalculatedFieldState(List requiredArguments) { + super(requiredArguments); } @Override @@ -71,49 +69,21 @@ public class GeofencingCalculatedFieldState extends BaseCalculatedFieldState { } @Override - public boolean updateState(CalculatedFieldCtx ctx, Map argumentValues) { - if (arguments == null) { - arguments = new HashMap<>(); - } - - boolean stateUpdated = false; - - for (var entry : argumentValues.entrySet()) { - String key = entry.getKey(); - ArgumentEntry newEntry = entry.getValue(); - - checkArgumentSize(key, newEntry, ctx); - - ArgumentEntry existingEntry = arguments.get(key); - boolean entryUpdated; - - if (existingEntry == null || newEntry.isForceResetPrevious()) { - entryUpdated = switch (key) { - case ENTITY_ID_LATITUDE_ARGUMENT_KEY, ENTITY_ID_LONGITUDE_ARGUMENT_KEY -> { - if (!(newEntry instanceof SingleValueArgumentEntry singleValueArgumentEntry)) { - throw new IllegalArgumentException("Unsupported argument entry type for " + key + " argument: " + newEntry.getType() + ". " + - "Only SINGLE_VALUE type is allowed."); - } - arguments.put(key, singleValueArgumentEntry); - yield true; - } - default -> { - if (!(newEntry instanceof GeofencingArgumentEntry geofencingArgumentEntry)) { - throw new IllegalArgumentException("Unsupported argument entry type for " + key + " argument: " + newEntry.getType() + ". " + - "Only GEOFENCING type is allowed."); - } - arguments.put(key, geofencingArgumentEntry); - yield true; - } - }; - } else { - entryUpdated = existingEntry.updateEntry(newEntry); + protected void validateNewEntry(String key, ArgumentEntry newEntry) { + switch (key) { + case ENTITY_ID_LATITUDE_ARGUMENT_KEY, ENTITY_ID_LONGITUDE_ARGUMENT_KEY -> { + if (!(newEntry instanceof SingleValueArgumentEntry)) { + throw new IllegalArgumentException("Unsupported argument entry type for " + key + " argument: " + newEntry.getType() + ". " + + "Only SINGLE_VALUE type is allowed."); + } } - if (entryUpdated) { - stateUpdated = true; + default -> { + if (!(newEntry instanceof GeofencingArgumentEntry)) { + throw new IllegalArgumentException("Unsupported argument entry type for " + key + " argument: " + newEntry.getType() + ". " + + "Only GEOFENCING type is allowed."); + } } } - return stateUpdated; } @Override @@ -125,7 +95,7 @@ public class GeofencingCalculatedFieldState extends BaseCalculatedFieldState { var geofencingCfg = (GeofencingCalculatedFieldConfiguration) ctx.getCalculatedField().getConfiguration(); Map zoneGroups = geofencingCfg.getZoneGroups(); - ObjectNode resultNode = JacksonUtil.newObjectNode(); + ObjectNode valuesNode = JacksonUtil.newObjectNode(); List> relationFutures = new ArrayList<>(); getGeofencingArguments().forEach((argumentKey, argumentEntry) -> { @@ -154,10 +124,11 @@ public class GeofencingCalculatedFieldState extends BaseCalculatedFieldState { relationFutures.add(f); } }); - updateResultNode(argumentKey, zoneResults, zoneGroupCfg.getReportStrategy(), resultNode); + updateValuesNode(argumentKey, zoneResults, zoneGroupCfg.getReportStrategy(), valuesNode); }); - var result = new CalculatedFieldResult(ctx.getOutput().getType(), ctx.getOutput().getScope(), resultNode); + OutputType outputType = ctx.getOutput().getType(); + var result = new CalculatedFieldResult(ctx.getOutput().getType(), ctx.getOutput().getScope(), toResultNode(outputType, valuesNode)); if (relationFutures.isEmpty()) { return Futures.immediateFuture(result); } @@ -171,7 +142,7 @@ public class GeofencingCalculatedFieldState extends BaseCalculatedFieldState { .collect(Collectors.toMap(Map.Entry::getKey, entry -> (GeofencingArgumentEntry) entry.getValue())); } - private void updateResultNode(String argumentKey, List zoneResults, GeofencingReportStrategy geofencingReportStrategy, ObjectNode resultNode) { + private void updateValuesNode(String argumentKey, List zoneResults, GeofencingReportStrategy geofencingReportStrategy, ObjectNode resultNode) { GeofencingEvalResult aggregationResult = aggregateZoneGroup(zoneResults); final String eventKey = argumentKey + "Event"; final String statusKey = argumentKey + "Status"; @@ -185,6 +156,16 @@ public class GeofencingCalculatedFieldState extends BaseCalculatedFieldState { } } + private JsonNode toResultNode(OutputType outputType, ObjectNode valuesNode) { + if (OutputType.ATTRIBUTES.equals(outputType) || latestTimestamp == -1) { + return valuesNode; + } + ObjectNode resultNode = JacksonUtil.newObjectNode(); + resultNode.put("ts", latestTimestamp); + resultNode.set("values", valuesNode); + return resultNode; + } + private GeofencingEvalResult aggregateZoneGroup(List zoneResults) { boolean nowInside = zoneResults.stream().anyMatch(r -> INSIDE.equals(r.status())); boolean prevInside = zoneResults.stream() 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 b6a1faa1ed..ffd876575c 100644 --- a/application/src/test/java/org/thingsboard/server/cf/CalculatedFieldIntegrationTest.java +++ b/application/src/test/java/org/thingsboard/server/cf/CalculatedFieldIntegrationTest.java @@ -831,10 +831,11 @@ public class CalculatedFieldIntegrationTest extends CalculatedFieldControllerTes TenantProfile foundTenantProfile = doGet("/api/tenantProfile/" + tenantProfileEntityInfo.getId().getId().toString(), TenantProfile.class); assertThat(foundTenantProfile).isNotNull(); assertThat(foundTenantProfile.getDefaultProfileConfiguration()).isNotNull(); - foundTenantProfile.getDefaultProfileConfiguration().setMinAllowedScheduledUpdateIntervalInSecForCF(TIMEOUT / 10); + int minAllowedScheduledUpdateIntervalInSecForCF = TIMEOUT / 10; + foundTenantProfile.getDefaultProfileConfiguration().setMinAllowedScheduledUpdateIntervalInSecForCF(minAllowedScheduledUpdateIntervalInSecForCF); TenantProfile savedTenantProfile = doPost("/api/tenantProfile", foundTenantProfile, TenantProfile.class); assertThat(savedTenantProfile).isNotNull(); - assertThat(savedTenantProfile.getDefaultProfileConfiguration().getMinAllowedScheduledUpdateIntervalInSecForCF()).isEqualTo(TIMEOUT / 10); + assertThat(savedTenantProfile.getDefaultProfileConfiguration().getMinAllowedScheduledUpdateIntervalInSecForCF()).isEqualTo(minAllowedScheduledUpdateIntervalInSecForCF); loginTenantAdmin(); // --- Arrange entities --- @@ -884,7 +885,8 @@ public class CalculatedFieldIntegrationTest extends CalculatedFieldControllerTes cfg.setOutput(out); // Enable scheduled refresh with a 6-second interval - cfg.setScheduledUpdateInterval(6); + cfg.setScheduledUpdateInterval(minAllowedScheduledUpdateIntervalInSecForCF); + cfg.setScheduledUpdateEnabled(true); cf.setConfiguration(cfg); CalculatedField savedCalculatedField = doPost("/api/calculatedField", cf, CalculatedField.class); @@ -935,7 +937,7 @@ public class CalculatedFieldIntegrationTest extends CalculatedFieldControllerTes relAllowedB.setType("AllowedZone"); doPost("/api/relation", relAllowedB).andExpect(status().isOk()); - awaitForCalculatedFieldEntityMessageProcessorToRegisterCfStateAsDirty(device.getId(), savedCalculatedField.getId()); + awaitForCalculatedFieldEntityMessageProcessorToRegisterCfStateAsReadyToRefreshDynamicArguments(device.getId(), savedCalculatedField.getId(), minAllowedScheduledUpdateIntervalInSecForCF); // --- Same coordinates as before, but now we expect ENTERED since a new zone is registered --- doPost("/api/plugins/telemetry/DEVICE/" + device.getUuidId() + "/timeseries/unusedScope", 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 fd01581e36..d7c9ad4590 100644 --- a/application/src/test/java/org/thingsboard/server/controller/AbstractWebTest.java +++ b/application/src/test/java/org/thingsboard/server/controller/AbstractWebTest.java @@ -155,6 +155,7 @@ import org.thingsboard.server.dao.timeseries.TimeseriesService; import org.thingsboard.server.queue.memory.InMemoryStorage; import org.thingsboard.server.service.cf.CfRocksDb; import org.thingsboard.server.service.cf.ctx.state.CalculatedFieldState; +import org.thingsboard.server.service.cf.ctx.state.geofencing.GeofencingCalculatedFieldState; import org.thingsboard.server.service.entitiy.tenant.profile.TbTenantProfileService; import org.thingsboard.server.service.security.auth.jwt.RefreshTokenRequest; import org.thingsboard.server.service.security.auth.rest.LoginRequest; @@ -1104,14 +1105,15 @@ public abstract class AbstractWebTest extends AbstractInMemoryStorageTest { }); } - protected void awaitForCalculatedFieldEntityMessageProcessorToRegisterCfStateAsDirty(EntityId entityId, CalculatedFieldId cfId) { + protected void awaitForCalculatedFieldEntityMessageProcessorToRegisterCfStateAsReadyToRefreshDynamicArguments(EntityId entityId, CalculatedFieldId cfId, int scheduledUpdateInterval) { CalculatedFieldEntityMessageProcessor processor = getCalculatedFieldEntityMessageProcessor(entityId); Map statesMap = (Map) ReflectionTestUtils.getField(processor, "states"); - Awaitility.await("CF state for entity actor marked as dirty").atMost(5, TimeUnit.SECONDS).until(() -> { + Awaitility.await("CF state for entity actor ready to refresh dynamic arguments").atMost(TIMEOUT, TimeUnit.SECONDS).until(() -> { CalculatedFieldState calculatedFieldState = statesMap.get(cfId); - boolean stateDirty = calculatedFieldState != null && calculatedFieldState.isDirty(); - log.warn("entityId {}, cfId {}, state dirty == {}", entityId, cfId, stateDirty); - return stateDirty; + boolean isReady = calculatedFieldState != null && ((GeofencingCalculatedFieldState) calculatedFieldState).getLastDynamicArgumentsRefreshTs() + < System.currentTimeMillis() - TimeUnit.SECONDS.toMillis(scheduledUpdateInterval); + log.warn("entityId {}, cfId {}, state ready to refresh == {}", entityId, cfId, isReady); + return isReady; }); } 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 691a1f7ec4..a86af77555 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 @@ -257,7 +257,7 @@ public class GeofencingCalculatedFieldStateTest { assertThat(result2).isNotNull(); assertThat(result2.getType()).isEqualTo(output.getType()); assertThat(result2.getScope()).isEqualTo(output.getScope()); - assertThat(result2.getResult()).isEqualTo( + assertThat(result2.getResult().get("values")).isEqualTo( JacksonUtil.newObjectNode() .put("allowedZonesEvent", "LEFT") .put("allowedZonesStatus", "OUTSIDE") @@ -329,7 +329,7 @@ public class GeofencingCalculatedFieldStateTest { assertThat(result2).isNotNull(); assertThat(result2.getType()).isEqualTo(output.getType()); assertThat(result2.getScope()).isEqualTo(output.getScope()); - assertThat(result2.getResult()).isEqualTo( + assertThat(result2.getResult().get("values")).isEqualTo( JacksonUtil.newObjectNode() .put("allowedZonesEvent", "LEFT") .put("restrictedZonesEvent", "ENTERED") @@ -401,7 +401,7 @@ public class GeofencingCalculatedFieldStateTest { assertThat(result2).isNotNull(); assertThat(result2.getType()).isEqualTo(output.getType()); assertThat(result2.getScope()).isEqualTo(output.getScope()); - assertThat(result2.getResult()).isEqualTo( + assertThat(result2.getResult().get("values")).isEqualTo( JacksonUtil.newObjectNode() .put("allowedZonesStatus", "OUTSIDE") .put("restrictedZonesStatus", "INSIDE") diff --git a/application/src/test/java/org/thingsboard/server/service/cf/ctx/state/SimpleCalculatedFieldStateTest.java b/application/src/test/java/org/thingsboard/server/service/cf/ctx/state/SimpleCalculatedFieldStateTest.java index 8c631ecf6f..3aef8896de 100644 --- a/application/src/test/java/org/thingsboard/server/service/cf/ctx/state/SimpleCalculatedFieldStateTest.java +++ b/application/src/test/java/org/thingsboard/server/service/cf/ctx/state/SimpleCalculatedFieldStateTest.java @@ -123,7 +123,8 @@ public class SimpleCalculatedFieldStateTest { Map newArgs = Map.of("key3", new TsRollingArgumentEntry(10, 30000L)); assertThatThrownBy(() -> state.updateState(ctx, newArgs)) .isInstanceOf(IllegalArgumentException.class) - .hasMessage("Rolling argument entry is not supported for simple calculated fields."); + .hasMessage("Unsupported argument type detected for argument: key3. " + + "Rolling argument entry is not supported for simple calculated fields."); } @Test diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/ScheduledUpdateSupportedCalculatedFieldConfiguration.java b/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/ScheduledUpdateSupportedCalculatedFieldConfiguration.java index 7902a9cf5b..d0c5786f62 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/ScheduledUpdateSupportedCalculatedFieldConfiguration.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/ScheduledUpdateSupportedCalculatedFieldConfiguration.java @@ -15,11 +15,8 @@ */ package org.thingsboard.server.common.data.cf.configuration; -import com.fasterxml.jackson.annotation.JsonIgnore; - public interface ScheduledUpdateSupportedCalculatedFieldConfiguration extends CalculatedFieldConfiguration { - @JsonIgnore boolean isScheduledUpdateEnabled(); int getScheduledUpdateInterval(); diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/geofencing/GeofencingCalculatedFieldConfiguration.java b/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/geofencing/GeofencingCalculatedFieldConfiguration.java index dc331f5876..b331abc50b 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/geofencing/GeofencingCalculatedFieldConfiguration.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/geofencing/GeofencingCalculatedFieldConfiguration.java @@ -34,6 +34,8 @@ public class GeofencingCalculatedFieldConfiguration implements ArgumentsBasedCal private EntityCoordinates entityCoordinates; private Map zoneGroups; + + private boolean scheduledUpdateEnabled; private int scheduledUpdateInterval; private Output output; @@ -61,11 +63,6 @@ public class GeofencingCalculatedFieldConfiguration implements ArgumentsBasedCal return output; } - @Override - public boolean isScheduledUpdateEnabled() { - return scheduledUpdateInterval > 0 && zoneGroups.values().stream().anyMatch(ZoneGroupConfiguration::hasDynamicSource); - } - @Override public void validate() { if (entityCoordinates == null) { 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 4c8b9e06bd..c6bd9a7f38 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 @@ -173,7 +173,7 @@ public class DefaultTenantProfileConfiguration implements TenantProfileConfigura @Schema(example = "10") private long maxArgumentsPerCF = 10; @Schema(example = "3600") - private int minAllowedScheduledUpdateIntervalInSecForCF = 3600; + private int minAllowedScheduledUpdateIntervalInSecForCF = 60; @Schema(example = "10") private int maxRelationLevelPerCfArgument = 10; @Builder.Default diff --git a/common/data/src/test/java/org/thingsboard/server/common/data/cf/configuration/geofencing/GeofencingCalculatedFieldConfigurationTest.java b/common/data/src/test/java/org/thingsboard/server/common/data/cf/configuration/geofencing/GeofencingCalculatedFieldConfigurationTest.java index 91a47aac57..c5fb1f8953 100644 --- a/common/data/src/test/java/org/thingsboard/server/common/data/cf/configuration/geofencing/GeofencingCalculatedFieldConfigurationTest.java +++ b/common/data/src/test/java/org/thingsboard/server/common/data/cf/configuration/geofencing/GeofencingCalculatedFieldConfigurationTest.java @@ -101,33 +101,6 @@ public class GeofencingCalculatedFieldConfigurationTest { verify(zoneGroupConfigurationB).validate(zoneGroupBName); } - @Test - void scheduledUpdateDisabledWhenIntervalIsZero() { - var cfg = new GeofencingCalculatedFieldConfiguration(); - cfg.setScheduledUpdateInterval(0); - assertThat(cfg.isScheduledUpdateEnabled()).isFalse(); - } - - @Test - void scheduledUpdateDisabledWhenIntervalIsGreaterThanZeroButNoZonesWithDynamicArguments() { - var cfg = new GeofencingCalculatedFieldConfiguration(); - var zoneGroupConfigurationMock = mock(ZoneGroupConfiguration.class); - when(zoneGroupConfigurationMock.hasDynamicSource()).thenReturn(false); - cfg.setZoneGroups(Map.of("someGroupName", zoneGroupConfigurationMock)); - cfg.setScheduledUpdateInterval(60); - assertThat(cfg.isScheduledUpdateEnabled()).isFalse(); - } - - @Test - void scheduledUpdateEnabledWhenIntervalIsGreaterThanZeroAndDynamicArgumentsPresent() { - var cfg = new GeofencingCalculatedFieldConfiguration(); - var zoneGroupConfigurationMock = mock(ZoneGroupConfiguration.class); - when(zoneGroupConfigurationMock.hasDynamicSource()).thenReturn(true); - cfg.setZoneGroups(Map.of("someGroupName", zoneGroupConfigurationMock)); - cfg.setScheduledUpdateInterval(60); - assertThat(cfg.isScheduledUpdateEnabled()).isTrue(); - } - @Test void testGetArgumentsOverride() { var cfg = new GeofencingCalculatedFieldConfiguration(); diff --git a/common/message/src/main/java/org/thingsboard/server/common/msg/MsgType.java b/common/message/src/main/java/org/thingsboard/server/common/msg/MsgType.java index 48b07af29b..20043582d7 100644 --- a/common/message/src/main/java/org/thingsboard/server/common/msg/MsgType.java +++ b/common/message/src/main/java/org/thingsboard/server/common/msg/MsgType.java @@ -147,10 +147,7 @@ public enum MsgType { /* CF Manager Actor -> CF Entity actor */ CF_ENTITY_TELEMETRY_MSG, CF_ENTITY_INIT_CF_MSG, - CF_ENTITY_DELETE_MSG, - - CF_DYNAMIC_ARGUMENTS_REFRESH_MSG, - CF_ENTITY_DYNAMIC_ARGUMENTS_REFRESH_MSG; + CF_ENTITY_DELETE_MSG; @Getter private final boolean ignoreOnStart; diff --git a/dao/src/test/java/org/thingsboard/server/dao/service/CalculatedFieldServiceTest.java b/dao/src/test/java/org/thingsboard/server/dao/service/CalculatedFieldServiceTest.java index 49bdcca0fb..7f563ea436 100644 --- a/dao/src/test/java/org/thingsboard/server/dao/service/CalculatedFieldServiceTest.java +++ b/dao/src/test/java/org/thingsboard/server/dao/service/CalculatedFieldServiceTest.java @@ -99,53 +99,7 @@ public class CalculatedFieldServiceTest extends AbstractServiceTest { } @Test - public void testSaveGeofencingCalculatedField_shouldNotChangeScheduledInterval() { - // Arrange a device - Device device = createTestDevice(); - - // Build a valid Geofencing configuration - GeofencingCalculatedFieldConfiguration cfg = new GeofencingCalculatedFieldConfiguration(); - - // Coordinates: TS_LATEST, no dynamic source - EntityCoordinates entityCoordinates = new EntityCoordinates("latitude", "longitude"); - cfg.setEntityCoordinates(entityCoordinates); - - // Zone-group argument (ATTRIBUTE) — no DYNAMIC configuration, so no scheduling even if the scheduled interval is set - ZoneGroupConfiguration zoneGroupConfiguration = new ZoneGroupConfiguration("allowed", REPORT_TRANSITION_EVENTS_AND_PRESENCE_STATUS, false); - zoneGroupConfiguration.setRefEntityId(device.getId()); - cfg.setZoneGroups(Map.of("allowed", zoneGroupConfiguration)); - - // Set a scheduled interval to some value - cfg.setScheduledUpdateInterval(600); - - // Create & save Calculated Field - CalculatedField cf = new CalculatedField(); - cf.setTenantId(tenantId); - cf.setEntityId(device.getId()); - cf.setType(CalculatedFieldType.GEOFENCING); - cf.setName("GF clamp test"); - cf.setConfigurationVersion(0); - cf.setConfiguration(cfg); - - CalculatedField saved = calculatedFieldService.save(cf); - - assertThat(saved).isNotNull(); - assertThat(saved.getConfiguration()).isInstanceOf(GeofencingCalculatedFieldConfiguration.class); - - var geofencingCalculatedFieldConfiguration = (GeofencingCalculatedFieldConfiguration) saved.getConfiguration(); - - // Assert: the interval is saved, but scheduling is not enabled - int savedInterval = geofencingCalculatedFieldConfiguration.getScheduledUpdateInterval(); - boolean scheduledUpdateEnabled = geofencingCalculatedFieldConfiguration.isScheduledUpdateEnabled(); - - assertThat(savedInterval).isEqualTo(600); - assertThat(scheduledUpdateEnabled).isFalse(); - - calculatedFieldService.deleteCalculatedField(tenantId, saved.getId()); - } - - @Test - public void testSaveGeofencingCalculatedField_shouldThrowWhenScheduledIntervalIsLessThanMinAllowedIntervalInTenantProfile() { + public void testSaveGeofencingCalculatedField_shouldThrowWhenScheduledIntervalLessThanMinAllowedIntervalInTenantProfile() { // Arrange a device Device device = createTestDevice(); @@ -165,15 +119,22 @@ public class CalculatedFieldServiceTest extends AbstractServiceTest { zoneGroupConfiguration.setRefDynamicSourceConfiguration(dynamicSourceConfiguration); cfg.setZoneGroups(Map.of("allowed", zoneGroupConfiguration)); + // Get tenant profile min. + int min = tbTenantProfileCache.get(tenantId) + .getDefaultProfileConfiguration() + .getMinAllowedScheduledUpdateIntervalInSecForCF(); + int valueFromConfig = min - 10; + // Enable scheduling with an interval below tenant min - cfg.setScheduledUpdateInterval(600); + cfg.setScheduledUpdateEnabled(true); + cfg.setScheduledUpdateInterval(valueFromConfig); // Create & save Calculated Field CalculatedField cf = new CalculatedField(); cf.setTenantId(tenantId); cf.setEntityId(device.getId()); cf.setType(CalculatedFieldType.GEOFENCING); - cf.setName("GF clamp test"); + cf.setName("GF min allowed scheduled update interval test"); cf.setConfigurationVersion(0); cf.setConfiguration(cfg); @@ -185,7 +146,7 @@ public class CalculatedFieldServiceTest extends AbstractServiceTest { } @Test - public void testSaveGeofencingCalculatedField_shouldThrowWhenRelationLevelIsGreaterThanMaxAllowedRelationLevelInTenantProfile() { + public void testSaveGeofencingCalculatedField_shouldThrowWhenRelationLevelGreaterThanMaxAllowedRelationLevelInTenantProfile() { // Arrange a device Device device = createTestDevice(); @@ -210,7 +171,7 @@ public class CalculatedFieldServiceTest extends AbstractServiceTest { cf.setTenantId(tenantId); cf.setEntityId(device.getId()); cf.setType(CalculatedFieldType.GEOFENCING); - cf.setName("GF clamp test"); + cf.setName("GF max relation level test"); cf.setConfigurationVersion(0); cf.setConfiguration(cfg); @@ -221,7 +182,7 @@ public class CalculatedFieldServiceTest extends AbstractServiceTest { } @Test - public void testSaveGeofencingCalculatedField_shouldUseScheduledIntervalFromConfig() { + public void testSaveGeofencingCalculatedField_shouldSaveWithoutDataValidationExceptionOnScheduledUpdateInterval() { // Arrange a device Device device = createTestDevice(); @@ -245,10 +206,10 @@ public class CalculatedFieldServiceTest extends AbstractServiceTest { int min = tbTenantProfileCache.get(tenantId) .getDefaultProfileConfiguration() .getMinAllowedScheduledUpdateIntervalInSecForCF(); - + int valueFromConfig = min + 100; // Enable scheduling with an interval greater than tenant min - int valueFromConfig = min + 100; + cfg.setScheduledUpdateEnabled(true); cfg.setScheduledUpdateInterval(valueFromConfig); // Create & save Calculated Field @@ -256,7 +217,7 @@ public class CalculatedFieldServiceTest extends AbstractServiceTest { cf.setTenantId(tenantId); cf.setEntityId(device.getId()); cf.setType(CalculatedFieldType.GEOFENCING); - cf.setName("GF no clamp test"); + cf.setName("GF no validation error test"); cf.setConfigurationVersion(0); cf.setConfiguration(cfg); @@ -267,7 +228,6 @@ public class CalculatedFieldServiceTest extends AbstractServiceTest { var geofencingCalculatedFieldConfiguration = (GeofencingCalculatedFieldConfiguration) saved.getConfiguration(); - // Assert: the interval is clamped up to tenant profile min (or stays >= original if already >= min) int savedInterval = geofencingCalculatedFieldConfiguration.getScheduledUpdateInterval(); assertThat(savedInterval).isEqualTo(valueFromConfig); From c1596b591d8078d1cc7bffdb79e697a7d6bfc524 Mon Sep 17 00:00:00 2001 From: Vladyslav_Prykhodko Date: Wed, 1 Oct 2025 14:41:54 +0300 Subject: [PATCH 056/102] UI: Refactoring show total in chart tooltip --- ...e-series-chart-basic-config.component.html | 4 +- ...ime-series-chart-basic-config.component.ts | 5 +- .../chart/time-series-chart-tooltip.models.ts | 172 +++++++++--------- .../lib/chart/time-series-chart.models.ts | 2 +- .../widget/lib/chart/time-series-chart.ts | 3 +- ...eries-chart-widget-settings.component.html | 4 +- ...-series-chart-widget-settings.component.ts | 5 +- 7 files changed, 101 insertions(+), 94 deletions(-) diff --git a/ui-ngx/src/app/modules/home/components/widget/config/basic/chart/time-series-chart-basic-config.component.html b/ui-ngx/src/app/modules/home/components/widget/config/basic/chart/time-series-chart-basic-config.component.html index c237c056d9..0c5eb426b3 100644 --- a/ui-ngx/src/app/modules/home/components/widget/config/basic/chart/time-series-chart-basic-config.component.html +++ b/ui-ngx/src/app/modules/home/components/widget/config/basic/chart/time-series-chart-basic-config.component.html @@ -318,8 +318,8 @@ {{ 'tooltip.hide-zero-tooltip-values' | translate }} -
- +
+ {{ 'tooltip.show-stack-total' | translate }}
diff --git a/ui-ngx/src/app/modules/home/components/widget/config/basic/chart/time-series-chart-basic-config.component.ts b/ui-ngx/src/app/modules/home/components/widget/config/basic/chart/time-series-chart-basic-config.component.ts index 4223ce139b..60135a3438 100644 --- a/ui-ngx/src/app/modules/home/components/widget/config/basic/chart/time-series-chart-basic-config.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/config/basic/chart/time-series-chart-basic-config.component.ts @@ -363,10 +363,11 @@ export class TimeSeriesChartBasicConfigComponent extends BasicWidgetConfigCompon this.timeSeriesChartWidgetConfigForm.get('tooltipValueColor').enable(); this.timeSeriesChartWidgetConfigForm.get('tooltipShowDate').enable({emitEvent: false}); this.timeSeriesChartWidgetConfigForm.get('tooltipHideZeroValues').enable({emitEvent: false}); - if (stack) + if (stack) { this.timeSeriesChartWidgetConfigForm.get('tooltipStackedShowTotal').enable(); - else + } else { this.timeSeriesChartWidgetConfigForm.get('tooltipStackedShowTotal').disable(); + } this.timeSeriesChartWidgetConfigForm.get('tooltipBackgroundColor').enable(); this.timeSeriesChartWidgetConfigForm.get('tooltipBackgroundBlur').enable(); if (tooltipShowDate) { diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/chart/time-series-chart-tooltip.models.ts b/ui-ngx/src/app/modules/home/components/widget/lib/chart/time-series-chart-tooltip.models.ts index 77d32149a2..e7f3de8f31 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/chart/time-series-chart-tooltip.models.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/chart/time-series-chart-tooltip.models.ts @@ -14,7 +14,7 @@ /// limitations under the License. /// -import { isFunction } from '@core/utils'; +import { isDefined, isFunction, isNotEmptyStr } from '@core/utils'; import { FormattedData } from '@shared/models/widget.models'; import { DateFormatProcessor, DateFormatSettings, Font } from '@shared/models/widget-settings.models'; import { TimeSeriesChartDataItem } from '@home/components/widget/lib/chart/time-series-chart.models'; @@ -22,6 +22,7 @@ import { Renderer2, SecurityContext } from '@angular/core'; import { DomSanitizer } from '@angular/platform-browser'; import { CallbackDataParams } from 'echarts/types/dist/shared'; import { Interval } from '@shared/models/time/time.models'; +import { TranslateService } from '@ngx-translate/core'; export type TimeSeriesChartTooltipValueFormatFunction = (value: any, latestData: FormattedData, units?: string, decimals?: number) => string; @@ -89,7 +90,8 @@ export class TimeSeriesChartTooltip { private sanitizer: DomSanitizer, private settings: TimeSeriesChartTooltipWidgetSettings, private tooltipDateFormat: DateFormatProcessor, - private valueFormatFunction: TimeSeriesChartTooltipValueFormatFunction) { + private valueFormatFunction: TimeSeriesChartTooltipValueFormatFunction, + private translate: TranslateService) { } @@ -131,23 +133,65 @@ export class TimeSeriesChartTooltip { if (this.settings.tooltipShowDate) { this.renderer.appendChild(tooltipItemsElement, this.constructTooltipDateElement(items[0].param, interval)); } - let total = 0, isStacked = false; + let total = 0; + let isStacked = false; + const totalUnits = new Set(); + let totalDecimal = 0; for (const item of items) { - if (!this.settings.tooltipHideZeroValues || (item.param.value[1] && item.param.value[1] !== 'false')) { + if (this.shouldShowItem(item)) { this.renderer.appendChild(tooltipItemsElement, this.constructTooltipSeriesElement(item)); - if (item.dataItem?.barRenderContext?.barStackIndex !== undefined && !isNaN(Number(item.param.value[1]))) { + if (item.dataItem?.option?.stack !== undefined && !isNaN(Number(item.param.value[1]))) { isStacked = true; total += Number(item.param.value[1]); + if (isNotEmptyStr(item.dataItem.units)) { + totalUnits.add(item.dataItem.units); + } + if (isDefined(item.dataItem.decimals)) { + totalDecimal = Math.max(item.dataItem.decimals, totalDecimal); + } } } } - if (isStacked && this.settings.tooltipStackedShowTotal) - this.renderer.appendChild(tooltipItemsElement, this.constructTooltipTotalStackedElement(total)); + if (isStacked && this.settings.tooltipStackedShowTotal) { + const unit = totalUnits.size === 1 ? Array.from(totalUnits.values())[0] : ""; + const totalValue = this.valueFormatFunction(total, {} as FormattedData, unit, totalDecimal); + this.renderer.appendChild(tooltipItemsElement, this.constructTooltipTotalStackedElement(totalValue)); + } + } + } + + private shouldShowItem(item: TooltipItem): boolean { + if (!this.settings.tooltipHideZeroValues) return true; + const value = item.param?.value?.[1]; + return value && value !== 'false'; + } + + private createElement(tag = 'div', styles?: Record): HTMLElement { + const node = this.renderer.createElement(tag); + if (styles) { + for (const [k, v] of Object.entries(styles)) { + this.renderer.setStyle(node, k, v); + } + } + return node; + } + + private applyFont(el: HTMLElement, font: {family: string; size: number; sizeUnit: string; style: string; weight: string; lineHeight: string}, color: string, overrides?: Partial) { + this.renderer.setStyle(el, 'font-family', font.family); + this.renderer.setStyle(el, 'font-size', `${font.size}${font.sizeUnit}`); + this.renderer.setStyle(el, 'font-style', font.style); + this.renderer.setStyle(el, 'font-weight', font.weight); + this.renderer.setStyle(el, 'line-height', font.lineHeight); + this.renderer.setStyle(el, 'color', color); + if (overrides) { + for (const [k, v] of Object.entries(overrides)) { + if (v != null) this.renderer.setStyle(el, k, v as string); + } } } private constructTooltipDateElement(param: CallbackDataParams, interval?: Interval): HTMLElement { - const dateElement: HTMLElement = this.renderer.createElement('div'); + const dateElement = this.createElement(); let dateText: string; const startTs = param.value[2]; const endTs = param.value[3]; @@ -164,43 +208,25 @@ export class TimeSeriesChartTooltip { dateText = this.tooltipDateFormat.update(ts, interval); } this.renderer.appendChild(dateElement, this.renderer.createText(dateText)); - this.renderer.setStyle(dateElement, 'font-family', this.settings.tooltipDateFont.family); - this.renderer.setStyle(dateElement, 'font-size', this.settings.tooltipDateFont.size + this.settings.tooltipDateFont.sizeUnit); - this.renderer.setStyle(dateElement, 'font-style', this.settings.tooltipDateFont.style); - this.renderer.setStyle(dateElement, 'font-weight', this.settings.tooltipDateFont.weight); - this.renderer.setStyle(dateElement, 'line-height', this.settings.tooltipDateFont.lineHeight); - this.renderer.setStyle(dateElement, 'color', this.settings.tooltipDateColor); + this.applyFont(dateElement, this.settings.tooltipDateFont, this.settings.tooltipDateColor); return dateElement; } private constructTooltipSeriesElement(item: TooltipItem): HTMLElement { - const labelValueElement: HTMLElement = this.renderer.createElement('div'); - this.renderer.setStyle(labelValueElement, 'display', 'flex'); - this.renderer.setStyle(labelValueElement, 'flex-direction', 'row'); - this.renderer.setStyle(labelValueElement, 'align-items', 'center'); - this.renderer.setStyle(labelValueElement, 'align-self', 'stretch'); - this.renderer.setStyle(labelValueElement, 'gap', '12px'); - const labelElement: HTMLElement = this.renderer.createElement('div'); - this.renderer.setStyle(labelElement, 'display', 'flex'); - this.renderer.setStyle(labelElement, 'align-items', 'center'); - this.renderer.setStyle(labelElement, 'gap', '8px'); - this.renderer.appendChild(labelValueElement, labelElement); - const circleElement: HTMLElement = this.renderer.createElement('div'); - this.renderer.setStyle(circleElement, 'width', '8px'); - this.renderer.setStyle(circleElement, 'height', '8px'); - this.renderer.setStyle(circleElement, 'border-radius', '50%'); - this.renderer.setStyle(circleElement, 'background', item.param.color); - this.renderer.appendChild(labelElement, circleElement); - const labelTextElement: HTMLElement = this.renderer.createElement('div'); - this.renderer.setProperty(labelTextElement, 'innerHTML', this.sanitizer.sanitize(SecurityContext.HTML, item.param.seriesName)); - this.renderer.setStyle(labelTextElement, 'font-family', this.settings.tooltipLabelFont.family); - this.renderer.setStyle(labelTextElement, 'font-size', this.settings.tooltipLabelFont.size + this.settings.tooltipLabelFont.sizeUnit); - this.renderer.setStyle(labelTextElement, 'font-style', this.settings.tooltipLabelFont.style); - this.renderer.setStyle(labelTextElement, 'font-weight', this.settings.tooltipLabelFont.weight); - this.renderer.setStyle(labelTextElement, 'line-height', this.settings.tooltipLabelFont.lineHeight); - this.renderer.setStyle(labelTextElement, 'color', this.settings.tooltipLabelColor); - this.renderer.appendChild(labelElement, labelTextElement); - const valueElement: HTMLElement = this.renderer.createElement('div'); + const row = this.createElement('div', {display: 'flex', 'flex-direction': 'row', 'align-items': 'center', 'align-self': 'stretch', gap: '12px'}); + + const label = this.createElement('div', { display: 'flex', 'align-items': 'center', gap: '8px' }); + this.renderer.appendChild(row, label); + + const dot = this.createElement('div', { width: '8px', height: '8px', 'border-radius': '50%', background: item.param.color as string }); + this.renderer.appendChild(label, dot); + + const labelText = this.createElement('div'); + this.renderer.setProperty(labelText, 'innerHTML', this.sanitizer.sanitize(SecurityContext.HTML, item.param.seriesName)); + this.applyFont(labelText, this.settings.tooltipLabelFont, this.settings.tooltipLabelColor); + this.renderer.appendChild(label, labelText); + + const valueElement: HTMLElement = this.createElement('div', { flex: '1', 'text-align': 'end' }); let formatFunction = this.valueFormatFunction; let latestData: FormattedData; let units = ''; @@ -218,51 +244,29 @@ export class TimeSeriesChartTooltip { } const value = formatFunction(item.param.value[1], latestData, units, decimals); this.renderer.setProperty(valueElement, 'innerHTML', this.sanitizer.sanitize(SecurityContext.HTML, value)); - this.renderer.setStyle(valueElement, 'flex', '1'); - this.renderer.setStyle(valueElement, 'text-align', 'end'); - this.renderer.setStyle(valueElement, 'font-family', this.settings.tooltipValueFont.family); - this.renderer.setStyle(valueElement, 'font-size', this.settings.tooltipValueFont.size + this.settings.tooltipValueFont.sizeUnit); - this.renderer.setStyle(valueElement, 'font-style', this.settings.tooltipValueFont.style); - this.renderer.setStyle(valueElement, 'font-weight', this.settings.tooltipValueFont.weight); - this.renderer.setStyle(valueElement, 'line-height', this.settings.tooltipValueFont.lineHeight); - this.renderer.setStyle(valueElement, 'color', this.settings.tooltipValueColor); - this.renderer.appendChild(labelValueElement, valueElement); - return labelValueElement; + this.applyFont(valueElement, this.settings.tooltipValueFont, this.settings.tooltipValueColor); + this.renderer.appendChild(row, valueElement); + + return row; } - private constructTooltipTotalStackedElement(total: number): HTMLElement { - const labelValueElement: HTMLElement = this.renderer.createElement('div'); - this.renderer.setStyle(labelValueElement, 'display', 'flex'); - this.renderer.setStyle(labelValueElement, 'flex-direction', 'row'); - this.renderer.setStyle(labelValueElement, 'align-items', 'center'); - this.renderer.setStyle(labelValueElement, 'align-self', 'stretch'); - this.renderer.setStyle(labelValueElement, 'gap', '12px'); - const labelElement: HTMLElement = this.renderer.createElement('div'); - this.renderer.setStyle(labelElement, 'display', 'flex'); - this.renderer.setStyle(labelElement, 'align-items', 'center'); - this.renderer.setStyle(labelElement, 'gap', '8px'); - this.renderer.appendChild(labelValueElement, labelElement); - const labelTextElement: HTMLElement = this.renderer.createElement('div'); - this.renderer.setProperty(labelTextElement, 'innerHTML', this.sanitizer.sanitize(SecurityContext.HTML, 'Total')); - this.renderer.setStyle(labelTextElement, 'font-family', this.settings.tooltipLabelFont.family); - this.renderer.setStyle(labelTextElement, 'font-size', this.settings.tooltipLabelFont.size + this.settings.tooltipLabelFont.sizeUnit); - this.renderer.setStyle(labelTextElement, 'font-style', this.settings.tooltipLabelFont.style); - this.renderer.setStyle(labelTextElement, 'font-weight', 'bold'); - this.renderer.setStyle(labelTextElement, 'line-height', this.settings.tooltipLabelFont.lineHeight); - this.renderer.setStyle(labelTextElement, 'color', this.settings.tooltipLabelColor); - this.renderer.appendChild(labelElement, labelTextElement); - const valueElement: HTMLElement = this.renderer.createElement('div'); - this.renderer.setProperty(valueElement, 'innerHTML', this.sanitizer.sanitize(SecurityContext.HTML, total.toString())); - this.renderer.setStyle(valueElement, 'flex', '1'); - this.renderer.setStyle(valueElement, 'text-align', 'end'); - this.renderer.setStyle(valueElement, 'font-family', this.settings.tooltipValueFont.family); - this.renderer.setStyle(valueElement, 'font-size', this.settings.tooltipValueFont.size + this.settings.tooltipValueFont.sizeUnit); - this.renderer.setStyle(valueElement, 'font-style', this.settings.tooltipValueFont.style); - this.renderer.setStyle(valueElement, 'font-weight', 'bold'); - this.renderer.setStyle(valueElement, 'line-height', this.settings.tooltipValueFont.lineHeight); - this.renderer.setStyle(valueElement, 'color', this.settings.tooltipValueColor); - this.renderer.appendChild(labelValueElement, valueElement); - return labelValueElement; + private constructTooltipTotalStackedElement(total: string): HTMLElement { + const row = this.createElement('div', {display: 'flex', 'flex-direction': 'row', 'align-items': 'center', 'align-self': 'stretch', gap: '12px'}); + + const label = this.createElement('div', { display: 'flex', 'align-items': 'center', gap: '8px' }); + this.renderer.appendChild(row, label); + + const labelText = this.createElement('div'); + this.renderer.setProperty(labelText, 'innerHTML', this.sanitizer.sanitize(SecurityContext.HTML, this.translate.instant('legend.Total'))); + this.applyFont(labelText, this.settings.tooltipLabelFont, this.settings.tooltipLabelColor, { fontWeight: 'bold' }); + this.renderer.appendChild(label, labelText); + + const valueEl = this.createElement('div', { flex: '1', 'text-align': 'end' }); + this.renderer.setProperty(valueEl, 'innerHTML', this.sanitizer.sanitize(SecurityContext.HTML, total)); + this.applyFont(valueEl, this.settings.tooltipValueFont, this.settings.tooltipValueColor, { fontWeight: 'bold' }); + this.renderer.appendChild(row, valueEl); + + return row; } private static mapTooltipParams(params: CallbackDataParams[] | CallbackDataParams, diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/chart/time-series-chart.models.ts b/ui-ngx/src/app/modules/home/components/widget/lib/chart/time-series-chart.models.ts index d18f985c1d..089c655224 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/chart/time-series-chart.models.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/chart/time-series-chart.models.ts @@ -143,7 +143,7 @@ export interface TimeSeriesChartDataItem { xAxisIndex: number; yAxisId: TimeSeriesChartYAxisId; yAxisIndex: number; - option?: LineSeriesOption | CustomSeriesOption; + option?: LineSeriesOption; barRenderContext?: BarRenderContext; unitConvertor?: TbUnitConverter; } diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/chart/time-series-chart.ts b/ui-ngx/src/app/modules/home/components/widget/lib/chart/time-series-chart.ts index 7ac6e3ec85..d447b51b37 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/chart/time-series-chart.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/chart/time-series-chart.ts @@ -212,7 +212,8 @@ export class TbTimeSeriesChart { this.ctx.sanitizer, this.settings, this.tooltipDateFormat, - tooltipValueFormatFunction + tooltipValueFormatFunction, + this.ctx.translate ); this.onResize(); if (this.autoResize) { diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/chart/time-series-chart-widget-settings.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/settings/chart/time-series-chart-widget-settings.component.html index 9914a6124d..9a80362c8d 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/chart/time-series-chart-widget-settings.component.html +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/chart/time-series-chart-widget-settings.component.html @@ -242,8 +242,8 @@ {{ 'tooltip.hide-zero-tooltip-values' | translate }}
-
- +
+ {{ 'tooltip.show-stack-total' | translate }}
diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/chart/time-series-chart-widget-settings.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/settings/chart/time-series-chart-widget-settings.component.ts index a753b12acd..0c2f00d09b 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/chart/time-series-chart-widget-settings.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/chart/time-series-chart-widget-settings.component.ts @@ -227,10 +227,11 @@ export class TimeSeriesChartWidgetSettingsComponent extends WidgetSettingsCompon this.timeSeriesChartWidgetSettingsForm.get('tooltipValueFormatter').enable(); this.timeSeriesChartWidgetSettingsForm.get('tooltipShowDate').enable({emitEvent: false}); this.timeSeriesChartWidgetSettingsForm.get('tooltipHideZeroValues').enable(); - if (stack) + if (stack) { this.timeSeriesChartWidgetSettingsForm.get('tooltipStackedShowTotal').enable(); - else + } else { this.timeSeriesChartWidgetSettingsForm.get('tooltipStackedShowTotal').disable(); + } this.timeSeriesChartWidgetSettingsForm.get('tooltipBackgroundColor').enable(); this.timeSeriesChartWidgetSettingsForm.get('tooltipBackgroundBlur').enable(); if (tooltipShowDate) { From 909497703a71449d5d7a388d96da45bc745d13bb Mon Sep 17 00:00:00 2001 From: dshvaika Date: Wed, 1 Oct 2025 15:35:55 +0300 Subject: [PATCH 057/102] new relation path query --- ...tractCalculatedFieldProcessingService.java | 6 ++ .../server/dao/relation/RelationService.java | 3 + .../CFArgumentDynamicSourceType.java | 2 +- .../CfArgumentDynamicSourceConfiguration.java | 3 +- ...onPathQueryDynamicSourceConfiguration.java | 73 ++++++++++++++ .../cf/configuration/RelationQueryBased.java | 29 ++++++ ...lationQueryDynamicSourceConfiguration.java | 9 +- .../relation/EntityRelationPathQuery.java | 24 +++++ .../data/relation/RelationPathLevel.java | 30 ++++++ .../dao/relation/BaseRelationService.java | 21 ++++ .../server/dao/relation/RelationDao.java | 3 + .../CalculatedFieldDataValidator.java | 6 +- .../dao/sql/relation/JpaRelationDao.java | 99 +++++++++++++++++++ 13 files changed, 295 insertions(+), 13 deletions(-) create mode 100644 common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/RelationPathQueryDynamicSourceConfiguration.java create mode 100644 common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/RelationQueryBased.java create mode 100644 common/data/src/main/java/org/thingsboard/server/common/data/relation/EntityRelationPathQuery.java create mode 100644 common/data/src/main/java/org/thingsboard/server/common/data/relation/RelationPathLevel.java 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 8709e0cb68..bd3546082a 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 @@ -26,6 +26,7 @@ import lombok.extern.slf4j.Slf4j; import org.thingsboard.common.util.ThingsBoardExecutors; import org.thingsboard.server.common.data.cf.configuration.Argument; import org.thingsboard.server.common.data.cf.configuration.ArgumentType; +import org.thingsboard.server.common.data.cf.configuration.RelationPathQueryDynamicSourceConfiguration; import org.thingsboard.server.common.data.cf.configuration.RelationQueryDynamicSourceConfiguration; import org.thingsboard.server.common.data.id.EntityId; import org.thingsboard.server.common.data.id.TenantId; @@ -178,6 +179,11 @@ public abstract class AbstractCalculatedFieldProcessingService { yield Futures.transform(relationService.findByQuery(tenantId, configuration.toEntityRelationsQuery(entityId)), configuration::resolveEntityIds, calculatedFieldCallbackExecutor); } + case RELATION_PATH_QUERY -> { + var configuration = (RelationPathQueryDynamicSourceConfiguration) refDynamicSourceConfiguration; + yield Futures.transform(relationService.findByRelationPathQueryAsync(tenantId, configuration.toRelationPathQuery(entityId)), + configuration::resolveEntityIds, calculatedFieldCallbackExecutor); + } }; } diff --git a/common/dao-api/src/main/java/org/thingsboard/server/dao/relation/RelationService.java b/common/dao-api/src/main/java/org/thingsboard/server/dao/relation/RelationService.java index 1db5739e94..a0bc9a72e6 100644 --- a/common/dao-api/src/main/java/org/thingsboard/server/dao/relation/RelationService.java +++ b/common/dao-api/src/main/java/org/thingsboard/server/dao/relation/RelationService.java @@ -20,6 +20,7 @@ import org.thingsboard.server.common.data.id.EntityId; import org.thingsboard.server.common.data.id.TenantId; import org.thingsboard.server.common.data.relation.EntityRelation; import org.thingsboard.server.common.data.relation.EntityRelationInfo; +import org.thingsboard.server.common.data.relation.EntityRelationPathQuery; import org.thingsboard.server.common.data.relation.EntityRelationsQuery; import org.thingsboard.server.common.data.relation.RelationTypeGroup; import org.thingsboard.server.common.data.rule.RuleChainType; @@ -83,6 +84,8 @@ public interface RelationService { List findRuleNodeToRuleChainRelations(TenantId tenantId, RuleChainType ruleChainType, int limit); + ListenableFuture> findByRelationPathQueryAsync(TenantId tenantId, EntityRelationPathQuery relationPathQuery); + // TODO: This method may be useful for some validations in the future // ListenableFuture checkRecursiveRelation(EntityId from, EntityId to); diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/CFArgumentDynamicSourceType.java b/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/CFArgumentDynamicSourceType.java index bd2e9b0c00..35c6cdf562 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/CFArgumentDynamicSourceType.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/CFArgumentDynamicSourceType.java @@ -17,6 +17,6 @@ package org.thingsboard.server.common.data.cf.configuration; public enum CFArgumentDynamicSourceType { - RELATION_QUERY + RELATION_QUERY, RELATION_PATH_QUERY } diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/CfArgumentDynamicSourceConfiguration.java b/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/CfArgumentDynamicSourceConfiguration.java index f36071615e..397b1d016e 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/CfArgumentDynamicSourceConfiguration.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/CfArgumentDynamicSourceConfiguration.java @@ -26,7 +26,8 @@ import com.fasterxml.jackson.annotation.JsonTypeInfo; property = "type" ) @JsonSubTypes({ - @JsonSubTypes.Type(value = RelationQueryDynamicSourceConfiguration.class, name = "RELATION_QUERY") + @JsonSubTypes.Type(value = RelationQueryDynamicSourceConfiguration.class, name = "RELATION_QUERY"), + @JsonSubTypes.Type(value = RelationPathQueryDynamicSourceConfiguration.class, name = "RELATION_PATH_QUERY") }) @JsonIgnoreProperties(ignoreUnknown = true) public interface CfArgumentDynamicSourceConfiguration { diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/RelationPathQueryDynamicSourceConfiguration.java b/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/RelationPathQueryDynamicSourceConfiguration.java new file mode 100644 index 0000000000..c7889be19d --- /dev/null +++ b/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/RelationPathQueryDynamicSourceConfiguration.java @@ -0,0 +1,73 @@ +/** + * 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.common.data.cf.configuration; + +import com.fasterxml.jackson.annotation.JsonIgnore; +import lombok.Data; +import org.thingsboard.server.common.data.id.EntityId; +import org.thingsboard.server.common.data.relation.EntityRelation; +import org.thingsboard.server.common.data.relation.EntityRelationPathQuery; +import org.thingsboard.server.common.data.relation.EntitySearchDirection; +import org.thingsboard.server.common.data.relation.RelationPathLevel; +import org.thingsboard.server.common.data.util.CollectionsUtil; + +import java.util.List; +import java.util.NoSuchElementException; + +@Data +public class RelationPathQueryDynamicSourceConfiguration implements CfArgumentDynamicSourceConfiguration, RelationQueryBased { + + private List levels; + + @Override + public CFArgumentDynamicSourceType getType() { + return CFArgumentDynamicSourceType.RELATION_PATH_QUERY; + } + + @Override + public void validate() { + if (CollectionsUtil.isEmpty(levels)) { + throw new IllegalArgumentException("At least one relation level must be specified!"); + } + levels.forEach(RelationPathLevel::validate); + } + + public List resolveEntityIds(List relations) { + EntitySearchDirection lastLevelDirection = getLastLevel().direction(); + return switch (lastLevelDirection) { + case FROM -> relations.stream().map(EntityRelation::getTo).toList(); + case TO -> relations.stream().map(EntityRelation::getFrom).toList(); + }; + } + + @Override + @JsonIgnore + public int getMaxLevel() { + return levels != null ? levels.size() : 0; + } + + public EntityRelationPathQuery toRelationPathQuery(EntityId entityId) { + return new EntityRelationPathQuery(entityId, levels); + } + + private RelationPathLevel getLastLevel() { + if (CollectionsUtil.isEmpty(levels)) { + throw new NoSuchElementException(); + } + return levels.get(levels.size() - 1); + } + +} diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/RelationQueryBased.java b/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/RelationQueryBased.java new file mode 100644 index 0000000000..e3248b264f --- /dev/null +++ b/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/RelationQueryBased.java @@ -0,0 +1,29 @@ +/** + * 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.common.data.cf.configuration; + +public interface RelationQueryBased { + + int getMaxLevel(); + + default void validateMaxRelationLevel(String argumentName, int maxAllowedRelationLevel) { + if (getMaxLevel() > maxAllowedRelationLevel) { + throw new IllegalArgumentException("Max relation level is greater than configured " + + "maximum allowed relation level in tenant profile: " + maxAllowedRelationLevel + " for argument: " + argumentName); + } + } + +} diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/RelationQueryDynamicSourceConfiguration.java b/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/RelationQueryDynamicSourceConfiguration.java index 4e9b4252c9..120d04d40d 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/RelationQueryDynamicSourceConfiguration.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/RelationQueryDynamicSourceConfiguration.java @@ -29,7 +29,7 @@ import java.util.Collections; import java.util.List; @Data -public class RelationQueryDynamicSourceConfiguration implements CfArgumentDynamicSourceConfiguration { +public class RelationQueryDynamicSourceConfiguration implements CfArgumentDynamicSourceConfiguration, RelationQueryBased { private int maxLevel; private boolean fetchLastLevelOnly; @@ -59,13 +59,6 @@ public class RelationQueryDynamicSourceConfiguration implements CfArgumentDynami return maxLevel == 1; } - public void validateMaxRelationLevel(String argumentName, int maxAllowedRelationLevel) { - if (maxLevel > maxAllowedRelationLevel) { - throw new IllegalArgumentException("Max relation level is greater than configured " + - "maximum allowed relation level in tenant profile: " + maxAllowedRelationLevel + " for argument: " + argumentName); - } - } - public EntityRelationsQuery toEntityRelationsQuery(EntityId rootEntityId) { if (isSimpleRelation()) { throw new IllegalArgumentException("Entity relations query can't be created for a simple relation!"); diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/relation/EntityRelationPathQuery.java b/common/data/src/main/java/org/thingsboard/server/common/data/relation/EntityRelationPathQuery.java new file mode 100644 index 0000000000..a81e3e0c86 --- /dev/null +++ b/common/data/src/main/java/org/thingsboard/server/common/data/relation/EntityRelationPathQuery.java @@ -0,0 +1,24 @@ +/** + * 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.common.data.relation; + +import org.thingsboard.server.common.data.id.EntityId; + +import java.util.List; + +public record EntityRelationPathQuery(EntityId rootEntityId, List levels) { + +} diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/relation/RelationPathLevel.java b/common/data/src/main/java/org/thingsboard/server/common/data/relation/RelationPathLevel.java new file mode 100644 index 0000000000..c28135204f --- /dev/null +++ b/common/data/src/main/java/org/thingsboard/server/common/data/relation/RelationPathLevel.java @@ -0,0 +1,30 @@ +/** + * 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.common.data.relation; + +import org.thingsboard.server.common.data.StringUtils; + +public record RelationPathLevel(EntitySearchDirection direction, String relationType) { + + public void validate() { + if (direction == null) { + throw new IllegalArgumentException("Direction must be specified!"); + } + if (StringUtils.isBlank(relationType)) { + throw new IllegalArgumentException("Relation type must be specified!"); + } + } +} diff --git a/dao/src/main/java/org/thingsboard/server/dao/relation/BaseRelationService.java b/dao/src/main/java/org/thingsboard/server/dao/relation/BaseRelationService.java index 87f7e44a1f..0ec1257bcc 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/relation/BaseRelationService.java +++ b/dao/src/main/java/org/thingsboard/server/dao/relation/BaseRelationService.java @@ -32,17 +32,21 @@ import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import org.springframework.transaction.event.TransactionalEventListener; import org.springframework.transaction.support.TransactionSynchronizationManager; +import org.springframework.util.CollectionUtils; import org.thingsboard.common.util.ThingsBoardExecutors; import org.thingsboard.server.cache.TbTransactionalCache; import org.thingsboard.server.common.data.StringUtils; import org.thingsboard.server.common.data.audit.ActionType; import org.thingsboard.server.common.data.id.EntityId; import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.common.data.id.UUIDBased; import org.thingsboard.server.common.data.relation.EntityRelation; import org.thingsboard.server.common.data.relation.EntityRelationInfo; +import org.thingsboard.server.common.data.relation.EntityRelationPathQuery; import org.thingsboard.server.common.data.relation.EntityRelationsQuery; import org.thingsboard.server.common.data.relation.EntitySearchDirection; import org.thingsboard.server.common.data.relation.RelationEntityTypeFilter; +import org.thingsboard.server.common.data.relation.RelationPathLevel; import org.thingsboard.server.common.data.relation.RelationTypeGroup; import org.thingsboard.server.common.data.relation.RelationsSearchParameters; import org.thingsboard.server.common.data.rule.RuleChainType; @@ -495,6 +499,23 @@ public class BaseRelationService implements RelationService { return relationDao.findRuleNodeToRuleChainRelations(ruleChainType, limit); } + @Override + public ListenableFuture> findByRelationPathQueryAsync(TenantId tenantId, EntityRelationPathQuery relationPathQuery) { + log.trace("Executing findByRelationPathQuery, tenantId [{}], relationPathQuery {}", tenantId, relationPathQuery); + validateId(tenantId, id -> "Invalid tenant id: " + id); + validate(relationPathQuery); + return executor.submit(() -> relationDao.findByRelationPathQuery(tenantId, relationPathQuery)); + } + + private void validate(EntityRelationPathQuery relationPathQuery) { + validateId((UUIDBased) relationPathQuery.rootEntityId(), id -> "Invalid root entity id: " + id); + List levels = relationPathQuery.levels(); + if (CollectionUtils.isEmpty(levels)) { + throw new DataValidationException("Relation path levels should be specified!"); + } + levels.forEach(RelationPathLevel::validate); + } + protected void validate(EntityRelation relation) { if (relation == null) { throw new DataValidationException("Relation type should be specified!"); diff --git a/dao/src/main/java/org/thingsboard/server/dao/relation/RelationDao.java b/dao/src/main/java/org/thingsboard/server/dao/relation/RelationDao.java index c061faed5e..ad53164ad7 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/relation/RelationDao.java +++ b/dao/src/main/java/org/thingsboard/server/dao/relation/RelationDao.java @@ -19,6 +19,7 @@ import com.google.common.util.concurrent.ListenableFuture; import org.thingsboard.server.common.data.id.EntityId; import org.thingsboard.server.common.data.id.TenantId; import org.thingsboard.server.common.data.relation.EntityRelation; +import org.thingsboard.server.common.data.relation.EntityRelationPathQuery; import org.thingsboard.server.common.data.relation.RelationTypeGroup; import org.thingsboard.server.common.data.rule.RuleChainType; @@ -71,4 +72,6 @@ public interface RelationDao { List findRuleNodeToRuleChainRelations(RuleChainType ruleChainType, int limit); + List findByRelationPathQuery(TenantId tenantId, EntityRelationPathQuery relationPathQuery); + } diff --git a/dao/src/main/java/org/thingsboard/server/dao/service/validator/CalculatedFieldDataValidator.java b/dao/src/main/java/org/thingsboard/server/dao/service/validator/CalculatedFieldDataValidator.java index 05b782c26c..85c30ca74e 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/service/validator/CalculatedFieldDataValidator.java +++ b/dao/src/main/java/org/thingsboard/server/dao/service/validator/CalculatedFieldDataValidator.java @@ -19,7 +19,7 @@ import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Component; import org.thingsboard.server.common.data.cf.CalculatedField; import org.thingsboard.server.common.data.cf.configuration.ArgumentsBasedCalculatedFieldConfiguration; -import org.thingsboard.server.common.data.cf.configuration.RelationQueryDynamicSourceConfiguration; +import org.thingsboard.server.common.data.cf.configuration.RelationQueryBased; import org.thingsboard.server.common.data.cf.configuration.ScheduledUpdateSupportedCalculatedFieldConfiguration; import org.thingsboard.server.common.data.id.TenantId; import org.thingsboard.server.common.data.tenant.profile.DefaultTenantProfileConfiguration; @@ -98,10 +98,10 @@ public class CalculatedFieldDataValidator extends DataValidator if (!(calculatedField.getConfiguration() instanceof ArgumentsBasedCalculatedFieldConfiguration argumentsBasedCfg)) { return; } - Map relationQueryBasedArguments = argumentsBasedCfg.getArguments().entrySet() + Map relationQueryBasedArguments = argumentsBasedCfg.getArguments().entrySet() .stream() .filter(entry -> entry.getValue().hasDynamicSource()) - .collect(Collectors.toMap(Map.Entry::getKey, entry -> (RelationQueryDynamicSourceConfiguration) entry.getValue().getRefDynamicSourceConfiguration())); + .collect(Collectors.toMap(Map.Entry::getKey, entry -> (RelationQueryBased) entry.getValue().getRefDynamicSourceConfiguration())); if (relationQueryBasedArguments.isEmpty()) { return; } diff --git a/dao/src/main/java/org/thingsboard/server/dao/sql/relation/JpaRelationDao.java b/dao/src/main/java/org/thingsboard/server/dao/sql/relation/JpaRelationDao.java index 7417418f54..782c9b9d53 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/sql/relation/JpaRelationDao.java +++ b/dao/src/main/java/org/thingsboard/server/dao/sql/relation/JpaRelationDao.java @@ -25,6 +25,9 @@ import org.thingsboard.server.common.data.id.EntityId; import org.thingsboard.server.common.data.id.EntityIdFactory; import org.thingsboard.server.common.data.id.TenantId; import org.thingsboard.server.common.data.relation.EntityRelation; +import org.thingsboard.server.common.data.relation.EntityRelationPathQuery; +import org.thingsboard.server.common.data.relation.EntitySearchDirection; +import org.thingsboard.server.common.data.relation.RelationPathLevel; import org.thingsboard.server.common.data.relation.RelationTypeGroup; import org.thingsboard.server.common.data.rule.RuleChainType; import org.thingsboard.server.dao.DaoUtil; @@ -43,6 +46,7 @@ import java.util.stream.Collectors; import static org.thingsboard.server.dao.model.ModelConstants.RELATION_FROM_ID_PROPERTY; import static org.thingsboard.server.dao.model.ModelConstants.RELATION_FROM_TYPE_PROPERTY; +import static org.thingsboard.server.dao.model.ModelConstants.RELATION_TABLE_NAME; import static org.thingsboard.server.dao.model.ModelConstants.RELATION_TO_ID_PROPERTY; import static org.thingsboard.server.dao.model.ModelConstants.RELATION_TO_TYPE_PROPERTY; import static org.thingsboard.server.dao.model.ModelConstants.RELATION_TYPE_GROUP_PROPERTY; @@ -293,4 +297,99 @@ public class JpaRelationDao extends JpaAbstractDaoListeningExecutorService imple public List findRuleNodeToRuleChainRelations(RuleChainType ruleChainType, int limit) { return DaoUtil.convertDataList(relationRepository.findRuleNodeToRuleChainRelations(ruleChainType, PageRequest.of(0, limit))); } + + @Override + public List findByRelationPathQuery(TenantId tenantId, EntityRelationPathQuery query) { + List levels = query.levels(); + if (levels == null || levels.isEmpty()) { + return Collections.emptyList(); + } + String sql = buildRelationPathSql(query); + Object[] params = buildRelationPathParams(query); + + log.info("[{}] relation path query: {}", tenantId, sql); + + return jdbcTemplate.queryForList(sql, params).stream() + .map(row -> { + var entityRelation = new EntityRelation(); + var fromId = (UUID) row.get(RELATION_FROM_ID_PROPERTY); + var fromType = (String) row.get(RELATION_FROM_TYPE_PROPERTY); + var toId = (UUID) row.get(RELATION_TO_ID_PROPERTY); + var toType = (String) row.get(RELATION_TO_TYPE_PROPERTY); + var grp = (String) row.get(RELATION_TYPE_GROUP_PROPERTY); + var type = (String) row.get(RELATION_TYPE_PROPERTY); + var version = (Long) row.get(VERSION_COLUMN); + + entityRelation.setFrom(EntityIdFactory.getByTypeAndUuid(fromType, fromId)); + entityRelation.setTo(EntityIdFactory.getByTypeAndUuid(toType, toId)); + entityRelation.setType(type); + entityRelation.setTypeGroup(RelationTypeGroup.valueOf(grp)); + entityRelation.setVersion(version); + return entityRelation; + }) + .collect(Collectors.toList()); + } + + private Object[] buildRelationPathParams(EntityRelationPathQuery query) { + final List params = new ArrayList<>(); + // seed + params.add(query.rootEntityId().getId()); + params.add(query.rootEntityId().getEntityType().name()); + + // levels + for (var lvl : query.levels()) { + params.add(lvl.relationType()); + } + return params.toArray(); + } + + private static String buildRelationPathSql(EntityRelationPathQuery query) { + List levels = query.levels(); + StringBuilder sb = new StringBuilder(); + + sb.append("WITH seed AS (\n") + .append(" SELECT ?::uuid AS id, ?::varchar AS type\n") + .append(")"); + + String prev = "seed"; + for (int i = 0; i < levels.size() - 1; i++) { + RelationPathLevel lvl = levels.get(i); + boolean down = lvl.direction() == EntitySearchDirection.FROM; + + String cur = "lvl" + (i + 1); + String joinCond = down + ? "r.from_id = p.id AND r.from_type = p.type" + : "r.to_id = p.id AND r.to_type = p.type"; + String selectNext = down + ? "r.to_id AS id, r.to_type AS type" + : "r.from_id AS id, r.from_type AS type"; + + sb.append(",\n").append(cur).append(" AS (\n") + .append(" SELECT ").append(selectNext).append("\n") + .append(" FROM ").append(RELATION_TABLE_NAME).append(" r\n") + .append(" JOIN ").append(prev).append(" p ON ").append(joinCond).append("\n") + .append(" WHERE r.relation_type_group = '").append(RelationTypeGroup.COMMON).append("'\n") + .append(" AND r.relation_type = ?\n") + .append(")"); + prev = cur; + } + + RelationPathLevel last = levels.get(levels.size() - 1); + boolean lastDown = last.direction() == EntitySearchDirection.FROM; + String prevForLast = (levels.size() == 1) ? "seed" : prev; + String lastJoin = lastDown + ? "r.from_id = p.id AND r.from_type = p.type" + : "r.to_id = p.id AND r.to_type = p.type"; + + sb.append("\n") + .append("SELECT r.from_id, r.from_type, r.to_id, r.to_type,\n") + .append(" r.relation_type_group, r.relation_type, r.version\n") + .append("FROM ").append(RELATION_TABLE_NAME).append(" r\n") + .append("JOIN ").append(prevForLast).append(" p ON ").append(lastJoin).append("\n") + .append("WHERE r.relation_type_group = '").append(RelationTypeGroup.COMMON).append("'\n") + .append(" AND r.relation_type = ?"); + + return sb.toString(); + } + } From 8e8b5507ee99f57dad022e1e977388ab05a01b26 Mon Sep 17 00:00:00 2001 From: Vladyslav_Prykhodko Date: Wed, 1 Oct 2025 16:42:31 +0300 Subject: [PATCH 058/102] UI: Doughnut widget disabled setting legendShowTotal in total Layout --- .../config/basic/chart/doughnut-basic-config.component.ts | 2 ++ .../lib/settings/chart/doughnut-widget-settings.component.ts | 2 ++ 2 files changed, 4 insertions(+) diff --git a/ui-ngx/src/app/modules/home/components/widget/config/basic/chart/doughnut-basic-config.component.ts b/ui-ngx/src/app/modules/home/components/widget/config/basic/chart/doughnut-basic-config.component.ts index 0321efac83..067fae3b4e 100644 --- a/ui-ngx/src/app/modules/home/components/widget/config/basic/chart/doughnut-basic-config.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/config/basic/chart/doughnut-basic-config.component.ts @@ -86,9 +86,11 @@ export class DoughnutBasicConfigComponent extends LatestChartBasicConfigComponen if (totalEnabled) { latestChartWidgetConfigForm.get('totalValueFont').enable(); latestChartWidgetConfigForm.get('totalValueColor').enable(); + latestChartWidgetConfigForm.get('legendShowTotal').disable(); } else { latestChartWidgetConfigForm.get('totalValueFont').disable(); latestChartWidgetConfigForm.get('totalValueColor').disable(); + latestChartWidgetConfigForm.get('legendShowTotal').enable(); } } } diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/chart/doughnut-widget-settings.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/settings/chart/doughnut-widget-settings.component.ts index 8db598b919..f1854a0d00 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/chart/doughnut-widget-settings.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/chart/doughnut-widget-settings.component.ts @@ -69,9 +69,11 @@ export class DoughnutWidgetSettingsComponent extends LatestChartWidgetSettingsCo if (totalEnabled) { latestChartWidgetSettingsForm.get('totalValueFont').enable(); latestChartWidgetSettingsForm.get('totalValueColor').enable(); + latestChartWidgetSettingsForm.get('legendShowTotal').disable(); } else { latestChartWidgetSettingsForm.get('totalValueFont').disable(); latestChartWidgetSettingsForm.get('totalValueColor').disable(); + latestChartWidgetSettingsForm.get('legendShowTotal').enable(); } } } From 0a2b1168fabeb03f14f199c0a424c771ea9927f2 Mon Sep 17 00:00:00 2001 From: IrynaMatveieva Date: Thu, 2 Oct 2025 13:18:31 +0300 Subject: [PATCH 059/102] updated ctx value in cache when expression failed after cf update --- ...alculatedFieldManagerMessageProcessor.java | 47 ++++++++-------- .../cf/ctx/state/CalculatedFieldCtx.java | 2 + .../cf/CalculatedFieldIntegrationTest.java | 53 +++++++++++++++++++ 3 files changed, 79 insertions(+), 23 deletions(-) diff --git a/application/src/main/java/org/thingsboard/server/actors/calculatedField/CalculatedFieldManagerMessageProcessor.java b/application/src/main/java/org/thingsboard/server/actors/calculatedField/CalculatedFieldManagerMessageProcessor.java index c00995d3d4..590e097928 100644 --- a/application/src/main/java/org/thingsboard/server/actors/calculatedField/CalculatedFieldManagerMessageProcessor.java +++ b/application/src/main/java/org/thingsboard/server/actors/calculatedField/CalculatedFieldManagerMessageProcessor.java @@ -287,29 +287,29 @@ public class CalculatedFieldManagerMessageProcessor extends AbstractContextAware newCfCtx.init(); } catch (Exception e) { throw CalculatedFieldException.builder().ctx(newCfCtx).eventEntity(newCfCtx.getEntityId()).cause(e).errorMessage("Failed to initialize CF context").build(); - } - calculatedFields.put(newCf.getId(), newCfCtx); - List oldCfList = entityIdCalculatedFields.get(newCf.getEntityId()); - List newCfList = new CopyOnWriteArrayList<>(); - boolean found = false; - for (CalculatedFieldCtx oldCtx : oldCfList) { - if (oldCtx.getCfId().equals(newCf.getId())) { + } finally { + calculatedFields.put(newCf.getId(), newCfCtx); + List oldCfList = entityIdCalculatedFields.get(newCf.getEntityId()); + List newCfList = new CopyOnWriteArrayList<>(); + boolean found = false; + for (CalculatedFieldCtx oldCtx : oldCfList) { + if (oldCtx.getCfId().equals(newCf.getId())) { + newCfList.add(newCfCtx); + found = true; + } else { + newCfList.add(oldCtx); + } + } + if (!found) { newCfList.add(newCfCtx); - found = true; - } else { - newCfList.add(oldCtx); } + // We use copy on write lists to safely pass the reference to another actor for the iteration. + // Alternative approach would be to use any list but avoid modifications to the list (change the complete map value instead) + entityIdCalculatedFields.put(newCf.getEntityId(), newCfList); + deleteLinks(oldCfCtx); + addLinks(newCf); } - if (!found) { - newCfList.add(newCfCtx); - } - entityIdCalculatedFields.put(newCf.getEntityId(), newCfList); - - deleteLinks(oldCfCtx); - addLinks(newCf); - // We use copy on write lists to safely pass the reference to another actor for the iteration. - // Alternative approach would be to use any list but avoid modifications to the list (change the complete map value instead) var stateChanges = newCfCtx.hasStateChanges(oldCfCtx); if (stateChanges || newCfCtx.hasOtherSignificantChanges(oldCfCtx)) { initCf(newCfCtx, callback, stateChanges); @@ -550,11 +550,12 @@ public class CalculatedFieldManagerMessageProcessor extends AbstractContextAware cfCtx.init(); } catch (Exception e) { throw CalculatedFieldException.builder().ctx(cfCtx).eventEntity(cf.getEntityId()).cause(e).errorMessage("Failed to initialize CF context").build(); + } finally { + calculatedFields.put(cf.getId(), cfCtx); + // We use copy on write lists to safely pass the reference to another actor for the iteration. + // Alternative approach would be to use any list but avoid modifications to the list (change the complete map value instead) + entityIdCalculatedFields.computeIfAbsent(cf.getEntityId(), id -> new CopyOnWriteArrayList<>()).add(cfCtx); } - calculatedFields.put(cf.getId(), cfCtx); - // We use copy on write lists to safely pass the reference to another actor for the iteration. - // Alternative approach would be to use any list but avoid modifications to the list (change the complete map value instead) - entityIdCalculatedFields.computeIfAbsent(cf.getEntityId(), id -> new CopyOnWriteArrayList<>()).add(cfCtx); } private void initCalculatedFieldLink(CalculatedFieldLink link) { 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 2e3321eece..c9eaaef19a 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 @@ -110,6 +110,7 @@ public class CalculatedFieldCtx { this.calculatedFieldScriptEngine = initEngine(tenantId, expression, tbelInvokeService); initialized = true; } catch (Exception e) { + initialized = false; throw new RuntimeException("Failed to init calculated field ctx. Invalid expression syntax.", e); } } else { @@ -123,6 +124,7 @@ public class CalculatedFieldCtx { ); initialized = true; } else { + initialized = false; throw new RuntimeException("Failed to init calculated field ctx. Invalid expression syntax."); } } 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 c8b8b0244b..da4b5758cc 100644 --- a/application/src/test/java/org/thingsboard/server/cf/CalculatedFieldIntegrationTest.java +++ b/application/src/test/java/org/thingsboard/server/cf/CalculatedFieldIntegrationTest.java @@ -606,6 +606,59 @@ public class CalculatedFieldIntegrationTest extends CalculatedFieldControllerTes }); } + @Test + public void testSimpleCalculatedFieldWhenCtxBecameUninitialized() throws Exception { + Device testDevice = createDevice("Test device", "1234567890"); + + CalculatedField calculatedField = new CalculatedField(); + calculatedField.setEntityId(testDevice.getId()); + calculatedField.setType(CalculatedFieldType.SIMPLE); + calculatedField.setName("M + 1"); + calculatedField.setDebugSettings(DebugSettings.all()); + + SimpleCalculatedFieldConfiguration config = new SimpleCalculatedFieldConfiguration(); + + Argument argument = new Argument(); + ReferencedEntityKey refEntityKey = new ReferencedEntityKey("m", ArgumentType.TS_LATEST, null); + argument.setRefEntityKey(refEntityKey); + config.setArguments(Map.of("m", argument)); + config.setExpression("m + 1"); + + Output output = new Output(); + output.setName("m1"); + output.setType(OutputType.TIME_SERIES); + output.setDecimalsByDefault(0); + config.setOutput(output); + + calculatedField.setConfiguration(config); + + calculatedField = doPost("/api/calculatedField", calculatedField, CalculatedField.class); + + doPost("/api/plugins/telemetry/DEVICE/" + testDevice.getUuidId() + "/timeseries/" + DataConstants.SERVER_SCOPE, JacksonUtil.toJsonNode("{\"m\":1}")); + + await().alias("create CF -> ctx is initialized -> perform calculation").atMost(TIMEOUT, TimeUnit.SECONDS) + .pollInterval(POLL_INTERVAL, TimeUnit.SECONDS) + .untilAsserted(() -> { + ObjectNode m1 = getLatestTelemetry(testDevice.getId(), "m1"); + assertThat(m1).isNotNull(); + assertThat(m1.get("m1").get(0).get("value").asText()).isEqualTo("2"); + }); + + config.setExpression("m m"); + calculatedField.setConfiguration(config); + calculatedField = doPost("/api/calculatedField", calculatedField, CalculatedField.class); + + doPost("/api/plugins/telemetry/DEVICE/" + testDevice.getUuidId() + "/timeseries/" + DataConstants.SERVER_SCOPE, JacksonUtil.toJsonNode("{\"m\":2}")); + + await().alias("update CF -> ctx is not initialized -> no calculation performed").atMost(TIMEOUT, TimeUnit.SECONDS) + .pollInterval(POLL_INTERVAL, TimeUnit.SECONDS) + .untilAsserted(() -> { + ObjectNode m1 = getLatestTelemetry(testDevice.getId(), "m1"); + assertThat(m1).isNotNull(); + assertThat(m1.get("m1").get(0).get("value").asText()).isEqualTo("2"); + }); + } + private ObjectNode getLatestTelemetry(EntityId entityId, String... keys) throws Exception { return doGetAsync("/api/plugins/telemetry/" + entityId.getEntityType() + "/" + entityId.getId() + "/values/timeseries?keys=" + String.join(",", keys), ObjectNode.class); } From 2cb05c9d2b975930db26371f81dcefeb36824c40 Mon Sep 17 00:00:00 2001 From: dshvaika Date: Thu, 2 Oct 2025 13:35:51 +0300 Subject: [PATCH 060/102] Added tests & temporary removed RelationQueryDynamicSourceConfiguration from interface --- .../CfArgumentDynamicSourceConfiguration.java | 1 - ...thQueryDynamicSourceConfigurationTest.java | 126 ++++++++++++++++++ ...onQueryDynamicSourceConfigurationTest.java | 14 +- .../dao/service/RelationServiceTest.java | 53 +++++++- 4 files changed, 183 insertions(+), 11 deletions(-) create mode 100644 common/data/src/test/java/org/thingsboard/server/common/data/cf/configuration/RelationPathQueryDynamicSourceConfigurationTest.java diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/CfArgumentDynamicSourceConfiguration.java b/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/CfArgumentDynamicSourceConfiguration.java index 397b1d016e..ff746161e9 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/CfArgumentDynamicSourceConfiguration.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/CfArgumentDynamicSourceConfiguration.java @@ -26,7 +26,6 @@ import com.fasterxml.jackson.annotation.JsonTypeInfo; property = "type" ) @JsonSubTypes({ - @JsonSubTypes.Type(value = RelationQueryDynamicSourceConfiguration.class, name = "RELATION_QUERY"), @JsonSubTypes.Type(value = RelationPathQueryDynamicSourceConfiguration.class, name = "RELATION_PATH_QUERY") }) @JsonIgnoreProperties(ignoreUnknown = true) diff --git a/common/data/src/test/java/org/thingsboard/server/common/data/cf/configuration/RelationPathQueryDynamicSourceConfigurationTest.java b/common/data/src/test/java/org/thingsboard/server/common/data/cf/configuration/RelationPathQueryDynamicSourceConfigurationTest.java new file mode 100644 index 0000000000..1a6ea5de3b --- /dev/null +++ b/common/data/src/test/java/org/thingsboard/server/common/data/cf/configuration/RelationPathQueryDynamicSourceConfigurationTest.java @@ -0,0 +1,126 @@ +/** + * 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.common.data.cf.configuration; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.NullAndEmptySource; +import org.mockito.junit.jupiter.MockitoExtension; +import org.thingsboard.server.common.data.id.EntityId; +import org.thingsboard.server.common.data.relation.EntityRelation; +import org.thingsboard.server.common.data.relation.EntitySearchDirection; +import org.thingsboard.server.common.data.relation.RelationPathLevel; + +import java.util.ArrayList; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatCode; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +public class RelationPathQueryDynamicSourceConfigurationTest { + + @Test + void typeShouldBeRelationQuery() { + var cfg = new RelationPathQueryDynamicSourceConfiguration(); + assertThat(cfg.getType()).isEqualTo(CFArgumentDynamicSourceType.RELATION_PATH_QUERY); + } + + @ParameterizedTest + @NullAndEmptySource + void validateShouldThrowWhenLevelsIsNull(List levels) { + var cfg = new RelationPathQueryDynamicSourceConfiguration(); + cfg.setLevels(levels); + + assertThatThrownBy(cfg::validate) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("At least one relation level must be specified!"); + } + + @Test + void validateShouldCallValidateForPathLevels() { + List levels = new ArrayList<>(); + + RelationPathLevel lvl1 = mock(RelationPathLevel.class); + RelationPathLevel lvl2 = mock(RelationPathLevel.class); + levels.add(lvl1); + levels.add(lvl2); + + var cfg = new RelationPathQueryDynamicSourceConfiguration(); + cfg.setLevels(levels); + + assertThatCode(cfg::validate).doesNotThrowAnyException(); + + verify(lvl1).validate(); + verify(lvl2).validate(); + } + + @Test + void resolveEntityIds_whenDirectionFROM_thenReturnsToIds() { + List levels = new ArrayList<>(); + + RelationPathLevel lvl1 = mock(RelationPathLevel.class); + RelationPathLevel lvl2 = mock(RelationPathLevel.class); + levels.add(lvl1); + levels.add(lvl2); + + when(lvl2.direction()).thenReturn(EntitySearchDirection.FROM); + + EntityRelation rel1 = mock(EntityRelation.class); + EntityRelation rel2 = mock(EntityRelation.class); + + when(rel1.getTo()).thenReturn(mock(EntityId.class)); + when(rel2.getTo()).thenReturn(mock(EntityId.class)); + + var cfg = new RelationPathQueryDynamicSourceConfiguration(); + cfg.setLevels(levels); + + var out = cfg.resolveEntityIds(List.of(rel1, rel2)); + + assertThat(out).containsExactly(rel1.getTo(), rel2.getTo()); + } + + @Test + void resolveEntityIds_whenDirectionTO_thenReturnsFromIds() { + List levels = new ArrayList<>(); + + RelationPathLevel lvl1 = mock(RelationPathLevel.class); + RelationPathLevel lvl2 = mock(RelationPathLevel.class); + levels.add(lvl1); + levels.add(lvl2); + + when(lvl2.direction()).thenReturn(EntitySearchDirection.TO); + + EntityRelation rel1 = mock(EntityRelation.class); + EntityRelation rel2 = mock(EntityRelation.class); + + when(rel1.getFrom()).thenReturn(mock(EntityId.class)); + when(rel2.getFrom()).thenReturn(mock(EntityId.class)); + + var cfg = new RelationPathQueryDynamicSourceConfiguration(); + cfg.setLevels(levels); + + var out = cfg.resolveEntityIds(List.of(rel1, rel2)); + + assertThat(out).containsExactly(rel1.getFrom(), rel2.getFrom()); + } + +} diff --git a/common/data/src/test/java/org/thingsboard/server/common/data/cf/configuration/RelationQueryDynamicSourceConfigurationTest.java b/common/data/src/test/java/org/thingsboard/server/common/data/cf/configuration/RelationQueryDynamicSourceConfigurationTest.java index 86fa52ba66..afd78e47f9 100644 --- a/common/data/src/test/java/org/thingsboard/server/common/data/cf/configuration/RelationQueryDynamicSourceConfigurationTest.java +++ b/common/data/src/test/java/org/thingsboard/server/common/data/cf/configuration/RelationQueryDynamicSourceConfigurationTest.java @@ -123,9 +123,8 @@ public class RelationQueryDynamicSourceConfigurationTest { .hasMessage("Relation query dynamic source configuration relation type must be specified!"); } - @ParameterizedTest - @NullAndEmptySource - void isSimpleRelationTrueWhenLevelIsOneAndEntityTypesEmptyOrNull(List entityTypes) { + @Test + void isSimpleRelationTrueWhenLevelIsOneAndEntityTypesEmptyOrNull() { var cfg = new RelationQueryDynamicSourceConfiguration(); cfg.setMaxLevel(1); assertThat(cfg.isSimpleRelation()).isTrue(); @@ -138,9 +137,8 @@ public class RelationQueryDynamicSourceConfigurationTest { assertThat(cfg.isSimpleRelation()).isFalse(); } - @ParameterizedTest - @NullAndEmptySource - void toEntityRelationsQueryShouldThrowForSimpleRelation(List entityTypes) { + @Test + void toEntityRelationsQueryShouldThrowForSimpleRelation() { var cfg = new RelationQueryDynamicSourceConfiguration(); cfg.setMaxLevel(1); cfg.setFetchLastLevelOnly(false); @@ -177,7 +175,7 @@ public class RelationQueryDynamicSourceConfigurationTest { } @Test - void resolveEntityIdsFromDirectionFROMReturnsToIds() { + void resolveEntityIds_whenDirectionFROM_thenReturnsToIds() { when(rel1.getTo()).thenReturn(mock(EntityId.class)); when(rel2.getTo()).thenReturn(mock(EntityId.class)); @@ -190,7 +188,7 @@ public class RelationQueryDynamicSourceConfigurationTest { } @Test - void resolveEntityIdsFromDirectionTOReturnsFromIds() { + void resolveEntityIds_whenDirectionTO_thenReturnsFromIds() { when(rel1.getFrom()).thenReturn(mock(EntityId.class)); when(rel2.getFrom()).thenReturn(mock(EntityId.class)); diff --git a/dao/src/test/java/org/thingsboard/server/dao/service/RelationServiceTest.java b/dao/src/test/java/org/thingsboard/server/dao/service/RelationServiceTest.java index 063e35ae80..bb7bfad677 100644 --- a/dao/src/test/java/org/thingsboard/server/dao/service/RelationServiceTest.java +++ b/dao/src/test/java/org/thingsboard/server/dao/service/RelationServiceTest.java @@ -28,9 +28,11 @@ import org.thingsboard.server.common.data.EntityType; import org.thingsboard.server.common.data.id.AssetId; import org.thingsboard.server.common.data.id.DeviceId; import org.thingsboard.server.common.data.relation.EntityRelation; +import org.thingsboard.server.common.data.relation.EntityRelationPathQuery; import org.thingsboard.server.common.data.relation.EntityRelationsQuery; import org.thingsboard.server.common.data.relation.EntitySearchDirection; import org.thingsboard.server.common.data.relation.RelationEntityTypeFilter; +import org.thingsboard.server.common.data.relation.RelationPathLevel; import org.thingsboard.server.common.data.relation.RelationTypeGroup; import org.thingsboard.server.common.data.relation.RelationsSearchParameters; import org.thingsboard.server.dao.exception.DataValidationException; @@ -42,6 +44,8 @@ import java.util.LinkedList; import java.util.List; import java.util.concurrent.ExecutionException; +import static org.assertj.core.api.Assertions.assertThat; + @DaoSqlTest public class RelationServiceTest extends AbstractServiceTest { @@ -348,14 +352,14 @@ public class RelationServiceTest extends AbstractServiceTest { query.setFilters(Collections.singletonList(new RelationEntityTypeFilter(EntityRelation.CONTAINS_TYPE, Collections.singletonList(EntityType.ASSET)))); List relations = relationService.findByQuery(SYSTEM_TENANT_ID, query).get(); Assert.assertEquals(expected.size(), relations.size()); - for(EntityRelation r : expected){ + for (EntityRelation r : expected) { Assert.assertTrue(relations.contains(r)); } //Test from cache relations = relationService.findByQuery(SYSTEM_TENANT_ID, query).get(); Assert.assertEquals(expected.size(), relations.size()); - for(EntityRelation r : expected){ + for (EntityRelation r : expected) { Assert.assertTrue(relations.contains(r)); } } @@ -623,6 +627,51 @@ public class RelationServiceTest extends AbstractServiceTest { Assert.assertTrue(relations.contains(relationF)); } + @Test + public void testFindByPathQuery() throws Exception { + /* + A + └──[firstLevel, TO]→ B + └──[secondLevel, TO]→ C + ├──[thirdLevel, FROM]→ D + ├──[thirdLevel, FROM]→ E + └──[thirdLevel, FROM]→ F + */ + // rootEntity + AssetId assetA = new AssetId(Uuids.timeBased()); + // firstLevelEntity + AssetId assetB = new AssetId(Uuids.timeBased()); + // secondLevelEntity + AssetId assetC = new AssetId(Uuids.timeBased()); + // thirdLevelEntities + AssetId assetD = new AssetId(Uuids.timeBased()); + AssetId assetE = new AssetId(Uuids.timeBased()); + AssetId assetF = new AssetId(Uuids.timeBased()); + + EntityRelation firstLevelRelation = new EntityRelation(assetB, assetA, "firstLevel"); + EntityRelation secondLevelRelation = new EntityRelation(assetC, assetB, "secondLevel"); + EntityRelation thirdLevelRelation1 = new EntityRelation(assetC, assetD, "thirdLevel"); + EntityRelation thirdLevelRelation2 = new EntityRelation(assetC, assetE, "thirdLevel"); + EntityRelation thirdLevelRelation3 = new EntityRelation(assetC, assetF, "thirdLevel"); + + firstLevelRelation = saveRelation(firstLevelRelation); + secondLevelRelation = saveRelation(secondLevelRelation); + thirdLevelRelation1 = saveRelation(thirdLevelRelation1); + thirdLevelRelation2 = saveRelation(thirdLevelRelation2); + thirdLevelRelation3 = saveRelation(thirdLevelRelation3); + + List expectedRelations = List.of(thirdLevelRelation1, thirdLevelRelation2, thirdLevelRelation3); + + EntityRelationPathQuery relationPathQuery = new EntityRelationPathQuery(assetA, List.of( + new RelationPathLevel(EntitySearchDirection.TO, "firstLevel"), + new RelationPathLevel(EntitySearchDirection.TO, "secondLevel"), + new RelationPathLevel(EntitySearchDirection.FROM, "thirdLevel") + )); + List entityRelations = relationService.findByRelationPathQueryAsync(tenantId, relationPathQuery).get(); + + assertThat(expectedRelations).containsExactlyInAnyOrderElementsOf(entityRelations); + } + @Test public void testFindByQueryLargeHierarchyFetchAllWithUnlimLvl() throws Exception { AssetId rootAsset = new AssetId(Uuids.timeBased()); From 14ee34889609201de5703dc091cd758fce3097e4 Mon Sep 17 00:00:00 2001 From: Dmytro Skarzhynets Date: Thu, 2 Oct 2025 15:10:40 +0300 Subject: [PATCH 061/102] Remove deprecated Sonatype repo --- pom.xml | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/pom.xml b/pom.xml index 189e81411f..fd49af548f 100755 --- a/pom.xml +++ b/pom.xml @@ -1929,9 +1929,6 @@ Typesafe Repository https://repo.typesafe.com/typesafe/releases/ - - sonatype - https://oss.sonatype.org/content/groups/public - + From 00700afd83637c752f3fe340bebb723dc5c87845 Mon Sep 17 00:00:00 2001 From: dashevchenko Date: Thu, 2 Oct 2025 15:29:38 +0300 Subject: [PATCH 062/102] fixed pubAck publications --- .../AbstractGatewaySessionHandler.java | 250 ++++++++++++------ .../session/SparkplugNodeSessionHandler.java | 27 +- 2 files changed, 194 insertions(+), 83 deletions(-) diff --git a/common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/session/AbstractGatewaySessionHandler.java b/common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/session/AbstractGatewaySessionHandler.java index af8ed2aa6d..168fef6c2e 100644 --- a/common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/session/AbstractGatewaySessionHandler.java +++ b/common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/session/AbstractGatewaySessionHandler.java @@ -80,6 +80,8 @@ import java.util.Set; import java.util.UUID; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ConcurrentMap; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicInteger; import java.util.concurrent.locks.Lock; import java.util.concurrent.locks.ReentrantLock; import java.util.function.Consumer; @@ -405,18 +407,34 @@ public abstract class AbstractGatewaySessionHandler deviceEntry : json.getAsJsonObject().entrySet()) { - if (!deviceEntry.getValue().isJsonArray()) { - log.warn("{}[{}]", CAN_T_PARSE_VALUE, json); - continue; - } + + List> deviceEntries = json.getAsJsonObject().entrySet().stream() + .filter(entry -> { + final boolean isArray = entry.getValue().isJsonArray(); + if (!isArray) { + log.warn("{} device='{}' value={}", CAN_T_PARSE_VALUE, entry.getKey(), entry.getValue()); + } + return isArray; + }) + .toList(); + + if (deviceEntries.isEmpty()) { + log.debug("[{}][{}][{}] Devices telemetry message is empty", gateway.getTenantId(), gateway.getDeviceId(), sessionId); + throw new IllegalArgumentException("[" + sessionId + "] Devices telemetry message is empty for [" + gateway.getDeviceId() + "]"); + } + + AtomicInteger remaining = new AtomicInteger(deviceEntries.size()); + AtomicBoolean ackSent = new AtomicBoolean(false); + + for (Map.Entry deviceEntry : deviceEntries) { String deviceName = deviceEntry.getKey(); - process(deviceName, deviceCtx -> processPostTelemetryMsg(deviceCtx, deviceEntry.getValue(), deviceName, msgId), - t -> processFailure(msgId, deviceName, TELEMETRY, t)); + process(deviceName, deviceCtx -> processPostTelemetryMsg(deviceCtx, deviceEntry.getValue(), deviceName, msgId, + remaining, ackSent), + t -> processFailure(msgId, deviceName, TELEMETRY, ackSent, t)); } } - private void processPostTelemetryMsg(T deviceCtx, JsonElement msg, String deviceName, int msgId) { + private void processPostTelemetryMsg(T deviceCtx, JsonElement msg, String deviceName, int msgId, AtomicInteger remaining, AtomicBoolean ackSent) { try { long systemTs = System.currentTimeMillis(); TbPair> gatewayPayloadPair = JsonConverter.convertToGatewayTelemetry(msg.getAsJsonArray(), systemTs); @@ -425,10 +443,10 @@ public abstract class AbstractGatewaySessionHandler { String deviceName = checkDeviceName(telemetryMsg.getDeviceName()); - process(deviceName, deviceCtx -> processPostTelemetryMsg(deviceCtx, telemetryMsg.getMsg(), deviceName, msgId), - t -> processFailure(msgId, deviceName, TELEMETRY, t)); + process(deviceName, deviceCtx -> processPostTelemetryMsg(deviceCtx, telemetryMsg.getMsg(), deviceName, msgId, + remaining, ackSent), + t -> processFailure(msgId, deviceName, TELEMETRY, ackSent, t)); }); } catch (RuntimeException | InvalidProtocolBufferException e) { throw new AdaptorException(e); } } - protected void processPostTelemetryMsg(MqttDeviceAwareSessionContext deviceCtx, TransportProtos.PostTelemetryMsg msg, String deviceName, int msgId) { + protected void processPostTelemetryMsg(MqttDeviceAwareSessionContext deviceCtx, TransportProtos.PostTelemetryMsg msg, String deviceName, int msgId, + AtomicInteger remaining, AtomicBoolean ackSent) { try { TransportProtos.PostTelemetryMsg postTelemetryMsg = ProtoConverter.validatePostTelemetryMsg(msg.toByteArray()); - transportService.process(deviceCtx.getSessionInfo(), postTelemetryMsg, getPubAckCallback(channel, deviceName, msgId, postTelemetryMsg)); + transportService.process(deviceCtx.getSessionInfo(), postTelemetryMsg, getAggregatePubAckCallback(channel, msgId, deviceName, postTelemetryMsg, remaining, ackSent)); } catch (Throwable e) { log.warn("[{}][{}][{}] Failed to convert telemetry: [{}]", gateway.getTenantId(), gateway.getDeviceId(), deviceName, msg, e); - ackOrClose(msgId); + ackOrClose(msgId, ackSent); } } @@ -475,26 +498,42 @@ public abstract class AbstractGatewaySessionHandler deviceEntry : json.getAsJsonObject().entrySet()) { - if (!deviceEntry.getValue().isJsonObject()) { - log.warn("{}[{}]", CAN_T_PARSE_VALUE, json); - continue; - } + List> deviceEntries = json.getAsJsonObject().entrySet().stream() + .filter(entry -> { + boolean isJsonObject = entry.getValue().isJsonObject(); + if (!isJsonObject) { + log.warn("{} device='{}' value={}", CAN_T_PARSE_VALUE, entry.getKey(), entry.getValue()); + } + return isJsonObject; + }) + .toList(); + + if (deviceEntries.isEmpty()) { + log.debug("[{}][{}][{}] Devices claim message is empty", gateway.getTenantId(), gateway.getDeviceId(), sessionId); + throw new IllegalArgumentException("[" + sessionId + "] Devices claim message is empty for [" + gateway.getDeviceId() + "]"); + } + + AtomicInteger remaining = new AtomicInteger(deviceEntries.size()); + AtomicBoolean ackSent = new AtomicBoolean(false); + + for (Map.Entry deviceEntry : deviceEntries) { String deviceName = deviceEntry.getKey(); - process(deviceName, deviceCtx -> processClaimDeviceMsg(deviceCtx, deviceEntry.getValue(), deviceName, msgId), - t -> processFailure(msgId, deviceName, CLAIMING, t)); + process(deviceName, deviceCtx -> processClaimDeviceMsg(deviceCtx, deviceEntry.getValue(), deviceName, msgId, + remaining, ackSent), + t -> processFailure(msgId, deviceName, CLAIMING, ackSent, t)); } } - private void processClaimDeviceMsg(MqttDeviceAwareSessionContext deviceCtx, JsonElement claimRequest, String deviceName, int msgId) { + private void processClaimDeviceMsg(MqttDeviceAwareSessionContext deviceCtx, JsonElement claimRequest, String deviceName, int msgId, + AtomicInteger remaining, AtomicBoolean ackSent) { try { DeviceId deviceId = deviceCtx.getDeviceId(); TransportProtos.ClaimDeviceMsg claimDeviceMsg = JsonConverter.convertToClaimDeviceProto(deviceId, claimRequest); - transportService.process(deviceCtx.getSessionInfo(), claimDeviceMsg, getPubAckCallback(channel, deviceName, msgId, claimDeviceMsg)); + transportService.process(deviceCtx.getSessionInfo(), claimDeviceMsg, getAggregatePubAckCallback(channel, msgId, deviceName, claimDeviceMsg, remaining, ackSent)); } catch (Throwable e) { log.warn("[{}][{}][{}] Failed to convert claim message: [{}]", gateway.getTenantId(), gateway.getDeviceId(), deviceName, claimRequest, e); - ackOrClose(msgId); + ackOrClose(msgId, ackSent); } } @@ -507,49 +546,70 @@ public abstract class AbstractGatewaySessionHandler { String deviceName = checkDeviceName(claimDeviceMsg.getDeviceName()); - process(deviceName, deviceCtx -> processClaimDeviceMsg(deviceCtx, claimDeviceMsg.getClaimRequest(), deviceName, msgId), - t -> processFailure(msgId, deviceName, CLAIMING, t)); + process(deviceName, deviceCtx -> processClaimDeviceMsg(deviceCtx, claimDeviceMsg.getClaimRequest(), deviceName, msgId, + remaining, ackSent), + t -> processFailure(msgId, deviceName, CLAIMING, ackSent, t)); }); } catch (RuntimeException | InvalidProtocolBufferException e) { throw new AdaptorException(e); } } - private void processClaimDeviceMsg(MqttDeviceAwareSessionContext deviceCtx, TransportApiProtos.ClaimDevice claimRequest, String deviceName, int msgId) { + private void processClaimDeviceMsg(MqttDeviceAwareSessionContext deviceCtx, TransportApiProtos.ClaimDevice claimRequest, String deviceName, int msgId, + AtomicInteger remaining, AtomicBoolean ackSent) { try { DeviceId deviceId = deviceCtx.getDeviceId(); TransportProtos.ClaimDeviceMsg claimDeviceMsg = ProtoConverter.convertToClaimDeviceProto(deviceId, claimRequest.toByteArray()); - transportService.process(deviceCtx.getSessionInfo(), claimDeviceMsg, getPubAckCallback(channel, deviceName, msgId, claimDeviceMsg)); + transportService.process(deviceCtx.getSessionInfo(), claimDeviceMsg, getAggregatePubAckCallback(channel, msgId, deviceName, claimDeviceMsg, remaining, ackSent)); } catch (Throwable e) { log.warn("[{}][{}][{}] Failed to convert claim message: [{}]", gateway.getTenantId(), gateway.getDeviceId(), deviceName, claimRequest, e); - ackOrClose(msgId); + ackOrClose(msgId, ackSent); } } private void onDeviceAttributesJson(int msgId, ByteBuf payload) throws AdaptorException { JsonElement json = JsonMqttAdaptor.validateJsonPayload(sessionId, payload); validateJsonObject(json); - for (Map.Entry deviceEntry : json.getAsJsonObject().entrySet()) { - if (!deviceEntry.getValue().isJsonObject()) { - log.warn("{}[{}]", CAN_T_PARSE_VALUE, json); - continue; - } + List> deviceEntries = json.getAsJsonObject().entrySet().stream() + .filter(entry -> { + boolean isJsonObject = entry.getValue().isJsonObject(); + if (!isJsonObject) { + log.warn("{} device='{}' value={}", CAN_T_PARSE_VALUE, entry.getKey(), entry.getValue()); + } + return isJsonObject; + }) + .toList(); + + if (deviceEntries.isEmpty()) { + log.debug("[{}][{}][{}] Devices attribute message is empty", gateway.getTenantId(), gateway.getDeviceId(), sessionId); + throw new IllegalArgumentException("[" + sessionId + "] Devices attribute message is empty for [" + gateway.getDeviceId() + "]"); + } + + AtomicInteger remaining = new AtomicInteger(deviceEntries.size()); + AtomicBoolean ackSent = new AtomicBoolean(false); + + for (Map.Entry deviceEntry : deviceEntries) { String deviceName = deviceEntry.getKey(); - process(deviceName, deviceCtx -> processPostAttributesMsg(deviceCtx, deviceEntry.getValue(), deviceName, msgId), - t -> processFailure(msgId, deviceName, ATTRIBUTE, t)); + process(deviceName, deviceCtx -> processPostAttributesMsg(deviceCtx, deviceEntry.getValue(), deviceName, msgId, + remaining, ackSent), + t -> processFailure(msgId, deviceName, ATTRIBUTE, ackSent, t)); } } - private void processPostAttributesMsg(MqttDeviceAwareSessionContext deviceCtx, JsonElement msg, String deviceName, int msgId) { + private void processPostAttributesMsg(MqttDeviceAwareSessionContext deviceCtx, JsonElement msg, String deviceName, int msgId, + AtomicInteger remaining, AtomicBoolean ackSent) { try { TransportProtos.PostAttributeMsg postAttributeMsg = JsonConverter.convertToAttributesProto(msg.getAsJsonObject()); - transportService.process(deviceCtx.getSessionInfo(), postAttributeMsg, getPubAckCallback(channel, deviceName, msgId, postAttributeMsg)); + transportService.process(deviceCtx.getSessionInfo(), postAttributeMsg, getAggregatePubAckCallback(channel, msgId, deviceName, postAttributeMsg, remaining, ackSent)); } catch (Throwable e) { log.warn("[{}][{}][{}] Failed to process device attributes command: [{}]", gateway.getTenantId(), gateway.getDeviceId(), deviceName, msg, e); - ackOrClose(msgId); + ackOrClose(msgId, ackSent); } } @@ -562,23 +622,28 @@ public abstract class AbstractGatewaySessionHandler { String deviceName = checkDeviceName(attributesMsg.getDeviceName()); - process(deviceName, deviceCtx -> processPostAttributesMsg(deviceCtx, attributesMsg.getMsg(), deviceName, msgId), - t -> processFailure(msgId, deviceName, ATTRIBUTE, t)); + process(deviceName, deviceCtx -> processPostAttributesMsg(deviceCtx, attributesMsg.getMsg(), deviceName, msgId, + remaining, ackSent), + t -> processFailure(msgId, deviceName, ATTRIBUTE, ackSent, t)); }); } catch (RuntimeException | InvalidProtocolBufferException e) { throw new AdaptorException(e); } } - protected void processPostAttributesMsg(MqttDeviceAwareSessionContext deviceCtx, TransportProtos.PostAttributeMsg kvListProto, String deviceName, int msgId) { + protected void processPostAttributesMsg(MqttDeviceAwareSessionContext deviceCtx, TransportProtos.PostAttributeMsg kvListProto, String deviceName, int msgId, + AtomicInteger remaining, AtomicBoolean ackSent) { try { TransportProtos.PostAttributeMsg postAttributeMsg = ProtoConverter.validatePostAttributeMsg(kvListProto); - transportService.process(deviceCtx.getSessionInfo(), postAttributeMsg, getPubAckCallback(channel, deviceName, msgId, postAttributeMsg)); + transportService.process(deviceCtx.getSessionInfo(), postAttributeMsg, getAggregatePubAckCallback(channel, msgId, deviceName, postAttributeMsg, remaining, ackSent)); } catch (Throwable e) { log.warn("[{}][{}][{}] Failed to process device attributes command: [{}]", gateway.getTenantId(), gateway.getDeviceId(), deviceName, kvListProto, e); - ackOrClose(msgId); + ackOrClose(msgId, ackSent); } } @@ -647,26 +712,34 @@ public abstract class AbstractGatewaySessionHandler processRpcResponseMsg(deviceCtx, requestId, data, deviceName, msgId), - t -> processFailure(msgId, deviceName, RPC_RESPONSE, t)); + AtomicInteger remaining = new AtomicInteger(1); + AtomicBoolean ackSent = new AtomicBoolean(false); + process(deviceName, deviceCtx -> processRpcResponseMsg(deviceCtx, requestId, data, deviceName, msgId, remaining, ackSent), + t -> processFailure(msgId, deviceName, RPC_RESPONSE, ackSent, t)); } - private void processRpcResponseMsg(MqttDeviceAwareSessionContext deviceCtx, Integer requestId, String data, String deviceName, int msgId) { + private void processRpcResponseMsg(MqttDeviceAwareSessionContext deviceCtx, Integer requestId, String data, String deviceName, + int msgId, AtomicInteger remaining, AtomicBoolean ackSent) { TransportProtos.ToDeviceRpcResponseMsg rpcResponseMsg = TransportProtos.ToDeviceRpcResponseMsg.newBuilder() .setRequestId(requestId).setPayload(data).build(); - transportService.process(deviceCtx.getSessionInfo(), rpcResponseMsg, getPubAckCallback(channel, deviceName, msgId, rpcResponseMsg)); + transportService.process(deviceCtx.getSessionInfo(), rpcResponseMsg, + getAggregatePubAckCallback(channel, msgId, deviceName, rpcResponseMsg, remaining, ackSent)); } private void processGetAttributeRequestMessage(MqttPublishMessage mqttMsg, String deviceName, TransportProtos.GetAttributeRequestMsg requestMsg) { int msgId = getMsgId(mqttMsg); - process(deviceName, deviceCtx -> processGetAttributeRequestMessage(deviceCtx, requestMsg, deviceName, msgId), - t -> { - processFailure(msgId, deviceName, ATTRIBUTES_REQUEST, t, MqttReasonCodes.PubAck.IMPLEMENTATION_SPECIFIC_ERROR); - }); + AtomicInteger remaining = new AtomicInteger(1); + AtomicBoolean ackSent = new AtomicBoolean(false); + process(deviceName, deviceCtx -> { + processGetAttributeRequestMessage(deviceCtx, requestMsg, deviceName, msgId, remaining, ackSent); + }, + t -> processFailure(msgId, deviceName, ATTRIBUTES_REQUEST, ackSent, MqttReasonCodes.PubAck.IMPLEMENTATION_SPECIFIC_ERROR, t)); } - private void processGetAttributeRequestMessage(T deviceCtx, TransportProtos.GetAttributeRequestMsg requestMsg, String deviceName, int msgId) { - transportService.process(deviceCtx.getSessionInfo(), requestMsg, getPubAckCallback(channel, deviceName, msgId, requestMsg)); + private void processGetAttributeRequestMessage(T deviceCtx, TransportProtos.GetAttributeRequestMsg requestMsg, + String deviceName, int msgId, AtomicInteger remaining, AtomicBoolean ackSent) { + transportService.process(deviceCtx.getSessionInfo(), requestMsg, + getAggregatePubAckCallback(channel, msgId, deviceName, requestMsg, remaining, ackSent)); } private TransportProtos.GetAttributeRequestMsg toGetAttributeRequestMsg(int requestId, boolean clientScope, Set keys) { @@ -717,9 +790,11 @@ public abstract class AbstractGatewaySessionHandler pubAckCallback = getAggregatePubAckCallback(channel, -1, deviceName, postTelemetryMsg, + new AtomicInteger(1), new AtomicBoolean(false)); + transportService.process(sessionInfo, postTelemetryMsg, pubAckCallback); } - public ConcurrentMap getDevices () { + public ConcurrentMap getDevices() { return this.devices; } - private TransportServiceCallback getPubAckCallback(final ChannelHandlerContext ctx, final String deviceName, final int msgId, final T msg) { + protected TransportServiceCallback getAggregatePubAckCallback( + final ChannelHandlerContext ctx, + final int msgId, + final String deviceName, + final T msg, + final AtomicInteger remaining, + final AtomicBoolean ackSent) { + return new TransportServiceCallback() { @Override public void onSuccess(Void dummy) { log.trace("[{}][{}][{}][{}] Published msg: [{}]", gateway.getTenantId(), gateway.getDeviceId(), sessionId, deviceName, msg); - if (msgId > 0) { - ctx.writeAndFlush(MqttTransportHandler.createMqttPubAckMsg(deviceSessionCtx, msgId, MqttReasonCodes.PubAck.SUCCESS.byteValue())); - } else { - log.trace("[{}][{}][{}] Wrong msg id: [{}]", gateway.getTenantId(), gateway.getDeviceId(), sessionId, msg); - ctx.writeAndFlush(MqttTransportHandler.createMqttPubAckMsg(deviceSessionCtx, msgId, MqttReasonCodes.PubAck.UNSPECIFIED_ERROR.byteValue())); - closeDeviceSession(deviceName, MqttReasonCodes.Disconnect.MALFORMED_PACKET); + if (remaining.decrementAndGet() == 0 && ackSent.compareAndSet(false, true)) { + if (msgId > 0) { + ctx.writeAndFlush(MqttTransportHandler.createMqttPubAckMsg( + deviceSessionCtx, msgId, MqttReasonCodes.PubAck.SUCCESS.byteValue())); + } else { + log.trace("[{}][{}][{}] Wrong msg id: [{}]", gateway.getTenantId(), gateway.getDeviceId(), sessionId, msgId); + ctx.writeAndFlush(MqttTransportHandler.createMqttPubAckMsg( + deviceSessionCtx, msgId, MqttReasonCodes.PubAck.UNSPECIFIED_ERROR.byteValue())); + closeDeviceSession(deviceName, MqttReasonCodes.Disconnect.MALFORMED_PACKET); + } } } @Override public void onError(Throwable e) { log.trace("[{}][{}][{}] Failed to publish msg: [{}] for device: [{}]", gateway.getTenantId(), gateway.getDeviceId(), sessionId, msg, deviceName, e); - if (e instanceof TbRateLimitsException) { - closeDeviceSession(deviceName, MqttReasonCodes.Disconnect.MESSAGE_RATE_TOO_HIGH); - } else { - closeDeviceSession(deviceName, MqttReasonCodes.Disconnect.UNSPECIFIED_ERROR); + if (ackSent.compareAndSet(false, true)) { + if (e instanceof TbRateLimitsException) { + ctx.writeAndFlush(MqttTransportHandler.createMqttPubAckMsg( + deviceSessionCtx, msgId, MqttReasonCodes.PubAck.QUOTA_EXCEEDED.byteValue())); + closeDeviceSession(deviceName, MqttReasonCodes.Disconnect.MESSAGE_RATE_TOO_HIGH); + } else { + ctx.writeAndFlush(MqttTransportHandler.createMqttPubAckMsg( + deviceSessionCtx, msgId, MqttReasonCodes.PubAck.UNSPECIFIED_ERROR.byteValue())); + closeDeviceSession(deviceName, MqttReasonCodes.Disconnect.UNSPECIFIED_ERROR); + } + ctx.close(); } - ctx.close(); + remaining.set(0); } }; } @@ -789,18 +884,19 @@ public abstract class AbstractGatewaySessionHandler contextListenableFuture, int msgId, List postTelemetryMsgList, String deviceName) { + if (CollectionUtils.isEmpty(postTelemetryMsgList)) { + log.debug("[{}] Device telemetry list is empty for: [{}]", sessionId, gateway.getDeviceId()); + } + + AtomicInteger remaining = new AtomicInteger(postTelemetryMsgList.size()); + AtomicBoolean ackSent = new AtomicBoolean(false); + process(contextListenableFuture, deviceCtx -> { for (TransportProtos.PostTelemetryMsg telemetryMsg : postTelemetryMsgList) { try { - processPostTelemetryMsg(deviceCtx, telemetryMsg, deviceName, msgId); + processPostTelemetryMsg(deviceCtx, telemetryMsg, deviceName, msgId, remaining, ackSent); } catch (Throwable e) { log.warn("[{}][{}] Failed to convert telemetry: {}", gateway.getDeviceId(), deviceName, telemetryMsg, e); - ackOrClose(msgId); + ackOrClose(msgId, ackSent); } } }, - t -> log.debug("[{}] Failed to process device telemetry command: {}", sessionId, deviceName, t)); + t -> processFailure(msgId, deviceName, "Failed to process device telemetry command", ackSent, t)); } private void onDeviceAttributesProto(ListenableFuture contextListenableFuture, int msgId, List attributesMsgList, String deviceName) throws AdaptorException { try { if (CollectionUtils.isEmpty(attributesMsgList)) { - log.debug("[{}] Devices attributes keys list is empty for: [{}]", sessionId, gateway.getDeviceId()); + log.debug("[{}] Device attribute list is empty for: [{}]", sessionId, gateway.getDeviceId()); } + + AtomicInteger remaining = new AtomicInteger(attributesMsgList.size()); + AtomicBoolean ackSent = new AtomicBoolean(false); + process(contextListenableFuture, deviceCtx -> { for (TransportApiProtos.AttributesMsg attributesMsg : attributesMsgList) { TransportProtos.PostAttributeMsg kvListProto = attributesMsg.getMsg(); try { TransportProtos.PostAttributeMsg postAttributeMsg = ProtoConverter.validatePostAttributeMsg(kvListProto); - processPostAttributesMsg(deviceCtx, postAttributeMsg, deviceName, msgId); + processPostAttributesMsg(deviceCtx, postAttributeMsg, deviceName, msgId, remaining, ackSent); } catch (Throwable e) { log.warn("[{}][{}] Failed to process device attributes command: {}", gateway.getDeviceId(), deviceName, kvListProto, e); + ackOrClose(msgId, ackSent); } } }, - t -> log.debug("[{}] Failed to process device attributes command: {}", sessionId, deviceName, t)); + t -> processFailure(msgId, deviceName, "Failed to process device attributes command", ackSent, t)); } catch (RuntimeException e) { throw new AdaptorException(e); } From fcba7004f94ab92521786bdcf3eeb61bd932ce79 Mon Sep 17 00:00:00 2001 From: dshvaika Date: Thu, 2 Oct 2025 16:10:09 +0300 Subject: [PATCH 063/102] fixed tests to use new dynamic source configuration --- .../cf/CalculatedFieldIntegrationTest.java | 25 +++++-------- .../GeofencingCalculatedFieldStateTest.java | 17 +++------ .../data/cf/configuration/ArgumentTest.java | 2 +- .../ZoneGroupConfigurationTest.java | 4 +- .../dao/sql/relation/JpaRelationDao.java | 2 +- .../service/CalculatedFieldServiceTest.java | 37 +++++++++++-------- .../server/msa/cf/CalculatedFieldTest.java | 18 ++++----- 7 files changed, 48 insertions(+), 57 deletions(-) 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 ffd876575c..efcc38304a 100644 --- a/application/src/test/java/org/thingsboard/server/cf/CalculatedFieldIntegrationTest.java +++ b/application/src/test/java/org/thingsboard/server/cf/CalculatedFieldIntegrationTest.java @@ -36,7 +36,7 @@ import org.thingsboard.server.common.data.cf.configuration.CalculatedFieldConfig import org.thingsboard.server.common.data.cf.configuration.Output; import org.thingsboard.server.common.data.cf.configuration.OutputType; import org.thingsboard.server.common.data.cf.configuration.ReferencedEntityKey; -import org.thingsboard.server.common.data.cf.configuration.RelationQueryDynamicSourceConfiguration; +import org.thingsboard.server.common.data.cf.configuration.RelationPathQueryDynamicSourceConfiguration; import org.thingsboard.server.common.data.cf.configuration.ScriptCalculatedFieldConfiguration; import org.thingsboard.server.common.data.cf.configuration.SimpleCalculatedFieldConfiguration; import org.thingsboard.server.common.data.cf.configuration.geofencing.EntityCoordinates; @@ -47,10 +47,12 @@ import org.thingsboard.server.common.data.id.AssetProfileId; import org.thingsboard.server.common.data.id.EntityId; import org.thingsboard.server.common.data.relation.EntityRelation; import org.thingsboard.server.common.data.relation.EntitySearchDirection; +import org.thingsboard.server.common.data.relation.RelationPathLevel; import org.thingsboard.server.controller.CalculatedFieldControllerTest; import org.thingsboard.server.dao.service.DaoSqlTest; import java.util.HashMap; +import java.util.List; import java.util.Map; import java.util.concurrent.TimeUnit; @@ -760,19 +762,13 @@ public class CalculatedFieldIntegrationTest extends CalculatedFieldControllerTes // Zone groups: ATTRIBUTE on specific assets (one zone per group) ZoneGroupConfiguration allowedZonesGroup = new ZoneGroupConfiguration("zone", REPORT_TRANSITION_EVENTS_AND_PRESENCE_STATUS, false); - var allowedZoneDynamicSourceConfiguration = new RelationQueryDynamicSourceConfiguration(); - allowedZoneDynamicSourceConfiguration.setDirection(EntitySearchDirection.FROM); - allowedZoneDynamicSourceConfiguration.setRelationType("AllowedZone"); - allowedZoneDynamicSourceConfiguration.setMaxLevel(1); - allowedZoneDynamicSourceConfiguration.setFetchLastLevelOnly(true); + var allowedZoneDynamicSourceConfiguration = new RelationPathQueryDynamicSourceConfiguration(); + allowedZoneDynamicSourceConfiguration.setLevels(List.of(new RelationPathLevel(EntitySearchDirection.FROM, "AllowedZone"))); allowedZonesGroup.setRefDynamicSourceConfiguration(allowedZoneDynamicSourceConfiguration); ZoneGroupConfiguration restrictedZonesGroup = new ZoneGroupConfiguration("zone", REPORT_TRANSITION_EVENTS_AND_PRESENCE_STATUS, false); - var restrictedZoneDynamicSourceConfiguration = new RelationQueryDynamicSourceConfiguration(); - restrictedZoneDynamicSourceConfiguration.setDirection(EntitySearchDirection.FROM); - restrictedZoneDynamicSourceConfiguration.setRelationType("RestrictedZone"); - restrictedZoneDynamicSourceConfiguration.setMaxLevel(1); - restrictedZoneDynamicSourceConfiguration.setFetchLastLevelOnly(true); + var restrictedZoneDynamicSourceConfiguration = new RelationPathQueryDynamicSourceConfiguration(); + restrictedZoneDynamicSourceConfiguration.setLevels(List.of(new RelationPathLevel(EntitySearchDirection.FROM, "RestrictedZone"))); restrictedZonesGroup.setRefDynamicSourceConfiguration(restrictedZoneDynamicSourceConfiguration); cfg.setZoneGroups(Map.of("allowedZones", allowedZonesGroup, "restrictedZones", restrictedZonesGroup)); @@ -870,11 +866,8 @@ public class CalculatedFieldIntegrationTest extends CalculatedFieldControllerTes cfg.setEntityCoordinates(new EntityCoordinates(ENTITY_ID_LATITUDE_ARGUMENT_KEY, ENTITY_ID_LONGITUDE_ARGUMENT_KEY)); var allowedZonesGroup = new ZoneGroupConfiguration("zone", REPORT_TRANSITION_EVENTS_AND_PRESENCE_STATUS, false); - var allowedZoneDynamicSourceConfiguration = new RelationQueryDynamicSourceConfiguration(); - allowedZoneDynamicSourceConfiguration.setDirection(EntitySearchDirection.FROM); - allowedZoneDynamicSourceConfiguration.setRelationType("AllowedZone"); - allowedZoneDynamicSourceConfiguration.setMaxLevel(1); - allowedZoneDynamicSourceConfiguration.setFetchLastLevelOnly(true); + var allowedZoneDynamicSourceConfiguration = new RelationPathQueryDynamicSourceConfiguration(); + allowedZoneDynamicSourceConfiguration.setLevels(List.of(new RelationPathLevel(EntitySearchDirection.FROM, "AllowedZone"))); allowedZonesGroup.setRefDynamicSourceConfiguration(allowedZoneDynamicSourceConfiguration); cfg.setZoneGroups(Map.of("allowedZones", allowedZonesGroup)); 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 a86af77555..1f1ee32df2 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 @@ -28,7 +28,7 @@ import org.thingsboard.server.common.data.cf.CalculatedFieldType; import org.thingsboard.server.common.data.cf.configuration.CalculatedFieldConfiguration; import org.thingsboard.server.common.data.cf.configuration.Output; import org.thingsboard.server.common.data.cf.configuration.OutputType; -import org.thingsboard.server.common.data.cf.configuration.RelationQueryDynamicSourceConfiguration; +import org.thingsboard.server.common.data.cf.configuration.RelationPathQueryDynamicSourceConfiguration; import org.thingsboard.server.common.data.cf.configuration.geofencing.EntityCoordinates; import org.thingsboard.server.common.data.cf.configuration.geofencing.GeofencingCalculatedFieldConfiguration; import org.thingsboard.server.common.data.cf.configuration.geofencing.GeofencingReportStrategy; @@ -41,6 +41,7 @@ import org.thingsboard.server.common.data.kv.DoubleDataEntry; import org.thingsboard.server.common.data.kv.JsonDataEntry; import org.thingsboard.server.common.data.relation.EntityRelation; import org.thingsboard.server.common.data.relation.EntitySearchDirection; +import org.thingsboard.server.common.data.relation.RelationPathLevel; import org.thingsboard.server.dao.relation.RelationService; import org.thingsboard.server.dao.usagerecord.ApiLimitService; import org.thingsboard.server.service.cf.CalculatedFieldResult; @@ -453,21 +454,15 @@ public class GeofencingCalculatedFieldStateTest { config.setEntityCoordinates(entityCoordinates); ZoneGroupConfiguration allowedZonesGroup = new ZoneGroupConfiguration("zone", reportStrategy, true); - var allowedZoneDynamicSourceConfiguration = new RelationQueryDynamicSourceConfiguration(); - allowedZoneDynamicSourceConfiguration.setDirection(EntitySearchDirection.TO); - allowedZoneDynamicSourceConfiguration.setRelationType("AllowedZone"); - allowedZoneDynamicSourceConfiguration.setMaxLevel(1); - allowedZoneDynamicSourceConfiguration.setFetchLastLevelOnly(true); + var allowedZoneDynamicSourceConfiguration = new RelationPathQueryDynamicSourceConfiguration(); + allowedZoneDynamicSourceConfiguration.setLevels(List.of(new RelationPathLevel(EntitySearchDirection.TO, "AllowedZone"))); allowedZonesGroup.setRefDynamicSourceConfiguration(allowedZoneDynamicSourceConfiguration); allowedZonesGroup.setRelationType("CurrentZone"); allowedZonesGroup.setDirection(EntitySearchDirection.TO); ZoneGroupConfiguration restrictedZonesGroup = new ZoneGroupConfiguration("zone", reportStrategy, true); - var restrictedZoneDynamicSourceConfiguration = new RelationQueryDynamicSourceConfiguration(); - restrictedZoneDynamicSourceConfiguration.setDirection(EntitySearchDirection.TO); - restrictedZoneDynamicSourceConfiguration.setRelationType("RestrictedZone"); - restrictedZoneDynamicSourceConfiguration.setMaxLevel(1); - restrictedZoneDynamicSourceConfiguration.setFetchLastLevelOnly(true); + var restrictedZoneDynamicSourceConfiguration = new RelationPathQueryDynamicSourceConfiguration(); + restrictedZoneDynamicSourceConfiguration.setLevels(List.of(new RelationPathLevel(EntitySearchDirection.TO, "RestrictedZone"))); restrictedZonesGroup.setRefDynamicSourceConfiguration(restrictedZoneDynamicSourceConfiguration); restrictedZonesGroup.setRelationType("CurrentZone"); restrictedZonesGroup.setDirection(EntitySearchDirection.TO); diff --git a/common/data/src/test/java/org/thingsboard/server/common/data/cf/configuration/ArgumentTest.java b/common/data/src/test/java/org/thingsboard/server/common/data/cf/configuration/ArgumentTest.java index fd59317649..d1108e9eed 100644 --- a/common/data/src/test/java/org/thingsboard/server/common/data/cf/configuration/ArgumentTest.java +++ b/common/data/src/test/java/org/thingsboard/server/common/data/cf/configuration/ArgumentTest.java @@ -31,7 +31,7 @@ public class ArgumentTest { @Test void validateShouldReturnTrueIfDynamicSourceConfigurationIsNotNull() { var argument = new Argument(); - argument.setRefDynamicSourceConfiguration(new RelationQueryDynamicSourceConfiguration()); + argument.setRefDynamicSourceConfiguration(new RelationPathQueryDynamicSourceConfiguration()); assertThat(argument.hasDynamicSource()).isTrue(); } diff --git a/common/data/src/test/java/org/thingsboard/server/common/data/cf/configuration/geofencing/ZoneGroupConfigurationTest.java b/common/data/src/test/java/org/thingsboard/server/common/data/cf/configuration/geofencing/ZoneGroupConfigurationTest.java index 4eb822d93c..beb4639a31 100644 --- a/common/data/src/test/java/org/thingsboard/server/common/data/cf/configuration/geofencing/ZoneGroupConfigurationTest.java +++ b/common/data/src/test/java/org/thingsboard/server/common/data/cf/configuration/geofencing/ZoneGroupConfigurationTest.java @@ -23,7 +23,7 @@ import org.thingsboard.server.common.data.AttributeScope; import org.thingsboard.server.common.data.cf.configuration.Argument; import org.thingsboard.server.common.data.cf.configuration.ArgumentType; import org.thingsboard.server.common.data.cf.configuration.ReferencedEntityKey; -import org.thingsboard.server.common.data.cf.configuration.RelationQueryDynamicSourceConfiguration; +import org.thingsboard.server.common.data.cf.configuration.RelationPathQueryDynamicSourceConfiguration; import org.thingsboard.server.common.data.relation.EntityRelation; import org.thingsboard.server.common.data.relation.EntitySearchDirection; @@ -100,7 +100,7 @@ public class ZoneGroupConfigurationTest { @Test void whenHasDynamicSourceCalled_shouldReturnTrueIfDynamicSourceConfigurationIsNotNull() { var zoneGroupConfiguration = new ZoneGroupConfiguration("perimeter", REPORT_TRANSITION_EVENTS_AND_PRESENCE_STATUS, false); - zoneGroupConfiguration.setRefDynamicSourceConfiguration(new RelationQueryDynamicSourceConfiguration()); + zoneGroupConfiguration.setRefDynamicSourceConfiguration(new RelationPathQueryDynamicSourceConfiguration()); assertThat(zoneGroupConfiguration.hasDynamicSource()).isTrue(); } diff --git a/dao/src/main/java/org/thingsboard/server/dao/sql/relation/JpaRelationDao.java b/dao/src/main/java/org/thingsboard/server/dao/sql/relation/JpaRelationDao.java index 782c9b9d53..b2871313ed 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/sql/relation/JpaRelationDao.java +++ b/dao/src/main/java/org/thingsboard/server/dao/sql/relation/JpaRelationDao.java @@ -307,7 +307,7 @@ public class JpaRelationDao extends JpaAbstractDaoListeningExecutorService imple String sql = buildRelationPathSql(query); Object[] params = buildRelationPathParams(query); - log.info("[{}] relation path query: {}", tenantId, sql); + log.trace("[{}] relation path query: {}", tenantId, sql); return jdbcTemplate.queryForList(sql, params).stream() .map(row -> { diff --git a/dao/src/test/java/org/thingsboard/server/dao/service/CalculatedFieldServiceTest.java b/dao/src/test/java/org/thingsboard/server/dao/service/CalculatedFieldServiceTest.java index 7f563ea436..4b830835fa 100644 --- a/dao/src/test/java/org/thingsboard/server/dao/service/CalculatedFieldServiceTest.java +++ b/dao/src/test/java/org/thingsboard/server/dao/service/CalculatedFieldServiceTest.java @@ -31,7 +31,7 @@ import org.thingsboard.server.common.data.cf.configuration.CalculatedFieldConfig import org.thingsboard.server.common.data.cf.configuration.Output; import org.thingsboard.server.common.data.cf.configuration.OutputType; import org.thingsboard.server.common.data.cf.configuration.ReferencedEntityKey; -import org.thingsboard.server.common.data.cf.configuration.RelationQueryDynamicSourceConfiguration; +import org.thingsboard.server.common.data.cf.configuration.RelationPathQueryDynamicSourceConfiguration; import org.thingsboard.server.common.data.cf.configuration.SimpleCalculatedFieldConfiguration; import org.thingsboard.server.common.data.cf.configuration.geofencing.EntityCoordinates; import org.thingsboard.server.common.data.cf.configuration.geofencing.GeofencingCalculatedFieldConfiguration; @@ -39,15 +39,19 @@ import org.thingsboard.server.common.data.cf.configuration.geofencing.ZoneGroupC import org.thingsboard.server.common.data.id.EntityId; import org.thingsboard.server.common.data.relation.EntityRelation; import org.thingsboard.server.common.data.relation.EntitySearchDirection; +import org.thingsboard.server.common.data.relation.RelationPathLevel; import org.thingsboard.server.dao.cf.CalculatedFieldService; import org.thingsboard.server.dao.device.DeviceService; import org.thingsboard.server.dao.exception.DataValidationException; import org.thingsboard.server.dao.tenant.TbTenantProfileCache; +import java.util.ArrayList; +import java.util.List; import java.util.Map; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.Mockito.mock; import static org.thingsboard.server.common.data.cf.configuration.geofencing.GeofencingReportStrategy.REPORT_TRANSITION_EVENTS_AND_PRESENCE_STATUS; @DaoSqlTest @@ -112,10 +116,8 @@ public class CalculatedFieldServiceTest extends AbstractServiceTest { // Zone-group argument (ATTRIBUTE) — make it DYNAMIC so scheduling is enabled ZoneGroupConfiguration zoneGroupConfiguration = new ZoneGroupConfiguration("allowed", REPORT_TRANSITION_EVENTS_AND_PRESENCE_STATUS, false); - var dynamicSourceConfiguration = new RelationQueryDynamicSourceConfiguration(); - dynamicSourceConfiguration.setDirection(EntitySearchDirection.FROM); - dynamicSourceConfiguration.setMaxLevel(1); - dynamicSourceConfiguration.setRelationType(EntityRelation.CONTAINS_TYPE); + var dynamicSourceConfiguration = new RelationPathQueryDynamicSourceConfiguration(); + dynamicSourceConfiguration.setLevels(List.of(new RelationPathLevel(EntitySearchDirection.FROM, EntityRelation.CONTAINS_TYPE))); zoneGroupConfiguration.setRefDynamicSourceConfiguration(dynamicSourceConfiguration); cfg.setZoneGroups(Map.of("allowed", zoneGroupConfiguration)); @@ -150,19 +152,26 @@ public class CalculatedFieldServiceTest extends AbstractServiceTest { // Arrange a device Device device = createTestDevice(); - // Build a valid Geofencing configuration GeofencingCalculatedFieldConfiguration cfg = new GeofencingCalculatedFieldConfiguration(); // Coordinates: TS_LATEST, no dynamic source EntityCoordinates entityCoordinates = new EntityCoordinates("latitude", "longitude"); cfg.setEntityCoordinates(entityCoordinates); - // Zone-group argument (ATTRIBUTE) — make it DYNAMIC so scheduling is enabled + int maxRelationLevel = tbTenantProfileCache.get(tenantId) + .getDefaultProfileConfiguration() + .getMaxRelationLevelPerCfArgument(); + + // Zone-group argument (ATTRIBUTE) ZoneGroupConfiguration zoneGroupConfiguration = new ZoneGroupConfiguration( "allowed", REPORT_TRANSITION_EVENTS_AND_PRESENCE_STATUS, false); - var dynamicSourceConfiguration = new RelationQueryDynamicSourceConfiguration(); - dynamicSourceConfiguration.setDirection(EntitySearchDirection.FROM); - dynamicSourceConfiguration.setMaxLevel(Integer.MAX_VALUE); - dynamicSourceConfiguration.setRelationType(EntityRelation.CONTAINS_TYPE); + var dynamicSourceConfiguration = new RelationPathQueryDynamicSourceConfiguration(); + + List levels = new ArrayList<>(); + for (int i = 0; i < maxRelationLevel + 1; i++) { + levels.add(mock(RelationPathLevel.class)); + } + + dynamicSourceConfiguration.setLevels(levels); zoneGroupConfiguration.setRefDynamicSourceConfiguration(dynamicSourceConfiguration); cfg.setZoneGroups(Map.of("allowed", zoneGroupConfiguration)); @@ -195,10 +204,8 @@ public class CalculatedFieldServiceTest extends AbstractServiceTest { // Zone-group argument (ATTRIBUTE) — make it DYNAMIC so scheduling is enabled ZoneGroupConfiguration zoneGroupConfiguration = new ZoneGroupConfiguration( "allowed", REPORT_TRANSITION_EVENTS_AND_PRESENCE_STATUS, false); - var dynamicSourceConfiguration = new RelationQueryDynamicSourceConfiguration(); - dynamicSourceConfiguration.setDirection(EntitySearchDirection.FROM); - dynamicSourceConfiguration.setMaxLevel(1); - dynamicSourceConfiguration.setRelationType(EntityRelation.CONTAINS_TYPE); + var dynamicSourceConfiguration = new RelationPathQueryDynamicSourceConfiguration(); + dynamicSourceConfiguration.setLevels(List.of(new RelationPathLevel(EntitySearchDirection.FROM, EntityRelation.CONTAINS_TYPE))); zoneGroupConfiguration.setRefDynamicSourceConfiguration(dynamicSourceConfiguration); cfg.setZoneGroups(Map.of("allowed", zoneGroupConfiguration)); 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 7f2dfba937..5e8d367538 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 @@ -33,7 +33,7 @@ import org.thingsboard.server.common.data.cf.configuration.ArgumentType; import org.thingsboard.server.common.data.cf.configuration.Output; import org.thingsboard.server.common.data.cf.configuration.OutputType; import org.thingsboard.server.common.data.cf.configuration.ReferencedEntityKey; -import org.thingsboard.server.common.data.cf.configuration.RelationQueryDynamicSourceConfiguration; +import org.thingsboard.server.common.data.cf.configuration.RelationPathQueryDynamicSourceConfiguration; import org.thingsboard.server.common.data.cf.configuration.ScriptCalculatedFieldConfiguration; import org.thingsboard.server.common.data.cf.configuration.SimpleCalculatedFieldConfiguration; import org.thingsboard.server.common.data.cf.configuration.geofencing.EntityCoordinates; @@ -50,10 +50,12 @@ import org.thingsboard.server.common.data.id.TenantId; import org.thingsboard.server.common.data.id.UserId; import org.thingsboard.server.common.data.relation.EntityRelation; import org.thingsboard.server.common.data.relation.EntitySearchDirection; +import org.thingsboard.server.common.data.relation.RelationPathLevel; import org.thingsboard.server.msa.AbstractContainerTest; import org.thingsboard.server.msa.ui.utils.EntityPrototypes; import java.util.HashMap; +import java.util.List; import java.util.Map; import java.util.concurrent.TimeUnit; @@ -366,19 +368,13 @@ public class CalculatedFieldTest extends AbstractContainerTest { // Dynamic groups via relations ZoneGroupConfiguration allowedZoneGroupConfiguration = new ZoneGroupConfiguration("zone", REPORT_TRANSITION_EVENTS_AND_PRESENCE_STATUS, false); - var allowedDynamicSourceConfiguration = new RelationQueryDynamicSourceConfiguration(); - allowedDynamicSourceConfiguration.setDirection(EntitySearchDirection.FROM); - allowedDynamicSourceConfiguration.setMaxLevel(1); - allowedDynamicSourceConfiguration.setFetchLastLevelOnly(true); - allowedDynamicSourceConfiguration.setRelationType("AllowedZone"); + var allowedDynamicSourceConfiguration = new RelationPathQueryDynamicSourceConfiguration(); + allowedDynamicSourceConfiguration.setLevels(List.of(new RelationPathLevel(EntitySearchDirection.FROM, "AllowedZone"))); allowedZoneGroupConfiguration.setRefDynamicSourceConfiguration(allowedDynamicSourceConfiguration); ZoneGroupConfiguration restrictedZoneGroupConfiguration = new ZoneGroupConfiguration("zone", REPORT_TRANSITION_EVENTS_AND_PRESENCE_STATUS, false); - var restrictedDynamicSourceConfiguration = new RelationQueryDynamicSourceConfiguration(); - restrictedDynamicSourceConfiguration.setDirection(EntitySearchDirection.FROM); - restrictedDynamicSourceConfiguration.setMaxLevel(1); - restrictedDynamicSourceConfiguration.setFetchLastLevelOnly(true); - restrictedDynamicSourceConfiguration.setRelationType("RestrictedZone"); + var restrictedDynamicSourceConfiguration = new RelationPathQueryDynamicSourceConfiguration(); + restrictedDynamicSourceConfiguration.setLevels(List.of(new RelationPathLevel(EntitySearchDirection.FROM, "RestrictedZone"))); restrictedZoneGroupConfiguration.setRefDynamicSourceConfiguration(restrictedDynamicSourceConfiguration); cfg.setZoneGroups(Map.of("allowedZones", allowedZoneGroupConfiguration, "restrictedZones", restrictedZoneGroupConfiguration)); From 19ef1e357712823daadfda1620d733e5b11b5125 Mon Sep 17 00:00:00 2001 From: VIacheslavKlimov Date: Fri, 3 Oct 2025 11:18:56 +0300 Subject: [PATCH 064/102] Fix tenant admins filter for enforced 2FA --- .../java/org/thingsboard/server/dao/user/UserServiceImpl.java | 3 +++ 1 file changed, 3 insertions(+) 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 7b5e4da6a0..4a83c71d66 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 @@ -556,6 +556,9 @@ public class UserServiceImpl extends AbstractCachedEntityService { + if (user.isSystemAdmin() || user.isCustomerUser()) { + return false; + } TenantAdministratorsFilter tenantAdministratorsFilter = (TenantAdministratorsFilter) filter; if (isNotEmpty(tenantAdministratorsFilter.getTenantsIds())) { return tenantAdministratorsFilter.getTenantsIds().contains(user.getTenantId().getId()); From eea9e6bf6e43e38c5cd65ef5ca83db4ffb456ab9 Mon Sep 17 00:00:00 2001 From: dshvaika Date: Fri, 3 Oct 2025 11:43:24 +0300 Subject: [PATCH 065/102] Removed RELATION_QUERY type --- ...tractCalculatedFieldProcessingService.java | 16 -- .../CFArgumentDynamicSourceType.java | 2 +- ...onPathQueryDynamicSourceConfiguration.java | 14 +- .../cf/configuration/RelationQueryBased.java | 29 --- ...lationQueryDynamicSourceConfiguration.java | 79 ------- ...onQueryDynamicSourceConfigurationTest.java | 214 ------------------ .../dao/relation/BaseRelationService.java | 7 + .../CalculatedFieldDataValidator.java | 6 +- 8 files changed, 17 insertions(+), 350 deletions(-) delete mode 100644 common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/RelationQueryBased.java delete mode 100644 common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/RelationQueryDynamicSourceConfiguration.java delete mode 100644 common/data/src/test/java/org/thingsboard/server/common/data/cf/configuration/RelationQueryDynamicSourceConfigurationTest.java 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 bd3546082a..f1b39d87d8 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 @@ -27,7 +27,6 @@ import org.thingsboard.common.util.ThingsBoardExecutors; import org.thingsboard.server.common.data.cf.configuration.Argument; import org.thingsboard.server.common.data.cf.configuration.ArgumentType; import org.thingsboard.server.common.data.cf.configuration.RelationPathQueryDynamicSourceConfiguration; -import org.thingsboard.server.common.data.cf.configuration.RelationQueryDynamicSourceConfiguration; import org.thingsboard.server.common.data.id.EntityId; import org.thingsboard.server.common.data.id.TenantId; import org.thingsboard.server.common.data.kv.Aggregation; @@ -164,21 +163,6 @@ public abstract class AbstractCalculatedFieldProcessingService { } var refDynamicSourceConfiguration = value.getRefDynamicSourceConfiguration(); return switch (refDynamicSourceConfiguration.getType()) { - case RELATION_QUERY -> { - var configuration = (RelationQueryDynamicSourceConfiguration) refDynamicSourceConfiguration; - if (configuration.isSimpleRelation()) { - yield switch (configuration.getDirection()) { - case FROM -> - Futures.transform(relationService.findByFromAndTypeAsync(tenantId, entityId, configuration.getRelationType(), RelationTypeGroup.COMMON), - configuration::resolveEntityIds, calculatedFieldCallbackExecutor); - case TO -> - Futures.transform(relationService.findByToAndTypeAsync(tenantId, entityId, configuration.getRelationType(), RelationTypeGroup.COMMON), - configuration::resolveEntityIds, calculatedFieldCallbackExecutor); - }; - } - yield Futures.transform(relationService.findByQuery(tenantId, configuration.toEntityRelationsQuery(entityId)), - configuration::resolveEntityIds, calculatedFieldCallbackExecutor); - } case RELATION_PATH_QUERY -> { var configuration = (RelationPathQueryDynamicSourceConfiguration) refDynamicSourceConfiguration; yield Futures.transform(relationService.findByRelationPathQueryAsync(tenantId, configuration.toRelationPathQuery(entityId)), diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/CFArgumentDynamicSourceType.java b/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/CFArgumentDynamicSourceType.java index 35c6cdf562..dc52287f3e 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/CFArgumentDynamicSourceType.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/CFArgumentDynamicSourceType.java @@ -17,6 +17,6 @@ package org.thingsboard.server.common.data.cf.configuration; public enum CFArgumentDynamicSourceType { - RELATION_QUERY, RELATION_PATH_QUERY + RELATION_PATH_QUERY } diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/RelationPathQueryDynamicSourceConfiguration.java b/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/RelationPathQueryDynamicSourceConfiguration.java index c7889be19d..dc92ff3685 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/RelationPathQueryDynamicSourceConfiguration.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/RelationPathQueryDynamicSourceConfiguration.java @@ -28,7 +28,7 @@ import java.util.List; import java.util.NoSuchElementException; @Data -public class RelationPathQueryDynamicSourceConfiguration implements CfArgumentDynamicSourceConfiguration, RelationQueryBased { +public class RelationPathQueryDynamicSourceConfiguration implements CfArgumentDynamicSourceConfiguration { private List levels; @@ -53,10 +53,11 @@ public class RelationPathQueryDynamicSourceConfiguration implements CfArgumentDy }; } - @Override - @JsonIgnore - public int getMaxLevel() { - return levels != null ? levels.size() : 0; + public void validateMaxRelationLevel(String argumentName, int maxAllowedRelationLevel) { + if (levels.size() > maxAllowedRelationLevel) { + throw new IllegalArgumentException("Max relation level is greater than configured " + + "maximum allowed relation level in tenant profile: " + maxAllowedRelationLevel + " for argument: " + argumentName); + } } public EntityRelationPathQuery toRelationPathQuery(EntityId entityId) { @@ -64,9 +65,6 @@ public class RelationPathQueryDynamicSourceConfiguration implements CfArgumentDy } private RelationPathLevel getLastLevel() { - if (CollectionsUtil.isEmpty(levels)) { - throw new NoSuchElementException(); - } return levels.get(levels.size() - 1); } diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/RelationQueryBased.java b/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/RelationQueryBased.java deleted file mode 100644 index e3248b264f..0000000000 --- a/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/RelationQueryBased.java +++ /dev/null @@ -1,29 +0,0 @@ -/** - * 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.common.data.cf.configuration; - -public interface RelationQueryBased { - - int getMaxLevel(); - - default void validateMaxRelationLevel(String argumentName, int maxAllowedRelationLevel) { - if (getMaxLevel() > maxAllowedRelationLevel) { - throw new IllegalArgumentException("Max relation level is greater than configured " + - "maximum allowed relation level in tenant profile: " + maxAllowedRelationLevel + " for argument: " + argumentName); - } - } - -} diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/RelationQueryDynamicSourceConfiguration.java b/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/RelationQueryDynamicSourceConfiguration.java deleted file mode 100644 index 120d04d40d..0000000000 --- a/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/RelationQueryDynamicSourceConfiguration.java +++ /dev/null @@ -1,79 +0,0 @@ -/** - * 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.common.data.cf.configuration; - -import com.fasterxml.jackson.annotation.JsonIgnore; -import lombok.Data; -import org.thingsboard.server.common.data.StringUtils; -import org.thingsboard.server.common.data.id.EntityId; -import org.thingsboard.server.common.data.relation.EntityRelation; -import org.thingsboard.server.common.data.relation.EntityRelationsQuery; -import org.thingsboard.server.common.data.relation.EntitySearchDirection; -import org.thingsboard.server.common.data.relation.RelationEntityTypeFilter; -import org.thingsboard.server.common.data.relation.RelationsSearchParameters; - -import java.util.Collections; -import java.util.List; - -@Data -public class RelationQueryDynamicSourceConfiguration implements CfArgumentDynamicSourceConfiguration, RelationQueryBased { - - private int maxLevel; - private boolean fetchLastLevelOnly; - private EntitySearchDirection direction; - private String relationType; - - @Override - public CFArgumentDynamicSourceType getType() { - return CFArgumentDynamicSourceType.RELATION_QUERY; - } - - @Override - public void validate() { - if (maxLevel < 1) { - throw new IllegalArgumentException("Relation query dynamic source configuration max relation level can't be less than 1!"); - } - if (direction == null) { - throw new IllegalArgumentException("Relation query dynamic source configuration direction must be specified!"); - } - if (StringUtils.isBlank(relationType)) { - throw new IllegalArgumentException("Relation query dynamic source configuration relation type must be specified!"); - } - } - - @JsonIgnore - public boolean isSimpleRelation() { - return maxLevel == 1; - } - - public EntityRelationsQuery toEntityRelationsQuery(EntityId rootEntityId) { - if (isSimpleRelation()) { - throw new IllegalArgumentException("Entity relations query can't be created for a simple relation!"); - } - var entityRelationsQuery = new EntityRelationsQuery(); - entityRelationsQuery.setParameters(new RelationsSearchParameters(rootEntityId, direction, maxLevel, fetchLastLevelOnly)); - entityRelationsQuery.setFilters(Collections.singletonList(new RelationEntityTypeFilter(relationType, Collections.emptyList()))); - return entityRelationsQuery; - } - - public List resolveEntityIds(List relations) { - return switch (direction) { - case FROM -> relations.stream().map(EntityRelation::getTo).toList(); - case TO -> relations.stream().map(EntityRelation::getFrom).toList(); - }; - } - -} diff --git a/common/data/src/test/java/org/thingsboard/server/common/data/cf/configuration/RelationQueryDynamicSourceConfigurationTest.java b/common/data/src/test/java/org/thingsboard/server/common/data/cf/configuration/RelationQueryDynamicSourceConfigurationTest.java deleted file mode 100644 index afd78e47f9..0000000000 --- a/common/data/src/test/java/org/thingsboard/server/common/data/cf/configuration/RelationQueryDynamicSourceConfigurationTest.java +++ /dev/null @@ -1,214 +0,0 @@ -/** - * 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.common.data.cf.configuration; - -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.extension.ExtendWith; -import org.junit.jupiter.params.ParameterizedTest; -import org.junit.jupiter.params.provider.NullAndEmptySource; -import org.junit.jupiter.params.provider.ValueSource; -import org.mockito.Mock; -import org.mockito.junit.jupiter.MockitoExtension; -import org.thingsboard.server.common.data.EntityType; -import org.thingsboard.server.common.data.id.EntityId; -import org.thingsboard.server.common.data.relation.EntityRelation; -import org.thingsboard.server.common.data.relation.EntitySearchDirection; -import org.thingsboard.server.common.data.relation.RelationEntityTypeFilter; -import org.thingsboard.server.common.data.relation.RelationsSearchParameters; - -import java.util.List; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.assertj.core.api.Assertions.assertThatCode; -import static org.assertj.core.api.Assertions.assertThatThrownBy; -import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.when; - -@ExtendWith(MockitoExtension.class) -public class RelationQueryDynamicSourceConfigurationTest { - - @Mock - EntityId rootEntityId; - - @Mock - EntityRelation rel1; - @Mock - EntityRelation rel2; - - @Test - void typeShouldBeRelationQuery() { - var cfg = new RelationQueryDynamicSourceConfiguration(); - assertThat(cfg.getType()).isEqualTo(CFArgumentDynamicSourceType.RELATION_QUERY); - } - - @Test - void validateShouldThrowWhenMaxLevelLessThanOne() { - var cfg = new RelationQueryDynamicSourceConfiguration(); - cfg.setMaxLevel(0); - cfg.setDirection(EntitySearchDirection.FROM); - cfg.setRelationType(EntityRelation.CONTAINS_TYPE); - - assertThatThrownBy(cfg::validate) - .isInstanceOf(IllegalArgumentException.class) - .hasMessage("Relation query dynamic source configuration max relation level can't be less than 1!"); - } - - @Test - void validateShouldThrowWhenMaxLevelGreaterThanMaxAllowedLevelFromTenantProfile() { - int maxAllowedRelationLevel = 2; - int argumentMaxRelationLevel = 3; - - var cfg = new RelationQueryDynamicSourceConfiguration(); - cfg.setMaxLevel(argumentMaxRelationLevel); - cfg.setDirection(EntitySearchDirection.FROM); - cfg.setRelationType(EntityRelation.CONTAINS_TYPE); - - String testRelationArgument = "testRelationArgument"; - assertThatThrownBy(() -> cfg.validateMaxRelationLevel(testRelationArgument, maxAllowedRelationLevel)) - .isInstanceOf(IllegalArgumentException.class) - .hasMessage("Max relation level is greater than configured " + - "maximum allowed relation level in tenant profile: " + maxAllowedRelationLevel + " for argument: " + testRelationArgument); - } - - @Test - void validateShouldPassValidationWhenMaxLevelLessThanMaxAllowedLevelFromTenantProfile() { - int maxAllowedRelationLevel = 5; - int argumentMaxRelationLevel = 2; - - var cfg = new RelationQueryDynamicSourceConfiguration(); - cfg.setMaxLevel(argumentMaxRelationLevel); - cfg.setDirection(EntitySearchDirection.FROM); - cfg.setRelationType(EntityRelation.CONTAINS_TYPE); - - String testRelationArgument = "testRelationArgument"; - assertThatCode(() -> cfg.validateMaxRelationLevel(testRelationArgument, maxAllowedRelationLevel)).doesNotThrowAnyException(); - } - - @Test - void validateShouldThrowWhenDirectionIsNull() { - var cfg = new RelationQueryDynamicSourceConfiguration(); - cfg.setMaxLevel(1); - cfg.setDirection(null); - cfg.setRelationType(EntityRelation.CONTAINS_TYPE); - - assertThatThrownBy(cfg::validate) - .isInstanceOf(IllegalArgumentException.class) - .hasMessage("Relation query dynamic source configuration direction must be specified!"); - } - - @ParameterizedTest - @ValueSource(strings = {" "}) - @NullAndEmptySource - void validateShouldThrowWhenRelationTypeIsNull(String relationType) { - var cfg = new RelationQueryDynamicSourceConfiguration(); - cfg.setMaxLevel(1); - cfg.setDirection(EntitySearchDirection.TO); - cfg.setRelationType(relationType); - - assertThatThrownBy(cfg::validate) - .isInstanceOf(IllegalArgumentException.class) - .hasMessage("Relation query dynamic source configuration relation type must be specified!"); - } - - @Test - void isSimpleRelationTrueWhenLevelIsOneAndEntityTypesEmptyOrNull() { - var cfg = new RelationQueryDynamicSourceConfiguration(); - cfg.setMaxLevel(1); - assertThat(cfg.isSimpleRelation()).isTrue(); - } - - @Test - void isSimpleRelationFalseWhenMaxLevelNotOne() { - var cfg = new RelationQueryDynamicSourceConfiguration(); - cfg.setMaxLevel(2); - assertThat(cfg.isSimpleRelation()).isFalse(); - } - - @Test - void toEntityRelationsQueryShouldThrowForSimpleRelation() { - var cfg = new RelationQueryDynamicSourceConfiguration(); - cfg.setMaxLevel(1); - cfg.setFetchLastLevelOnly(false); - cfg.setDirection(EntitySearchDirection.FROM); - cfg.setRelationType(EntityRelation.CONTAINS_TYPE); - - assertThatThrownBy(() -> cfg.toEntityRelationsQuery(rootEntityId)) - .isInstanceOf(IllegalArgumentException.class) - .hasMessage("Entity relations query can't be created for a simple relation!"); - } - - @Test - void toEntityRelationsQueryShouldBuildQueryForNonSimpleRelation() { - var cfg = new RelationQueryDynamicSourceConfiguration(); - cfg.setMaxLevel(2); - cfg.setFetchLastLevelOnly(true); - cfg.setDirection(EntitySearchDirection.TO); - cfg.setRelationType(EntityRelation.MANAGES_TYPE); - - var query = cfg.toEntityRelationsQuery(rootEntityId); - - assertThat(query).isNotNull(); - RelationsSearchParameters params = query.getParameters(); - assertThat(params).isNotNull(); - assertThat(params.getRootId()).isEqualTo(rootEntityId.getId()); - assertThat(params.getDirection()).isEqualTo(EntitySearchDirection.TO); - assertThat(params.getMaxLevel()).isEqualTo(2); - assertThat(params.isFetchLastLevelOnly()).isTrue(); - - assertThat(query.getFilters()).hasSize(1); - assertThat(query.getFilters().get(0)).isInstanceOf(RelationEntityTypeFilter.class); - RelationEntityTypeFilter filter = query.getFilters().get(0); - assertThat(filter.getRelationType()).isEqualTo(EntityRelation.MANAGES_TYPE); - } - - @Test - void resolveEntityIds_whenDirectionFROM_thenReturnsToIds() { - when(rel1.getTo()).thenReturn(mock(EntityId.class)); - when(rel2.getTo()).thenReturn(mock(EntityId.class)); - - var cfg = new RelationQueryDynamicSourceConfiguration(); - cfg.setDirection(EntitySearchDirection.FROM); - - var out = cfg.resolveEntityIds(List.of(rel1, rel2)); - - assertThat(out).containsExactly(rel1.getTo(), rel2.getTo()); - } - - @Test - void resolveEntityIds_whenDirectionTO_thenReturnsFromIds() { - when(rel1.getFrom()).thenReturn(mock(EntityId.class)); - when(rel2.getFrom()).thenReturn(mock(EntityId.class)); - - var cfg = new RelationQueryDynamicSourceConfiguration(); - cfg.setDirection(EntitySearchDirection.TO); - - var out = cfg.resolveEntityIds(List.of(rel1, rel2)); - - assertThat(out).containsExactly(rel1.getFrom(), rel2.getFrom()); - } - - @Test - void validateShouldPassForValidConfig() { - var cfg = new RelationQueryDynamicSourceConfiguration(); - cfg.setMaxLevel(2); - cfg.setFetchLastLevelOnly(false); - cfg.setDirection(EntitySearchDirection.FROM); - cfg.setRelationType(EntityRelation.CONTAINS_TYPE); - - assertThatCode(cfg::validate).doesNotThrowAnyException(); - } - -} diff --git a/dao/src/main/java/org/thingsboard/server/dao/relation/BaseRelationService.java b/dao/src/main/java/org/thingsboard/server/dao/relation/BaseRelationService.java index 0ec1257bcc..18d1806fe4 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/relation/BaseRelationService.java +++ b/dao/src/main/java/org/thingsboard/server/dao/relation/BaseRelationService.java @@ -504,6 +504,13 @@ public class BaseRelationService implements RelationService { log.trace("Executing findByRelationPathQuery, tenantId [{}], relationPathQuery {}", tenantId, relationPathQuery); validateId(tenantId, id -> "Invalid tenant id: " + id); validate(relationPathQuery); + if (relationPathQuery.levels().size() == 1) { + RelationPathLevel relationPathLevel = relationPathQuery.levels().get(0); + return switch (relationPathLevel.direction()) { + case FROM -> findByFromAndTypeAsync(tenantId, relationPathQuery.rootEntityId(), relationPathLevel.relationType(), RelationTypeGroup.COMMON); + case TO -> findByToAndTypeAsync(tenantId, relationPathQuery.rootEntityId(), relationPathLevel.relationType(), RelationTypeGroup.COMMON); + }; + } return executor.submit(() -> relationDao.findByRelationPathQuery(tenantId, relationPathQuery)); } diff --git a/dao/src/main/java/org/thingsboard/server/dao/service/validator/CalculatedFieldDataValidator.java b/dao/src/main/java/org/thingsboard/server/dao/service/validator/CalculatedFieldDataValidator.java index 85c30ca74e..67cf32191b 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/service/validator/CalculatedFieldDataValidator.java +++ b/dao/src/main/java/org/thingsboard/server/dao/service/validator/CalculatedFieldDataValidator.java @@ -19,7 +19,7 @@ import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Component; import org.thingsboard.server.common.data.cf.CalculatedField; import org.thingsboard.server.common.data.cf.configuration.ArgumentsBasedCalculatedFieldConfiguration; -import org.thingsboard.server.common.data.cf.configuration.RelationQueryBased; +import org.thingsboard.server.common.data.cf.configuration.RelationPathQueryDynamicSourceConfiguration; import org.thingsboard.server.common.data.cf.configuration.ScheduledUpdateSupportedCalculatedFieldConfiguration; import org.thingsboard.server.common.data.id.TenantId; import org.thingsboard.server.common.data.tenant.profile.DefaultTenantProfileConfiguration; @@ -98,10 +98,10 @@ public class CalculatedFieldDataValidator extends DataValidator if (!(calculatedField.getConfiguration() instanceof ArgumentsBasedCalculatedFieldConfiguration argumentsBasedCfg)) { return; } - Map relationQueryBasedArguments = argumentsBasedCfg.getArguments().entrySet() + Map relationQueryBasedArguments = argumentsBasedCfg.getArguments().entrySet() .stream() .filter(entry -> entry.getValue().hasDynamicSource()) - .collect(Collectors.toMap(Map.Entry::getKey, entry -> (RelationQueryBased) entry.getValue().getRefDynamicSourceConfiguration())); + .collect(Collectors.toMap(Map.Entry::getKey, entry -> (RelationPathQueryDynamicSourceConfiguration) entry.getValue().getRefDynamicSourceConfiguration())); if (relationQueryBasedArguments.isEmpty()) { return; } From 95583a0703bef23ed36b0da532301931326c2953 Mon Sep 17 00:00:00 2001 From: dshvaika Date: Fri, 3 Oct 2025 12:18:19 +0300 Subject: [PATCH 066/102] Added minor test to CalculatedFieldController and todo for geo CF state --- ...tractCalculatedFieldProcessingService.java | 1 + .../CalculatedFieldControllerTest.java | 66 ++++++++++++++++++- 2 files changed, 64 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 f1b39d87d8..fa10a49503 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 @@ -103,6 +103,7 @@ public abstract class AbstractCalculatedFieldProcessingService { return Futures.whenAllComplete(argFutures.values()).call(() -> { var result = createStateByType(ctx); result.updateState(ctx, resolveArgumentFutures(argFutures)); + // TODO: move to state.init() method after merge with alarm rules 2.0 if (ctx.hasRelationQueryDynamicArguments() && result instanceof GeofencingCalculatedFieldState geofencingCalculatedFieldState) { geofencingCalculatedFieldState.setLastDynamicArgumentsRefreshTs(System.currentTimeMillis()); } diff --git a/application/src/test/java/org/thingsboard/server/controller/CalculatedFieldControllerTest.java b/application/src/test/java/org/thingsboard/server/controller/CalculatedFieldControllerTest.java index af43b34558..27622b347a 100644 --- a/application/src/test/java/org/thingsboard/server/controller/CalculatedFieldControllerTest.java +++ b/application/src/test/java/org/thingsboard/server/controller/CalculatedFieldControllerTest.java @@ -29,15 +29,23 @@ import org.thingsboard.server.common.data.cf.configuration.CalculatedFieldConfig import org.thingsboard.server.common.data.cf.configuration.Output; import org.thingsboard.server.common.data.cf.configuration.OutputType; import org.thingsboard.server.common.data.cf.configuration.ReferencedEntityKey; +import org.thingsboard.server.common.data.cf.configuration.RelationPathQueryDynamicSourceConfiguration; import org.thingsboard.server.common.data.cf.configuration.SimpleCalculatedFieldConfiguration; +import org.thingsboard.server.common.data.cf.configuration.geofencing.EntityCoordinates; +import org.thingsboard.server.common.data.cf.configuration.geofencing.GeofencingCalculatedFieldConfiguration; +import org.thingsboard.server.common.data.cf.configuration.geofencing.ZoneGroupConfiguration; import org.thingsboard.server.common.data.id.DeviceId; +import org.thingsboard.server.common.data.relation.EntitySearchDirection; +import org.thingsboard.server.common.data.relation.RelationPathLevel; import org.thingsboard.server.common.data.security.Authority; import org.thingsboard.server.dao.service.DaoSqlTest; +import java.util.List; import java.util.Map; import static org.assertj.core.api.Assertions.assertThat; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; +import static org.thingsboard.server.common.data.cf.configuration.geofencing.GeofencingReportStrategy.REPORT_TRANSITION_EVENTS_AND_PRESENCE_STATUS; @DaoSqlTest public class CalculatedFieldControllerTest extends AbstractControllerTest { @@ -84,7 +92,35 @@ public class CalculatedFieldControllerTest extends AbstractControllerTest { assertThat(savedCalculatedField.getEntityId()).isEqualTo(calculatedField.getEntityId()); assertThat(savedCalculatedField.getType()).isEqualTo(calculatedField.getType()); assertThat(savedCalculatedField.getName()).isEqualTo(calculatedField.getName()); - assertThat(savedCalculatedField.getConfiguration()).isEqualTo(getCalculatedFieldConfig()); + assertThat(savedCalculatedField.getConfiguration()).isEqualTo(getSimpleCalculatedFieldConfig()); + assertThat(savedCalculatedField.getVersion()).isEqualTo(1L); + + savedCalculatedField.setName("Test CF"); + + CalculatedField updatedCalculatedField = doPost("/api/calculatedField", savedCalculatedField, CalculatedField.class); + + assertThat(updatedCalculatedField.getName()).isEqualTo(savedCalculatedField.getName()); + assertThat(updatedCalculatedField.getVersion()).isEqualTo(savedCalculatedField.getVersion() + 1); + + doDelete("/api/calculatedField/" + savedCalculatedField.getId().getId().toString()) + .andExpect(status().isOk()); + } + + @Test + public void testSaveGeofencingCalculatedField() throws Exception { + Device testDevice = createDevice("Test device", "1234567890"); + CalculatedField calculatedField = getCalculatedField(testDevice.getId(), getGeofencingCalculatedFieldConfig()); + + CalculatedField savedCalculatedField = doPost("/api/calculatedField", calculatedField, CalculatedField.class); + + assertThat(savedCalculatedField).isNotNull(); + assertThat(savedCalculatedField.getId()).isNotNull(); + assertThat(savedCalculatedField.getCreatedTime()).isGreaterThan(0); + assertThat(savedCalculatedField.getTenantId()).isEqualTo(savedTenant.getId()); + assertThat(savedCalculatedField.getEntityId()).isEqualTo(calculatedField.getEntityId()); + assertThat(savedCalculatedField.getType()).isEqualTo(calculatedField.getType()); + assertThat(savedCalculatedField.getName()).isEqualTo(calculatedField.getName()); + assertThat(savedCalculatedField.getConfiguration()).isEqualTo(getGeofencingCalculatedFieldConfig()); assertThat(savedCalculatedField.getVersion()).isEqualTo(1L); savedCalculatedField.setName("Test CF"); @@ -128,17 +164,41 @@ public class CalculatedFieldControllerTest extends AbstractControllerTest { } private CalculatedField getCalculatedField(DeviceId deviceId) { + return getCalculatedField(deviceId, getSimpleCalculatedFieldConfig()); + } + + private CalculatedField getCalculatedField(DeviceId deviceId, CalculatedFieldConfiguration configuration) { CalculatedField calculatedField = new CalculatedField(); calculatedField.setEntityId(deviceId); calculatedField.setType(CalculatedFieldType.SIMPLE); calculatedField.setName("Test Calculated Field"); calculatedField.setConfigurationVersion(1); - calculatedField.setConfiguration(getCalculatedFieldConfig()); + calculatedField.setConfiguration(configuration); calculatedField.setVersion(1L); return calculatedField; } - private CalculatedFieldConfiguration getCalculatedFieldConfig() { + private CalculatedFieldConfiguration getGeofencingCalculatedFieldConfig() { + var config = new GeofencingCalculatedFieldConfiguration(); + + var refDynamicSourceConfiguration = new RelationPathQueryDynamicSourceConfiguration(); + refDynamicSourceConfiguration.setLevels(List.of(new RelationPathLevel(EntitySearchDirection.TO, "FromSafeArea"))); + + var zoneGroupConfiguration = new ZoneGroupConfiguration("perimeter", REPORT_TRANSITION_EVENTS_AND_PRESENCE_STATUS, false); + zoneGroupConfiguration.setRefDynamicSourceConfiguration(refDynamicSourceConfiguration); + + Output output = new Output(); + output.setType(OutputType.TIME_SERIES); + + config.setEntityCoordinates(new EntityCoordinates("latitide", "longitude")); + config.setZoneGroups(Map.of("safeArea", zoneGroupConfiguration)); + config.setScheduledUpdateEnabled(false); + config.setOutput(output); + + return config; + } + + private CalculatedFieldConfiguration getSimpleCalculatedFieldConfig() { SimpleCalculatedFieldConfiguration config = new SimpleCalculatedFieldConfiguration(); Argument argument = new Argument(); From 6b5e33992182b0825b3dd145f545e4851910b224 Mon Sep 17 00:00:00 2001 From: ArtemDzhereleiko Date: Fri, 3 Oct 2025 16:21:03 +0300 Subject: [PATCH 067/102] UI: fixed and improve geofencing cf --- .../calculated-field-dialog.component.html | 8 +- .../calculated-field-dialog.component.ts | 19 +++ ...-geofencing-zone-groups-table.component.ts | 2 +- ...eofencing-zone-groups-panel.component.html | 146 +++++++++++------- ...eofencing-zone-groups-panel.component.scss | 25 +++ ...-geofencing-zone-groups-panel.component.ts | 79 ++++++++-- .../shared/models/calculated-field.models.ts | 14 +- .../assets/locale/locale.constant-en_US.json | 27 +++- 8 files changed, 239 insertions(+), 81 deletions(-) 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 4b12d9f31a..e235b66fa7 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 @@ -160,12 +160,16 @@ [tenantId]="data.tenantId" [entityName]="data.entityName"/>
-
{{ 'calculated-fields.zone-group-refresh-interval' | translate }}
+ +
+ {{ 'calculated-fields.zone-group-refresh-interval' | translate }} +
+
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 3037b3a4ce..8cca16d4ef 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 @@ -81,6 +81,7 @@ export class CalculatedFieldDialogComponent extends DialogComponent + this.checkScheduledUpdateEnabled(value) + ); + this.checkScheduledUpdateEnabled(this.configFormGroup.get('scheduledUpdateEnabled').value); + } + + private checkScheduledUpdateEnabled(value: boolean) { + if (value) { + this.configFormGroup.get('scheduledUpdateInterval').enable({emitEvent: false}); + } else { + this.configFormGroup.get('scheduledUpdateInterval').disable({emitEvent: false}); + } + } + private checkRelatedEntity(zoneGroups: CalculatedFieldGeofencing) { this.isRelatedEntity = Object.values(zoneGroups).some(zone => zone.refDynamicSourceConfiguration?.type === ArgumentEntityType.RelationQuery); } diff --git a/ui-ngx/src/app/modules/home/components/calculated-fields/components/geofencing-zone-grups-table/calculated-field-geofencing-zone-groups-table.component.ts b/ui-ngx/src/app/modules/home/components/calculated-fields/components/geofencing-zone-grups-table/calculated-field-geofencing-zone-groups-table.component.ts index a11ae4cea5..9d2a124d68 100644 --- a/ui-ngx/src/app/modules/home/components/calculated-fields/components/geofencing-zone-grups-table/calculated-field-geofencing-zone-groups-table.component.ts +++ b/ui-ngx/src/app/modules/home/components/calculated-fields/components/geofencing-zone-grups-table/calculated-field-geofencing-zone-groups-table.component.ts @@ -168,7 +168,7 @@ export class CalculatedFieldGeofencingZoneGroupsTableComponent implements Contro renderer: this.renderer, componentType: CalculatedFieldGeofencingZoneGroupsPanelComponent, hostView: this.viewContainerRef, - preferredPlacement: isExists ? ['left', 'leftTop', 'leftBottom'] : ['topRight', 'right', 'rightTop'], + preferredPlacement: 'right', context: ctx, isModal: true }); diff --git a/ui-ngx/src/app/modules/home/components/calculated-fields/components/panel/calculated-field-geofencing-zone-groups-panel.component.html b/ui-ngx/src/app/modules/home/components/calculated-fields/components/panel/calculated-field-geofencing-zone-groups-panel.component.html index a660158ab9..1c57cfbc02 100644 --- a/ui-ngx/src/app/modules/home/components/calculated-fields/components/panel/calculated-field-geofencing-zone-groups-panel.component.html +++ b/ui-ngx/src/app/modules/home/components/calculated-fields/components/panel/calculated-field-geofencing-zone-groups-panel.component.html @@ -80,12 +80,13 @@ @if (ArgumentEntityTypeParamsMap.has(entityType)) {
{{ ArgumentEntityTypeParamsMap.get(entityType).title | translate }}
-
@@ -94,76 +95,115 @@
- {{ 'calculated-fields.relation-query' | translate }}* - -
-
{{ 'calculated-fields.direction' | translate }}
- - - @for (direction of GeofencingDirectionList; track direction) { - {{ GeofencingDirectionTranslations.get(direction) | translate }} + {{ 'calculated-fields.entity-zone-relationship' | translate }} +
+
+
calculated-fields.level
+
calculated-fields.direction-level
+
calculated-fields.relation-type
+
+
+ @if (levelsFormArray()?.controls?.length) { +
+ @for (keyControl of levelsFormArray().controls; track trackByKey;) { +
+
+
{{ $index+1 }}
+ + + @for (direction of GeofencingDirectionList; track direction) { + {{ GeofencingDirectionLevelTranslations.get(direction) | translate }} + } + + + + +
+
+ + +
+
} - - +
+ } @else { + {{ 'calculated-fields.no-level' | translate }} + } + @if (levelsFormArray().errors) { + + }
-
-
{{ 'calculated-fields.relation-type' | translate }}
- - +
+ @if (maxRelationLevelPerCfArgument && levelsFormArray().length >= maxRelationLevelPerCfArgument) { +
+ warning + {{ 'calculated-fields.max-allowed-levels-error' | translate }} +
+ } @else { + + }
-
-
{{ 'calculated-fields.relation-level' | translate }}
+ +
+ + + @if (entityFilter.singleEntity.id) { +
+
+ {{ 'calculated-fields.perimeter-attribute-key' | translate }} +
+ @if (entityType === ArgumentEntityType.RelationQuery) { - - @if (refDynamicSourceFormGroup.get('maxLevel').touched && refDynamicSourceFormGroup.get('maxLevel').hasError('required')) { - - warning - - } @else if (refDynamicSourceFormGroup.get('maxLevel').touched && refDynamicSourceFormGroup.get('maxLevel').hasError('min')) { + + @if (geofencingFormGroup.get('perimeterKeyName').touched && geofencingFormGroup.get('perimeterKeyName').hasError('required')) { warning - } @else if (refDynamicSourceFormGroup.get('maxLevel').touched && refDynamicSourceFormGroup.get('maxLevel').hasError('max')) { + } @else if (geofencingFormGroup.get('perimeterKeyName').touched && geofencingFormGroup.get('perimeterKeyName').hasError('pattern')) { warning } -
-
- - {{ 'calculated-fields.fetch-last-available-level' | translate }} - -
- -
- - -
-
- {{ 'calculated-fields.perimeter-attribute-key' | translate }} + } @else { + + }
- -
+ }
{{ 'calculated-fields.report-strategy' | translate }}
diff --git a/ui-ngx/src/app/modules/home/components/calculated-fields/components/panel/calculated-field-geofencing-zone-groups-panel.component.scss b/ui-ngx/src/app/modules/home/components/calculated-fields/components/panel/calculated-field-geofencing-zone-groups-panel.component.scss index bedaf2eeb0..ff6140f12c 100644 --- a/ui-ngx/src/app/modules/home/components/calculated-fields/components/panel/calculated-field-geofencing-zone-groups-panel.component.scss +++ b/ui-ngx/src/app/modules/home/components/calculated-fields/components/panel/calculated-field-geofencing-zone-groups-panel.component.scss @@ -29,6 +29,26 @@ $panel-width: 520px; } } + .level-text { + width: 25px; + color: rgba(0, 0, 0, 0.54); + } + + .tb-form-table { + .tb-form-row { + gap: 12px; + } + .tb-form-table-body { + gap: unset; + } + } + .tb-form-table-header-cell { + &.tb-actions-header { + width: 80px; + min-width: 80px; + } + } + .limit-field-row { @media screen and (max-width: $panel-width) { display: flex; @@ -48,4 +68,9 @@ $panel-width: 520px; flex-direction: column; } } + tb-entity-autocomplete { + .mat-mdc-form-field-has-icon-suffix .mat-mdc-text-field-wrapper { + padding-right: 0 !important; + } + } } diff --git a/ui-ngx/src/app/modules/home/components/calculated-fields/components/panel/calculated-field-geofencing-zone-groups-panel.component.ts b/ui-ngx/src/app/modules/home/components/calculated-fields/components/panel/calculated-field-geofencing-zone-groups-panel.component.ts index 72ea58919f..8e5921516e 100644 --- a/ui-ngx/src/app/modules/home/components/calculated-fields/components/panel/calculated-field-geofencing-zone-groups-panel.component.ts +++ b/ui-ngx/src/app/modules/home/components/calculated-fields/components/panel/calculated-field-geofencing-zone-groups-panel.component.ts @@ -16,7 +16,15 @@ import { AfterViewInit, ChangeDetectorRef, Component, Input, OnInit, output, ViewChild } from '@angular/core'; import { TbPopoverComponent } from '@shared/components/popover.component'; -import { FormBuilder, FormControl, FormGroup, ValidatorFn, Validators } from '@angular/forms'; +import { + AbstractControl, + FormBuilder, + FormControl, + FormGroup, + UntypedFormArray, + ValidatorFn, + Validators +} from '@angular/forms'; import { charsWithNumRegex, oneSpaceInsideRegex } from '@shared/models/regex.constants'; import { ArgumentEntityType, @@ -25,14 +33,15 @@ import { CalculatedFieldGeofencing, CalculatedFieldGeofencingValue, CalculatedFieldType, + GeofencingDirectionLevelTranslations, GeofencingDirectionTranslations, GeofencingReportStrategy, GeofencingReportStrategyTranslations, getCalculatedFieldCurrentEntityFilter } from '@shared/models/calculated-field.models'; -import { debounceTime, delay, distinctUntilChanged, filter, map } from 'rxjs/operators'; +import { debounceTime, delay, distinctUntilChanged, map } from 'rxjs/operators'; import { EntityType } from '@shared/models/entity-type.models'; -import { DataKeyType } from '@shared/models/telemetry/telemetry.models'; +import { AttributeScope, DataKeyType } from '@shared/models/telemetry/telemetry.models'; import { EntityId } from '@shared/models/id/entity-id'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { EntityFilter } from '@shared/models/query/query.models'; @@ -44,6 +53,7 @@ import { Store } from '@ngrx/store'; import { EntityAutocompleteComponent } from '@shared/components/entity/entity-autocomplete.component'; import { NULL_UUID } from '@shared/models/id/has-uuid'; import { EntitySearchDirection } from '@shared/models/relation.models'; +import { CdkDragDrop } from "@angular/cdk/drag-drop"; @Component({ selector: 'tb-calculated-field-geofencing-zone-groups-panel', @@ -73,12 +83,9 @@ export class CalculatedFieldGeofencingZoneGroupsPanelComponent implements OnInit id: [''] }), refDynamicSourceConfiguration: this.fb.group({ - direction: [EntitySearchDirection.TO], - relationType: ['', [Validators.required]], - maxLevel: [1, [Validators.required, Validators.min(1), Validators.max(this.maxRelationLevelPerCfArgument)]], - fetchLastLevelOnly: [false], + levels: this.fb.array([], [this.levelsRequired()]) }), - perimeterKeyName: ['', [Validators.pattern(oneSpaceInsideRegex)]], + perimeterKeyName: ['', [Validators.required, Validators.pattern(oneSpaceInsideRegex)]], reportStrategy: [GeofencingReportStrategy.REPORT_TRANSITION_EVENTS_AND_PRESENCE_STATUS], createRelationsWithMatchedZones: [false], direction: [EntitySearchDirection.TO], @@ -97,6 +104,8 @@ export class CalculatedFieldGeofencingZoneGroupsPanelComponent implements OnInit readonly GeofencingReportStrategyTranslations = GeofencingReportStrategyTranslations; readonly GeofencingDirectionList = Object.values(EntitySearchDirection) as Array; readonly GeofencingDirectionTranslations = GeofencingDirectionTranslations; + readonly GeofencingDirectionLevelTranslations = GeofencingDirectionLevelTranslations; + readonly AttributeScope = AttributeScope; private currentEntityFilter: EntityFilter; @@ -107,7 +116,6 @@ export class CalculatedFieldGeofencingZoneGroupsPanelComponent implements OnInit private store: Store ) { - this.observeMaxLevelChanges(); this.observeEntityFilterChanges(); this.observeEntityTypeChanges(); this.observeUpdatePosition(); @@ -131,7 +139,16 @@ export class CalculatedFieldGeofencingZoneGroupsPanelComponent implements OnInit if (this.zone.refDynamicSourceConfiguration?.type) { this.refEntityIdFormGroup.get('entityType').setValue(this.zone.refDynamicSourceConfiguration.type, {emitEvent: false}); } - this.validateFetchLastLevelOnly(this.zone?.refDynamicSourceConfiguration?.maxLevel); + if (this.zone?.refDynamicSourceConfiguration?.levels?.length > 0) { + this.zone.refDynamicSourceConfiguration.levels.forEach(level => { + this.levelsFormArray().push(this.fb.group({ + direction: [level.direction], + relationType: [level.relationType, [Validators.required]] + })); + }) + } else { + this.addKey(); + } this.validateDirectionAndRelationType(this.zone?.createRelationsWithMatchedZones); this.validateRefDynamicSourceConfiguration(this.zone?.refEntityId?.entityType || this.zone?.refDynamicSourceConfiguration?.type); @@ -241,17 +258,19 @@ export class CalculatedFieldGeofencingZoneGroupsPanelComponent implements OnInit private observeEntityFilterChanges(): void { merge( this.refEntityIdFormGroup.get('entityType').valueChanges, - this.refEntityIdFormGroup.get('id').valueChanges.pipe(filter(Boolean)), + this.refEntityIdFormGroup.get('id').valueChanges, ) .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.geofencingFormGroup.get('refEntityId').get('id').setValue(''); + 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'](); @@ -271,6 +290,12 @@ export class CalculatedFieldGeofencingZoneGroupsPanelComponent implements OnInit }; } + private levelsRequired(): ValidatorFn { + return (control: FormControl) => { + return control.value.length ? null : { levelsRequired: true }; + }; + } + private forbiddenNameValidator(): ValidatorFn { return (control: FormControl) => { const trimmedValue = control.value.trim().toLowerCase(); @@ -282,10 +307,40 @@ export class CalculatedFieldGeofencingZoneGroupsPanelComponent implements OnInit private observeUpdatePosition(): void { merge( this.refEntityIdFormGroup.get('entityType').valueChanges, + this.refEntityIdFormGroup.get('id').valueChanges, this.geofencingFormGroup.get('createRelationsWithMatchedZones').valueChanges ) .pipe(delay(50), takeUntilDestroyed()) .subscribe(() => this.popover.updatePosition()); } + levelsFormArray(): UntypedFormArray { + return this.refDynamicSourceFormGroup.get('levels') as UntypedFormArray; + } + + trackByKey(index: number, keyControl: AbstractControl): any { + return keyControl; + } + + removeKey(index: number) { + this.levelsFormArray().removeAt(index); + } + + addKey() { + this.levelsFormArray().push(this.fb.group({ + direction: [EntitySearchDirection.TO], + relationType: ['', [Validators.required]] + })); + } + + keyDrop(event: CdkDragDrop) { + const keysArray = this.levelsFormArray(); + const key = keysArray.at(event.previousIndex); + keysArray.removeAt(event.previousIndex); + keysArray.insert(event.currentIndex, key); + } + + get dragEnabled(): boolean { + return this.levelsFormArray().controls.length > 1; + } } 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 a86943c86e..841baea168 100644 --- a/ui-ngx/src/app/shared/models/calculated-field.models.ts +++ b/ui-ngx/src/app/shared/models/calculated-field.models.ts @@ -73,7 +73,7 @@ export enum ArgumentEntityType { Asset = 'ASSET', Customer = 'CUSTOMER', Tenant = 'TENANT', - RelationQuery = 'RELATION_QUERY', + RelationQuery = 'RELATION_PATH_QUERY', } export const ArgumentEntityTypeTranslations = new Map( @@ -108,6 +108,13 @@ export const GeofencingDirectionTranslations = new Map( + [ + [EntitySearchDirection.FROM, 'calculated-fields.direction-down'], + [EntitySearchDirection.TO, 'calculated-fields.direction-up'], + ] +) + export enum ArgumentType { Attribute = 'ATTRIBUTE', LatestTelemetry = 'TS_LATEST', @@ -167,10 +174,7 @@ export interface CalculatedFieldGeofencing { export interface RefDynamicSourceConfiguration { type?: ArgumentEntityType.RelationQuery; - direction: EntitySearchDirection; - relationType: string; - maxLevel: number; - fetchLastLevelOnly?: boolean; + levels?: Array<{direction: EntitySearchDirection; relationType: string;}>; } export interface CalculatedFieldGeofencingValue extends CalculatedFieldGeofencing { 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 1866ab7d0b..c56d08701c 100644 --- a/ui-ngx/src/assets/locale/locale.constant-en_US.json +++ b/ui-ngx/src/assets/locale/locale.constant-en_US.json @@ -1122,17 +1122,28 @@ "report-presence-status-only": "Presence status only", "report-transition-event-and-presence": "Presence status and transition events", "perimeter-attribute-key": "Perimeter attribute key", - "relation-query": "Relations query", - "direction": "Direction", - "direction-from": "From source entity", - "direction-to": "To source entity", + "perimeter-attribute-key-required": "Perimeter attribute key is required.", + "perimeter-attribute-key-pattern": "Perimeter attribute key is invalid.", + "entity-zone-relationship": "Path from Entity to Zones *", + "direction": "Relation direction", + "direction-from": "From entity to zone", + "direction-to": "From zone to entity", "relation-type": "Relation type", - "create-relation-with-matched-zones": "Create relations with matched zones", + "create-relation-with-matched-zones": "Create relations for source entity with matched zones", "relation-level": "Relation level", "fetch-last-available-level": "Fetch last available level only", "zone-group-refresh-interval": "Zone groups refresh interval", "copy-zone-group-name": "Copy zone group name", "open-details-page": "Open entity details page", + "level": "Level", + "direction-level": "Direction", + "direction-up": "Up", + "direction-down": "Down", + "add-level": "Add level", + "delete-level": "Delete level", + "no-level": "No level configured", + "levels-required": "At least one level must be configured.", + "max-allowed-levels-error": "Relation level exceeds the maximum allowed.", "hint": { "arguments-simple-with-rolling": "Simple type calculated field should not contain keys with time series rolling type.", "arguments-empty": "Arguments should not be empty.", @@ -1158,7 +1169,7 @@ "entity-coordinates": "Specify the time series keys that provide entity GPS coordinates (latitude and longitude).", "geofencing-zone-groups": "Define one or more geofencing zones groups to check (e.g. 'allowedZones', 'restrictedZones'). Each group must have a unique name, which is used as a prefix for calculated field output telemetry keys.", "perimeter-attribute-key": "Set the attribute key that contains the geofencing zone perimeter definition. The perimeter is always taken from server-side attributes of the zone entity.", - "report-strategy": "Presence status reports whether the entity is currently INSIDE or OUTSIDE the zone group.Transition events report when the entity ENTERED or LEFT the zone group.", + "report-strategy": "Presence status reports whether the entity is currently INSIDE or OUTSIDE the zone group. Transition events report when the entity ENTERED or LEFT the zone group.", "create-relation-with-matched-zones": "Automatically create and maintain relations between the entity and the zones it is currently inside. Relations are removed when the entity leaves a zone and created when it enters a new one.", "relation-type-required": "Relation type is required.", "relation-level-required": "Relation level is required.", @@ -1167,9 +1178,9 @@ "geofencing-empty": "At least one zone group must be configured.", "geofencing-entity-not-found": "Geofencing target entity not found.", "max-geofencing-zone": "Maximum number of geofencing zones reached.", - "zone-group-refresh-interval": "Defines how often zone groups configured via related entities are refreshed. Set to 0 to disable scheduled refresh.", + "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 is below the minimum allowed system interval." + "zone-group-refresh-interval-min": "Zone group refresh interval should be at least {{ min }} second." } }, "ai-models": { From a813cf403e63e7e271fa3007e485ee1664dbb9cd Mon Sep 17 00:00:00 2001 From: ArtemDzhereleiko Date: Fri, 3 Oct 2025 18:02:58 +0300 Subject: [PATCH 068/102] UI: Fixed entity key autocomplete on filter change --- .../components/entity/entity-key-autocomplete.component.ts | 1 + 1 file changed, 1 insertion(+) 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 a86fc70115..9bc8d4ced8 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 @@ -133,6 +133,7 @@ export class EntityKeyAutocompleteComponent implements ControlValueAccessor, Val if (filterChanged || keyScopeChanged || keyTypeChanged) { this.keyControl.setValue('', {emitEvent: false}); + this.cachedResult = null; } } From 7609722b308084149e013b1c2790c95715afdf93 Mon Sep 17 00:00:00 2001 From: ArtemDzhereleiko Date: Mon, 6 Oct 2025 16:46:54 +0300 Subject: [PATCH 069/102] UI: Move drag icon button to first column of table --- ...-geofencing-zone-groups-table.component.ts | 2 +- ...eofencing-zone-groups-panel.component.html | 23 +++++++++++-------- ...eofencing-zone-groups-panel.component.scss | 18 +++++++++++---- 3 files changed, 27 insertions(+), 16 deletions(-) diff --git a/ui-ngx/src/app/modules/home/components/calculated-fields/components/geofencing-zone-grups-table/calculated-field-geofencing-zone-groups-table.component.ts b/ui-ngx/src/app/modules/home/components/calculated-fields/components/geofencing-zone-grups-table/calculated-field-geofencing-zone-groups-table.component.ts index 9d2a124d68..a11ae4cea5 100644 --- a/ui-ngx/src/app/modules/home/components/calculated-fields/components/geofencing-zone-grups-table/calculated-field-geofencing-zone-groups-table.component.ts +++ b/ui-ngx/src/app/modules/home/components/calculated-fields/components/geofencing-zone-grups-table/calculated-field-geofencing-zone-groups-table.component.ts @@ -168,7 +168,7 @@ export class CalculatedFieldGeofencingZoneGroupsTableComponent implements Contro renderer: this.renderer, componentType: CalculatedFieldGeofencingZoneGroupsPanelComponent, hostView: this.viewContainerRef, - preferredPlacement: 'right', + preferredPlacement: isExists ? ['left', 'leftTop', 'leftBottom'] : ['topRight', 'right', 'rightTop'], context: ctx, isModal: true }); diff --git a/ui-ngx/src/app/modules/home/components/calculated-fields/components/panel/calculated-field-geofencing-zone-groups-panel.component.html b/ui-ngx/src/app/modules/home/components/calculated-fields/components/panel/calculated-field-geofencing-zone-groups-panel.component.html index 1c57cfbc02..a460deaf4e 100644 --- a/ui-ngx/src/app/modules/home/components/calculated-fields/components/panel/calculated-field-geofencing-zone-groups-panel.component.html +++ b/ui-ngx/src/app/modules/home/components/calculated-fields/components/panel/calculated-field-geofencing-zone-groups-panel.component.html @@ -97,7 +97,8 @@ {{ 'calculated-fields.entity-zone-relationship' | translate }}
-
+
+
calculated-fields.level
calculated-fields.direction-level
calculated-fields.relation-type
@@ -110,6 +111,17 @@ (cdkDropListDropped)="keyDrop($event)"> @for (keyControl of levelsFormArray().controls; track trackByKey;) {
+
+ +
{{ $index+1 }}
@@ -137,15 +149,6 @@ matTooltipPosition="above"> delete -
} diff --git a/ui-ngx/src/app/modules/home/components/calculated-fields/components/panel/calculated-field-geofencing-zone-groups-panel.component.scss b/ui-ngx/src/app/modules/home/components/calculated-fields/components/panel/calculated-field-geofencing-zone-groups-panel.component.scss index ff6140f12c..ac5dc70ef9 100644 --- a/ui-ngx/src/app/modules/home/components/calculated-fields/components/panel/calculated-field-geofencing-zone-groups-panel.component.scss +++ b/ui-ngx/src/app/modules/home/components/calculated-fields/components/panel/calculated-field-geofencing-zone-groups-panel.component.scss @@ -30,6 +30,8 @@ $panel-width: 520px; } .level-text { + display: flex; + justify-content: center; width: 25px; color: rgba(0, 0, 0, 0.54); } @@ -38,14 +40,20 @@ $panel-width: 520px; .tb-form-row { gap: 12px; } + .tb-form-table-body { gap: unset; } - } - .tb-form-table-header-cell { - &.tb-actions-header { - width: 80px; - min-width: 80px; + + .tb-form-table-header { + padding: 0; + } + + .tb-form-table-header-cell { + &.tb-actions-header { + width: 40px; + min-width: 40px; + } } } From 14efeecdc33949472e9878733bb4522b778656d0 Mon Sep 17 00:00:00 2001 From: Dmytro Skarzhynets Date: Mon, 6 Oct 2025 16:36:30 +0300 Subject: [PATCH 070/102] Update rule node doc links --- .../engine/action/TbAssignToCustomerNode.java | 5 +- .../rule/engine/action/TbClearAlarmNode.java | 6 +- .../TbCopyAttributesToEntityViewNode.java | 35 ++--- .../rule/engine/action/TbCreateAlarmNode.java | 21 ++- .../engine/action/TbCreateRelationNode.java | 5 +- .../engine/action/TbDeleteRelationNode.java | 6 +- .../rule/engine/action/TbDeviceStateNode.java | 3 +- .../rule/engine/action/TbLogNode.java | 16 +- .../rule/engine/action/TbMsgCountNode.java | 6 +- .../TbSaveToCustomCassandraTableNode.java | 7 +- .../action/TbUnassignFromCustomerNode.java | 3 +- .../thingsboard/rule/engine/ai/TbAiNode.java | 4 +- .../engine/aws/lambda/TbAwsLambdaNode.java | 4 +- .../rule/engine/aws/sns/TbSnsNode.java | 4 +- .../rule/engine/aws/sqs/TbSqsNode.java | 4 +- .../rule/engine/debug/TbMsgGeneratorNode.java | 4 +- .../deduplication/TbMsgDeduplicationNode.java | 5 +- .../rule/engine/delay/TbMsgDelayNode.java | 6 +- .../engine/edge/TbMsgPushToCloudNode.java | 8 +- .../rule/engine/edge/TbMsgPushToEdgeNode.java | 6 +- .../engine/filter/TbAssetTypeSwitchNode.java | 6 +- .../engine/filter/TbCheckAlarmStatusNode.java | 4 +- .../engine/filter/TbCheckMessageNode.java | 8 +- .../engine/filter/TbCheckRelationNode.java | 9 +- .../engine/filter/TbDeviceTypeSwitchNode.java | 6 +- .../rule/engine/filter/TbJsFilterNode.java | 12 +- .../rule/engine/filter/TbJsSwitchNode.java | 13 +- .../engine/filter/TbMsgTypeFilterNode.java | 13 +- .../engine/filter/TbMsgTypeSwitchNode.java | 14 +- .../filter/TbOriginatorTypeFilterNode.java | 10 +- .../filter/TbOriginatorTypeSwitchNode.java | 6 +- .../rule/engine/flow/TbAckNode.java | 13 +- .../rule/engine/flow/TbCheckpointNode.java | 7 +- .../engine/flow/TbRuleChainInputNode.java | 6 +- .../engine/flow/TbRuleChainOutputNode.java | 9 +- .../rule/engine/gcp/pubsub/TbPubSubNode.java | 4 +- .../engine/geo/TbGpsGeofencingActionNode.java | 14 +- .../engine/geo/TbGpsGeofencingFilterNode.java | 10 +- .../rule/engine/kafka/TbKafkaNode.java | 3 +- .../rule/engine/mail/TbMsgToEmailNode.java | 22 +-- .../rule/engine/mail/TbSendEmailNode.java | 6 +- .../rule/engine/math/TbMathNode.java | 7 +- .../engine/metadata/CalculateDeltaNode.java | 12 +- .../TbFetchDeviceCredentialsNode.java | 6 +- .../engine/metadata/TbGetAttributesNode.java | 12 +- .../metadata/TbGetCustomerAttributeNode.java | 6 +- .../metadata/TbGetCustomerDetailsNode.java | 9 +- .../engine/metadata/TbGetDeviceAttrNode.java | 9 +- .../metadata/TbGetOriginatorFieldsNode.java | 12 +- .../metadata/TbGetRelatedAttributeNode.java | 6 +- .../engine/metadata/TbGetTelemetryNode.java | 12 +- .../metadata/TbGetTenantAttributeNode.java | 6 +- .../metadata/TbGetTenantDetailsNode.java | 9 +- .../rule/engine/mqtt/TbMqttNode.java | 5 +- .../engine/mqtt/azure/TbAzureIotHubNode.java | 5 +- .../notification/TbNotificationNode.java | 5 +- .../rule/engine/notification/TbSlackNode.java | 5 +- .../engine/profile/TbDeviceProfileNode.java | 7 +- .../rule/engine/rabbitmq/TbRabbitMqNode.java | 35 ++--- .../rule/engine/rest/TbRestApiCallNode.java | 5 +- .../rest/TbSendRestApiCallReplyNode.java | 8 +- .../rule/engine/rpc/TbSendRPCReplyNode.java | 13 +- .../rule/engine/rpc/TbSendRPCRequestNode.java | 11 +- .../rule/engine/sms/TbSendSmsNode.java | 5 +- .../telemetry/TbCalculatedFieldsNode.java | 13 +- .../engine/telemetry/TbMsgAttributesNode.java | 5 +- .../telemetry/TbMsgDeleteAttributesNode.java | 9 +- .../engine/telemetry/TbMsgTimeseriesNode.java | 5 +- .../transform/TbChangeOriginatorNode.java | 3 +- .../rule/engine/transform/TbCopyKeysNode.java | 10 +- .../engine/transform/TbDeleteKeysNode.java | 12 +- .../rule/engine/transform/TbJsonPathNode.java | 21 ++- .../engine/transform/TbRenameKeysNode.java | 10 +- .../engine/transform/TbSplitArrayMsgNode.java | 13 +- .../engine/transform/TbTransformMsgNode.java | 4 +- .../geo/GpsGeofencingActionTestCase.java | 9 +- ui-ngx/src/app/shared/models/constants.ts | 145 +++++++++--------- .../src/app/shared/models/rule-node.models.ts | 7 + 78 files changed, 390 insertions(+), 439 deletions(-) diff --git a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/action/TbAssignToCustomerNode.java b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/action/TbAssignToCustomerNode.java index 35c3ce7683..6f18e7c1dd 100644 --- a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/action/TbAssignToCustomerNode.java +++ b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/action/TbAssignToCustomerNode.java @@ -18,7 +18,6 @@ package org.thingsboard.rule.engine.action; import com.google.common.util.concurrent.Futures; import com.google.common.util.concurrent.ListenableFuture; import com.google.common.util.concurrent.MoreExecutors; -import lombok.extern.slf4j.Slf4j; import org.thingsboard.rule.engine.api.RuleNode; import org.thingsboard.rule.engine.api.TbContext; import org.thingsboard.rule.engine.api.TbNodeConfiguration; @@ -32,7 +31,6 @@ import org.thingsboard.server.common.data.id.EntityViewId; import org.thingsboard.server.common.data.plugin.ComponentType; import org.thingsboard.server.common.msg.TbMsg; -@Slf4j @RuleNode( type = ComponentType.ACTION, name = "assign to customer", @@ -42,7 +40,8 @@ import org.thingsboard.server.common.msg.TbMsg; "Rule node will create a new customer if it doesn't exist, and 'Create new customer if it doesn't exist' enabled.", configDirective = "tbActionNodeAssignToCustomerConfig", icon = "add_circle", - version = 1 + version = 1, + docUrl = "https://thingsboard.io/docs/user-guide/rule-engine-2-0/nodes/action/assign-to-customer/" ) public class TbAssignToCustomerNode extends TbAbstractCustomerActionNode { diff --git a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/action/TbClearAlarmNode.java b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/action/TbClearAlarmNode.java index 6a806e7bc1..4ef3d6d589 100644 --- a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/action/TbClearAlarmNode.java +++ b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/action/TbClearAlarmNode.java @@ -18,7 +18,6 @@ package org.thingsboard.rule.engine.action; import com.fasterxml.jackson.databind.JsonNode; import com.google.common.util.concurrent.Futures; import com.google.common.util.concurrent.ListenableFuture; -import lombok.extern.slf4j.Slf4j; import org.thingsboard.rule.engine.api.RuleNode; import org.thingsboard.rule.engine.api.TbContext; import org.thingsboard.rule.engine.api.TbNodeConfiguration; @@ -31,7 +30,6 @@ import org.thingsboard.server.common.data.id.AlarmId; import org.thingsboard.server.common.data.plugin.ComponentType; import org.thingsboard.server.common.msg.TbMsg; -@Slf4j @RuleNode( type = ComponentType.ACTION, name = "clear alarm", relationTypes = {"Cleared", "False"}, @@ -44,7 +42,8 @@ import org.thingsboard.server.common.msg.TbMsg; "Message payload can be accessed via msg property. For example 'temperature = ' + msg.temperature ;. " + "Message metadata can be accessed via metadata property. For example 'name = ' + metadata.customerName;.", configDirective = "tbActionNodeClearAlarmConfig", - icon = "notifications_off" + icon = "notifications_off", + docUrl = "https://thingsboard.io/docs/user-guide/rule-engine-2-0/nodes/action/clear-alarm/" ) public class TbClearAlarmNode extends TbAbstractAlarmNode { @@ -79,4 +78,5 @@ public class TbClearAlarmNode extends TbAbstractAlarmNode getFutureCallback(TbContext ctx, TbMsg msg, EntityView entityView) { - return new FutureCallback() { + return new FutureCallback<>() { @Override public void onSuccess(@Nullable Void result) { transformAndTellNext(ctx, msg, entityView); } @Override - public void onFailure(Throwable t) { + public void onFailure(@NonNull Throwable t) { ctx.tellFailure(msg, t); } }; @@ -157,18 +151,11 @@ public class TbCopyAttributesToEntityViewNode implements TbNode { private boolean attributeContainsInEntityView(AttributeScope scope, String attrKey, EntityView entityView) { AttributesEntityView attributesEntityView = entityView.getKeys().getAttributes(); - List keys = null; - switch (scope) { - case CLIENT_SCOPE: - keys = attributesEntityView.getCs(); - break; - case SERVER_SCOPE: - keys = attributesEntityView.getSs(); - break; - case SHARED_SCOPE: - keys = attributesEntityView.getSh(); - break; - } + List keys = switch (scope) { + case CLIENT_SCOPE -> attributesEntityView.getCs(); + case SERVER_SCOPE -> attributesEntityView.getSs(); + case SHARED_SCOPE -> attributesEntityView.getSh(); + }; return CollectionsUtil.contains(keys, attrKey); } diff --git a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/action/TbCreateAlarmNode.java b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/action/TbCreateAlarmNode.java index bd576be936..600c276901 100644 --- a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/action/TbCreateAlarmNode.java +++ b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/action/TbCreateAlarmNode.java @@ -19,7 +19,6 @@ import com.fasterxml.jackson.databind.JsonNode; import com.google.common.util.concurrent.Futures; import com.google.common.util.concurrent.ListenableFuture; import com.google.common.util.concurrent.MoreExecutors; -import lombok.extern.slf4j.Slf4j; import org.apache.commons.lang3.EnumUtils; import org.thingsboard.common.util.JacksonUtil; import org.thingsboard.rule.engine.api.RuleNode; @@ -39,7 +38,6 @@ import org.thingsboard.server.common.msg.TbMsg; import java.io.IOException; import java.util.List; -@Slf4j @RuleNode( type = ComponentType.ACTION, name = "create alarm", relationTypes = {"Created", "Updated", "False"}, @@ -52,7 +50,8 @@ import java.util.List; "Message payload can be accessed via msg property. For example 'temperature = ' + msg.temperature ;. " + "Message metadata can be accessed via metadata property. For example 'name = ' + metadata.customerName;.", configDirective = "tbActionNodeCreateAlarmConfig", - icon = "notifications_active" + icon = "notifications_active", + docUrl = "https://thingsboard.io/docs/user-guide/rule-engine-2-0/nodes/action/create-alarm/" ) public class TbCreateAlarmNode extends TbAbstractAlarmNode { @@ -62,10 +61,10 @@ public class TbCreateAlarmNode extends TbAbstractAlarmNodeSuccess - if the relation already exists or successfully created, otherwise Failure.", configDirective = "tbActionNodeCreateRelationConfig", icon = "add_circle", - version = 1 + version = 1, + docUrl = "https://thingsboard.io/docs/user-guide/rule-engine-2-0/nodes/action/create-relation/" ) public class TbCreateRelationNode extends TbAbstractRelationActionNode { diff --git a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/action/TbDeleteRelationNode.java b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/action/TbDeleteRelationNode.java index 82c3a0fab5..6a643aee75 100644 --- a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/action/TbDeleteRelationNode.java +++ b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/action/TbDeleteRelationNode.java @@ -18,7 +18,6 @@ package org.thingsboard.rule.engine.action; import com.google.common.util.concurrent.Futures; import com.google.common.util.concurrent.ListenableFuture; import com.google.common.util.concurrent.MoreExecutors; -import lombok.extern.slf4j.Slf4j; import org.thingsboard.rule.engine.api.RuleNode; import org.thingsboard.rule.engine.api.TbContext; import org.thingsboard.rule.engine.api.TbNodeConfiguration; @@ -32,8 +31,6 @@ import org.thingsboard.server.common.msg.TbMsg; import static org.thingsboard.common.util.DonAsynchron.withCallback; - -@Slf4j @RuleNode( type = ComponentType.ACTION, name = "delete relation", @@ -55,7 +52,8 @@ import static org.thingsboard.common.util.DonAsynchron.withCallback; "Output connections: Success - If the relation(s) successfully deleted, otherwise Failure.", configDirective = "tbActionNodeDeleteRelationConfig", icon = "remove_circle", - version = 1 + version = 1, + docUrl = "https://thingsboard.io/docs/user-guide/rule-engine-2-0/nodes/action/delete-relation/" ) public class TbDeleteRelationNode extends TbAbstractRelationActionNode { diff --git a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/action/TbDeviceStateNode.java b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/action/TbDeviceStateNode.java index c9a50cf88d..caff3fc832 100644 --- a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/action/TbDeviceStateNode.java +++ b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/action/TbDeviceStateNode.java @@ -58,7 +58,8 @@ import java.util.Set; "This node is particularly useful when device isn't using transports to receive data, such as when fetching data from external API or computing new data within the rule chain.", configClazz = TbDeviceStateNodeConfiguration.class, relationTypes = {TbNodeConnectionType.SUCCESS, TbNodeConnectionType.FAILURE, "Rate limited"}, - configDirective = "tbActionNodeDeviceStateConfig" + configDirective = "tbActionNodeDeviceStateConfig", + docUrl = "https://thingsboard.io/docs/user-guide/rule-engine-2-0/nodes/action/device-state/" ) public class TbDeviceStateNode implements TbNode { diff --git a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/action/TbLogNode.java b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/action/TbLogNode.java index 47ecac154f..d4a598cf1b 100644 --- a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/action/TbLogNode.java +++ b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/action/TbLogNode.java @@ -19,6 +19,7 @@ import com.google.common.util.concurrent.FutureCallback; import com.google.common.util.concurrent.Futures; import com.google.common.util.concurrent.MoreExecutors; import lombok.extern.slf4j.Slf4j; +import org.checkerframework.checker.nullness.qual.NonNull; import org.checkerframework.checker.nullness.qual.Nullable; import org.thingsboard.common.util.JacksonUtil; import org.thingsboard.rule.engine.api.RuleNode; @@ -44,19 +45,19 @@ import java.util.Objects; "Message payload can be accessed via msg property. For example 'temperature = ' + msg.temperature ;. " + "Message metadata can be accessed via metadata property. For example 'name = ' + metadata.customerName;.", configDirective = "tbActionNodeLogConfig", - icon = "menu" + icon = "menu", + docUrl = "https://thingsboard.io/docs/user-guide/rule-engine-2-0/nodes/action/log/" ) public class TbLogNode implements TbNode { - private TbLogNodeConfiguration config; private ScriptEngine scriptEngine; private boolean standard; @Override public void init(TbContext ctx, TbNodeConfiguration configuration) throws TbNodeException { - this.config = TbNodeUtils.convert(configuration, TbLogNodeConfiguration.class); - this.standard = isStandard(config); - this.scriptEngine = this.standard ? null : createScriptEngine(ctx, config); + var config = TbNodeUtils.convert(configuration, TbLogNodeConfiguration.class); + standard = isStandard(config); + scriptEngine = standard ? null : createScriptEngine(ctx, config); } ScriptEngine createScriptEngine(TbContext ctx, TbLogNodeConfiguration config) { @@ -75,7 +76,7 @@ public class TbLogNode implements TbNode { return; } - Futures.addCallback(scriptEngine.executeToStringAsync(msg), new FutureCallback() { + Futures.addCallback(scriptEngine.executeToStringAsync(msg), new FutureCallback<>() { @Override public void onSuccess(@Nullable String result) { log.info(result); @@ -83,7 +84,7 @@ public class TbLogNode implements TbNode { } @Override - public void onFailure(Throwable t) { + public void onFailure(@NonNull Throwable t) { ctx.tellFailure(msg, t); } }, MoreExecutors.directExecutor()); //usually js responses runs on js callback executor @@ -120,4 +121,5 @@ public class TbLogNode implements TbNode { scriptEngine.destroy(); } } + } diff --git a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/action/TbMsgCountNode.java b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/action/TbMsgCountNode.java index 3d93bea8a2..4488a95e31 100644 --- a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/action/TbMsgCountNode.java +++ b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/action/TbMsgCountNode.java @@ -17,7 +17,6 @@ package org.thingsboard.rule.engine.action; import com.google.gson.Gson; import com.google.gson.JsonObject; -import lombok.extern.slf4j.Slf4j; import org.thingsboard.rule.engine.api.RuleNode; import org.thingsboard.rule.engine.api.TbContext; import org.thingsboard.rule.engine.api.TbNode; @@ -34,7 +33,6 @@ import java.util.UUID; import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicLong; -@Slf4j @RuleNode( type = ComponentType.ACTION, name = "message count", @@ -42,7 +40,8 @@ import java.util.concurrent.atomic.AtomicLong; nodeDescription = "Count incoming messages", nodeDetails = "Count incoming messages for specified interval and produces POST_TELEMETRY_REQUEST msg with messages count", icon = "functions", - configDirective = "tbActionNodeMsgCountConfig" + configDirective = "tbActionNodeMsgCountConfig", + docUrl = "https://thingsboard.io/docs/user-guide/rule-engine-2-0/nodes/action/message-count/" ) public class TbMsgCountNode implements TbNode { @@ -59,7 +58,6 @@ public class TbMsgCountNode implements TbNode { this.delay = TimeUnit.SECONDS.toMillis(config.getInterval()); this.telemetryPrefix = config.getTelemetryPrefix(); scheduleTickMsg(ctx, null); - } @Override diff --git a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/action/TbSaveToCustomCassandraTableNode.java b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/action/TbSaveToCustomCassandraTableNode.java index e56a3459f8..329e0e154b 100644 --- a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/action/TbSaveToCustomCassandraTableNode.java +++ b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/action/TbSaveToCustomCassandraTableNode.java @@ -57,7 +57,8 @@ import java.util.concurrent.atomic.AtomicInteger; import static org.thingsboard.common.util.DonAsynchron.withCallback; @Slf4j -@RuleNode(type = ComponentType.ACTION, +@RuleNode( + type = ComponentType.ACTION, name = "save to custom table", configClazz = TbSaveToCustomCassandraTableNodeConfiguration.class, version = 1, @@ -71,7 +72,9 @@ import static org.thingsboard.common.util.DonAsynchron.withCallback; " otherwise, the message will be routed via success chain.", configDirective = "tbActionNodeCustomTableConfig", icon = "file_upload", - ruleChainTypes = RuleChainType.CORE) + ruleChainTypes = RuleChainType.CORE, + docUrl = "https://thingsboard.io/docs/user-guide/rule-engine-2-0/nodes/action/save-to-custom-table/" +) public class TbSaveToCustomCassandraTableNode implements TbNode { private static final String TABLE_PREFIX = "cs_tb_"; diff --git a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/action/TbUnassignFromCustomerNode.java b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/action/TbUnassignFromCustomerNode.java index e889e3a970..0ff79dee39 100644 --- a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/action/TbUnassignFromCustomerNode.java +++ b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/action/TbUnassignFromCustomerNode.java @@ -44,7 +44,8 @@ import org.thingsboard.server.common.msg.TbMsg; "Other entities can be assigned only to one customer, so specified customer title in the configuration will be ignored if the originator isn't a dashboard.", configDirective = "tbActionNodeUnAssignToCustomerConfig", icon = "remove_circle", - version = 1 + version = 1, + docUrl = "https://thingsboard.io/docs/user-guide/rule-engine-2-0/nodes/action/unassign-from-customer/" ) public class TbUnassignFromCustomerNode extends TbAbstractCustomerActionNode { diff --git a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/ai/TbAiNode.java b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/ai/TbAiNode.java index bd02089204..054b06fb54 100644 --- a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/ai/TbAiNode.java +++ b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/ai/TbAiNode.java @@ -92,8 +92,8 @@ import static org.thingsboard.server.dao.service.ConstraintValidator.validateFie configClazz = TbAiNodeConfiguration.class, configDirective = "tbExternalNodeAiConfig", iconUrl = "data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iNDkiIGhlaWdodD0iNDgiIHZpZXdCb3g9IjAgMCA0OSA0OCIgZmlsbD0ibm9uZSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj4KPHBhdGggZmlsbC1ydWxlPSJldmVub2RkIiBjbGlwLXJ1bGU9ImV2ZW5vZGQiIGQ9Ik0zOC42MzExIDE3LjA3OTVDNDAuMTcwNSAxNy4wNzk2IDQxLjY1MTggMTcuNjg3MiA0Mi43NDc4IDE4Ljc3NjNDNDMuODQ0OCAxOS44NjYzIDQ0LjQ2NTkgMjEuMzUwMSA0NC40NjU5IDIyLjkwMjlWMzUuNDY1MkM0NC40NjU5IDM2LjM1MDkgNDQuMzU2NyAzNy4wNzY5IDQ0LjA5NzMgMzcuNzUxN0M0My44NDE0IDM4LjQxNjcgNDMuNDY1MSAzOC45NjE0IDQzLjA0NDggMzkuNTAyOEM0Mi40NjY3IDQwLjI0NzIgNDEuNjU2MyA0MC42ODU5IDQwLjg5MTkgNDAuOTM4OEM0MC4xMjExIDQxLjE5MzcgMzkuMzE0MyA0MS4yODg1IDM4LjYzMTEgNDEuMjg4NUgzMS4wMjU5TDIzLjM4MTIgNDUuODQ2NEMyMy4wNDMxIDQ2LjA0NzggMjIuNjI0MSA0Ni4wNTA3IDIyLjI4MzkgNDUuODUyOUMyMS45NDM3IDQ1LjY1NDcgMjEuNzMzOCA0NS4yODU5IDIxLjczMzcgNDQuODg3MlY0MS4yODg1SDE5LjY2NjNDMTguMTI2OSA0MS4yODg0IDE2LjY0NTUgNDAuNjgwOSAxNS41NDk2IDM5LjU5MThDMTQuNDUyNyAzOC41MDE5IDEzLjgzMTUgMzcuMDE3OSAxMy44MzE1IDM1LjQ2NTJWMjIuOTAyOUMxMy44MzE1IDIyLjMyMDIgMTMuOTE4NSAyMS43NDY4IDE0LjA4NTggMjEuMjAwN0wxNi4yODg5IDIxLjgxMDFMMTcuMjA5OSAyNS4yNTAyQzE3Ljk0MTYgMjcuOTg0NSAyMS43NTYyIDI3Ljk4NDQgMjIuNDg4IDI1LjI1MDJMMjMuNDA3OSAyMS44MTAxTDI2Ljc5MTcgMjAuODc0OUMyOC41NzkxIDIwLjM4MDUgMjkuMTc3IDE4LjUwMjYgMjguNTg4OCAxNy4wNzk1SDM4LjYzMTFaTTIyLjU4NDIgMzEuNTM5NUMyMS45OCAzMS41Mzk3IDIxLjQ5MDEgMzIuMDM3NiAyMS40OTAxIDMyLjY1MTlDMjEuNDkwMiAzMy4yNjYgMjEuOTgwMSAzMy43NjQgMjIuNTg0MiAzMy43NjQySDM0LjYxOTFDMzUuMjIzMyAzMy43NjQyIDM1LjcxMzEgMzMuMjY2MSAzNS43MTMyIDMyLjY1MTlDMzUuNzEzMiAzMi4wMzc1IDM1LjIyMzQgMzEuNTM5NSAzNC42MTkxIDMxLjUzOTVIMjIuNTg0MlpNMjQuNzcyMyAyNC44NjU3QzI0LjE2ODIgMjQuODY1OCAyMy42NzgzIDI1LjM2MzggMjMuNjc4MyAyNS45NzhDMjMuNjc4NCAyNi41OTIyIDI0LjE2ODMgMjcuMDkwMiAyNC43NzIzIDI3LjA5MDNIMzcuOTAxNEMzOC41MDU1IDI3LjA5MDMgMzguOTk1MyAyNi41OTIyIDM4Ljk5NTQgMjUuOTc4QzM4Ljk5NTQgMjUuMzYzNyAzOC41MDU2IDI0Ljg2NTcgMzcuOTAxNCAyNC44NjU3SDI0Ljc3MjNaIiBmaWxsPSJibGFjayIgZmlsbC1vcGFjaXR5PSIwLjc2Ii8+CjxwYXRoIGQ9Ik0xOC43ODkxIDExLjI5NzVDMTkuMDY5MSAxMC4xODA4IDIwLjYyOTkgMTAuMTgwOCAyMC45MDk5IDExLjI5NzVMMjEuOTE0MyAxNS4zMDM2QzIyLjAxMTYgMTUuNjkxOCAyMi4zMDY1IDE1Ljk5NzggMjIuNjg2NyAxNi4xMDNMMjYuMzYxMSAxNy4xMTg3QzI3LjQzNyAxNy40MTYyIDI3LjQzNyAxOC45Njc2IDI2LjM2MTEgMTkuMjY1MUwyMi42NzYxIDIwLjI4NEMyMi4zMDE4IDIwLjM4NzQgMjIuMDA4NyAyMC42ODQ1IDIxLjkwNjggMjEuMDY1TDIwLjkwNDYgMjQuODEyNUMyMC42MTE3IDI1LjkwNTggMTkuMDg2MSAyNS45MDU5IDE4Ljc5MzMgMjQuODEyNUwxNy43OTExIDIxLjA2NUMxNy42ODkzIDIwLjY4NDcgMTcuMzk3IDIwLjM4NzUgMTcuMDIyOSAyMC4yODRMMTMuMzM2OCAxOS4yNjUxQzEyLjI2MTQgMTguOTY3MyAxMi4yNjE1IDE3LjQxNjUgMTMuMzM2OCAxNy4xMTg3TDE3LjAxMTIgMTYuMTAzQzE3LjM5MTYgMTUuOTk3OCAxNy42ODc0IDE1LjY5MTkgMTcuNzg0NyAxNS4zMDM2TDE4Ljc4OTEgMTEuMjk3NVoiIGZpbGw9ImJsYWNrIiBmaWxsLW9wYWNpdHk9IjAuNzYiLz4KPHBhdGggZD0iTTEwLjAzNDMgNy4wMjQyNUMxMC4zMDY4IDUuODk0NDQgMTEuODg2OCA1Ljg5NDQ0IDEyLjE1OTQgNy4wMjQyNUwxMi42OTg5IDkuMjYyOThDMTIuNzkyNyA5LjY1MTc0IDEzLjA4NTEgOS45NTg4NyAxMy40NjQgMTAuMDY3OUwxNS41NzczIDEwLjY3NTFDMTYuNjM5MyAxMC45ODAzIDE2LjYzOTMgMTIuNTEwOSAxNS41NzczIDEyLjgxNjFMMTMuNDUzMyAxMy40MjY1QzEzLjA4MDIgMTMuNTMzOCAxMi43OTA4IDEzLjgzMzkgMTIuNjkyNSAxNC4yMTUxTDEyLjE1NTEgMTYuMzA0QzExLjg3IDE3LjQxMTYgMTAuMzIzNiAxNy40MTE2IDEwLjAzODUgMTYuMzA0TDkuNTAwMDMgMTQuMjE1MUM5LjQwMTczIDEzLjgzMzkgOS4xMTIzNSAxMy41MzM3IDguNzM5MyAxMy40MjY1TDYuNjE1MjQgMTIuODE2MUM1LjU1Mzc4IDEyLjUxMDYgNS41NTM2NCAxMC45ODA0IDYuNjE1MjQgMTAuNjc1MUw4LjcyODYyIDEwLjA2NzlDOS4xMDc2IDkuOTU4OTggOS4zOTk3OCA5LjY1MTg0IDkuNDkzNjIgOS4yNjI5OEwxMC4wMzQzIDcuMDI0MjVaIiBmaWxsPSJibGFjayIgZmlsbC1vcGFjaXR5PSIwLjc2Ii8+CjxwYXRoIGQ9Ik0yNS45MDI4IDYuNzMzMTNDMjYuMTg3OCA1LjYyNTQxIDI3LjczNDMgNS42MjU0MSAyOC4wMTkzIDYuNzMzMTNMMjguMjAzMSA3LjQ0Njc5QzI4LjMwMyA3LjgzNDMxIDI4LjYwMDEgOC4xMzcwNSAyOC45ODA5IDguMjM5NzVMMjkuNTM0NCA4LjM4OTY1QzMwLjYxOTIgOC42ODIxMiAzMC42MTkzIDEwLjI0NjkgMjkuNTM0NCAxMC41MzkzTDI4Ljk2OTIgMTAuNjkxNEMyOC41OTQ0IDEwLjc5MjUgMjguMjk5OSAxMS4wODgzIDI4LjE5NTYgMTEuNDY4TDI4LjAxNTEgMTIuMTI4NUMyNy43MTc0IDEzLjIxMjggMjYuMjA0NyAxMy4yMTI4IDI1LjkwNyAxMi4xMjg1TDI1LjcyNTQgMTEuNDY4QzI1LjYyMTEgMTEuMDg4MiAyNS4zMjY4IDEwLjc5MjQgMjQuOTUxOCAxMC42OTE0TDI0LjM4NzcgMTAuNTM5M0MyMy4zMDI2IDEwLjI0NyAyMy4zMDI2IDguNjgxOTggMjQuMzg3NyA4LjM4OTY1TDI0Ljk0MDEgOC4yMzk3NUMyNS4zMjExIDguMTM3MDkgMjUuNjE5MSA3LjgzNDQ2IDI1LjcxOSA3LjQ0Njc5TDI1LjkwMjggNi43MzMxM1oiIGZpbGw9ImJsYWNrIiBmaWxsLW9wYWNpdHk9IjAuNzYiLz4KPC9zdmc+Cg==", - docUrl = "https://thingsboard.io/docs/user-guide/rule-engine-2-0/external-nodes/#ai-request-node", - ruleChainTypes = RuleChainType.CORE + ruleChainTypes = RuleChainType.CORE, + docUrl = "https://thingsboard.io/docs/user-guide/rule-engine-2-0/nodes/external/ai-request/" ) public final class TbAiNode extends TbAbstractExternalNode implements TbNode { diff --git a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/aws/lambda/TbAwsLambdaNode.java b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/aws/lambda/TbAwsLambdaNode.java index 82aa667319..c4e448e2ee 100644 --- a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/aws/lambda/TbAwsLambdaNode.java +++ b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/aws/lambda/TbAwsLambdaNode.java @@ -54,7 +54,8 @@ import static org.thingsboard.server.dao.service.ConstraintValidator.validateFie "The node uses a pre-configured client and specified function to run.

" + "Output connections: Success, Failure.", configDirective = "tbExternalNodeLambdaConfig", - iconUrl = "data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNCAyNCIgd2lkdGg9IjQ4IiBoZWlnaHQ9IjQ4Ij48cGF0aCBkPSJNMTMuMjMgMTAuNTZWMTBjLTEuOTQgMC0zLjk5LjM5LTMuOTkgMi42NyAwIDEuMTYuNjEgMS45NSAxLjYzIDEuOTUuNzYgMCAxLjQzLS40NyAxLjg2LTEuMjIuNTItLjkzLjUtMS44LjUtMi44NG0yLjcgNi41M2MtLjE4LjE2LS40My4xNy0uNjMuMDYtLjg5LS43NC0xLjA1LTEuMDgtMS41NC0xLjc5LTEuNDcgMS41LTIuNTEgMS45NS00LjQyIDEuOTUtMi4yNSAwLTQuMDEtMS4zOS00LjAxLTQuMTcgMC0yLjE4IDEuMTctMy42NCAyLjg2LTQuMzggMS40Ni0uNjQgMy40OS0uNzYgNS4wNC0uOTNWNy41YzAtLjY2LjA1LTEuNDEtLjMzLTEuOTYtLjMyLS40OS0uOTUtLjctMS41LS43LTEuMDIgMC0xLjkzLjUzLTIuMTUgMS42MS0uMDUuMjQtLjI1LjQ4LS40Ny40OWwtMi42LS4yOGMtLjIyLS4wNS0uNDYtLjIyLS40LS41Ni42LTMuMTUgMy40NS00LjEgNi00LjEgMS4zIDAgMyAuMzUgNC4wMyAxLjMzQzE3LjExIDQuNTUgMTcgNi4xOCAxNyA3Ljk1djQuMTdjMCAxLjI1LjUgMS44MSAxIDIuNDguMTcuMjUuMjEuNTQgMCAuNzFsLTIuMDYgMS43OGgtLjAxIj48L3BhdGg+PHBhdGggZD0iTTIwLjE2IDE5LjU0QzE4IDIxLjE0IDE0LjgyIDIyIDEyLjEgMjJjLTMuODEgMC03LjI1LTEuNDEtOS44NS0zLjc2LS4yLS4xOC0uMDItLjQzLjI1LS4yOSAyLjc4IDEuNjMgNi4yNSAyLjYxIDkuODMgMi42MSAyLjQxIDAgNS4wNy0uNSA3LjUxLTEuNTMuMzctLjE2LjY2LjI0LjMyLjUxIj48L3BhdGg+PHBhdGggZD0iTTIxLjA3IDE4LjVjLS4yOC0uMzYtMS44NS0uMTctMi41Ny0uMDgtLjE5LjAyLS4yMi0uMTYtLjAzLS4zIDEuMjQtLjg4IDMuMjktLjYyIDMuNTMtLjMzLjI0LjMtLjA3IDIuMzUtMS4yNCAzLjMyLS4xOC4xNi0uMzUuMDctLjI2LS4xMS4yNi0uNjcuODUtMi4xNC41Ny0yLjV6Ij48L3BhdGg+PC9zdmc+" + iconUrl = "data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNCAyNCIgd2lkdGg9IjQ4IiBoZWlnaHQ9IjQ4Ij48cGF0aCBkPSJNMTMuMjMgMTAuNTZWMTBjLTEuOTQgMC0zLjk5LjM5LTMuOTkgMi42NyAwIDEuMTYuNjEgMS45NSAxLjYzIDEuOTUuNzYgMCAxLjQzLS40NyAxLjg2LTEuMjIuNTItLjkzLjUtMS44LjUtMi44NG0yLjcgNi41M2MtLjE4LjE2LS40My4xNy0uNjMuMDYtLjg5LS43NC0xLjA1LTEuMDgtMS41NC0xLjc5LTEuNDcgMS41LTIuNTEgMS45NS00LjQyIDEuOTUtMi4yNSAwLTQuMDEtMS4zOS00LjAxLTQuMTcgMC0yLjE4IDEuMTctMy42NCAyLjg2LTQuMzggMS40Ni0uNjQgMy40OS0uNzYgNS4wNC0uOTNWNy41YzAtLjY2LjA1LTEuNDEtLjMzLTEuOTYtLjMyLS40OS0uOTUtLjctMS41LS43LTEuMDIgMC0xLjkzLjUzLTIuMTUgMS42MS0uMDUuMjQtLjI1LjQ4LS40Ny40OWwtMi42LS4yOGMtLjIyLS4wNS0uNDYtLjIyLS40LS41Ni42LTMuMTUgMy40NS00LjEgNi00LjEgMS4zIDAgMyAuMzUgNC4wMyAxLjMzQzE3LjExIDQuNTUgMTcgNi4xOCAxNyA3Ljk1djQuMTdjMCAxLjI1LjUgMS44MSAxIDIuNDguMTcuMjUuMjEuNTQgMCAuNzFsLTIuMDYgMS43OGgtLjAxIj48L3BhdGg+PHBhdGggZD0iTTIwLjE2IDE5LjU0QzE4IDIxLjE0IDE0LjgyIDIyIDEyLjEgMjJjLTMuODEgMC03LjI1LTEuNDEtOS44NS0zLjc2LS4yLS4xOC0uMDItLjQzLjI1LS4yOSAyLjc4IDEuNjMgNi4yNSAyLjYxIDkuODMgMi42MSAyLjQxIDAgNS4wNy0uNSA3LjUxLTEuNTMuMzctLjE2LjY2LjI0LjMyLjUxIj48L3BhdGg+PHBhdGggZD0iTTIxLjA3IDE4LjVjLS4yOC0uMzYtMS44NS0uMTctMi41Ny0uMDgtLjE5LjAyLS4yMi0uMTYtLjAzLS4zIDEuMjQtLjg4IDMuMjktLjYyIDMuNTMtLjMzLjI0LjMtLjA3IDIuMzUtMS4yNCAzLjMyLS4xOC4xNi0uMzUuMDctLjI2LS4xMS4yNi0uNjcuODUtMi4xNC41Ny0yLjV6Ij48L3BhdGg+PC9zdmc+", + docUrl = "https://thingsboard.io/docs/user-guide/rule-engine-2-0/nodes/external/aws-lambda/" ) public class TbAwsLambdaNode extends TbAbstractExternalNode { @@ -156,4 +157,5 @@ public class TbAwsLambdaNode extends TbAbstractExternalNode { } } } + } diff --git a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/aws/sns/TbSnsNode.java b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/aws/sns/TbSnsNode.java index a13478c717..1877aff733 100644 --- a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/aws/sns/TbSnsNode.java +++ b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/aws/sns/TbSnsNode.java @@ -47,7 +47,8 @@ import static org.thingsboard.common.util.DonAsynchron.withCallback; "(messageId, requestId) in the Message Metadata from the AWS SNS. " + "For example requestId field can be accessed with metadata.requestId.", configDirective = "tbExternalNodeSnsConfig", - iconUrl = "data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNCAyNCIgd2lkdGg9IjQ4IiBoZWlnaHQ9IjQ4Ij48cGF0aCBkPSJNMTMuMjMgMTAuNTZWMTBjLTEuOTQgMC0zLjk5LjM5LTMuOTkgMi42NyAwIDEuMTYuNjEgMS45NSAxLjYzIDEuOTUuNzYgMCAxLjQzLS40NyAxLjg2LTEuMjIuNTItLjkzLjUtMS44LjUtMi44NG0yLjcgNi41M2MtLjE4LjE2LS40My4xNy0uNjMuMDYtLjg5LS43NC0xLjA1LTEuMDgtMS41NC0xLjc5LTEuNDcgMS41LTIuNTEgMS45NS00LjQyIDEuOTUtMi4yNSAwLTQuMDEtMS4zOS00LjAxLTQuMTcgMC0yLjE4IDEuMTctMy42NCAyLjg2LTQuMzggMS40Ni0uNjQgMy40OS0uNzYgNS4wNC0uOTNWNy41YzAtLjY2LjA1LTEuNDEtLjMzLTEuOTYtLjMyLS40OS0uOTUtLjctMS41LS43LTEuMDIgMC0xLjkzLjUzLTIuMTUgMS42MS0uMDUuMjQtLjI1LjQ4LS40Ny40OWwtMi42LS4yOGMtLjIyLS4wNS0uNDYtLjIyLS40LS41Ni42LTMuMTUgMy40NS00LjEgNi00LjEgMS4zIDAgMyAuMzUgNC4wMyAxLjMzQzE3LjExIDQuNTUgMTcgNi4xOCAxNyA3Ljk1djQuMTdjMCAxLjI1LjUgMS44MSAxIDIuNDguMTcuMjUuMjEuNTQgMCAuNzFsLTIuMDYgMS43OGgtLjAxIj48L3BhdGg+PHBhdGggZD0iTTIwLjE2IDE5LjU0QzE4IDIxLjE0IDE0LjgyIDIyIDEyLjEgMjJjLTMuODEgMC03LjI1LTEuNDEtOS44NS0zLjc2LS4yLS4xOC0uMDItLjQzLjI1LS4yOSAyLjc4IDEuNjMgNi4yNSAyLjYxIDkuODMgMi42MSAyLjQxIDAgNS4wNy0uNSA3LjUxLTEuNTMuMzctLjE2LjY2LjI0LjMyLjUxIj48L3BhdGg+PHBhdGggZD0iTTIxLjA3IDE4LjVjLS4yOC0uMzYtMS44NS0uMTctMi41Ny0uMDgtLjE5LjAyLS4yMi0uMTYtLjAzLS4zIDEuMjQtLjg4IDMuMjktLjYyIDMuNTMtLjMzLjI0LjMtLjA3IDIuMzUtMS4yNCAzLjMyLS4xOC4xNi0uMzUuMDctLjI2LS4xMS4yNi0uNjcuODUtMi4xNC41Ny0yLjV6Ij48L3BhdGg+PC9zdmc+" + iconUrl = "data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNCAyNCIgd2lkdGg9IjQ4IiBoZWlnaHQ9IjQ4Ij48cGF0aCBkPSJNMTMuMjMgMTAuNTZWMTBjLTEuOTQgMC0zLjk5LjM5LTMuOTkgMi42NyAwIDEuMTYuNjEgMS45NSAxLjYzIDEuOTUuNzYgMCAxLjQzLS40NyAxLjg2LTEuMjIuNTItLjkzLjUtMS44LjUtMi44NG0yLjcgNi41M2MtLjE4LjE2LS40My4xNy0uNjMuMDYtLjg5LS43NC0xLjA1LTEuMDgtMS41NC0xLjc5LTEuNDcgMS41LTIuNTEgMS45NS00LjQyIDEuOTUtMi4yNSAwLTQuMDEtMS4zOS00LjAxLTQuMTcgMC0yLjE4IDEuMTctMy42NCAyLjg2LTQuMzggMS40Ni0uNjQgMy40OS0uNzYgNS4wNC0uOTNWNy41YzAtLjY2LjA1LTEuNDEtLjMzLTEuOTYtLjMyLS40OS0uOTUtLjctMS41LS43LTEuMDIgMC0xLjkzLjUzLTIuMTUgMS42MS0uMDUuMjQtLjI1LjQ4LS40Ny40OWwtMi42LS4yOGMtLjIyLS4wNS0uNDYtLjIyLS40LS41Ni42LTMuMTUgMy40NS00LjEgNi00LjEgMS4zIDAgMyAuMzUgNC4wMyAxLjMzQzE3LjExIDQuNTUgMTcgNi4xOCAxNyA3Ljk1djQuMTdjMCAxLjI1LjUgMS44MSAxIDIuNDguMTcuMjUuMjEuNTQgMCAuNzFsLTIuMDYgMS43OGgtLjAxIj48L3BhdGg+PHBhdGggZD0iTTIwLjE2IDE5LjU0QzE4IDIxLjE0IDE0LjgyIDIyIDEyLjEgMjJjLTMuODEgMC03LjI1LTEuNDEtOS44NS0zLjc2LS4yLS4xOC0uMDItLjQzLjI1LS4yOSAyLjc4IDEuNjMgNi4yNSAyLjYxIDkuODMgMi42MSAyLjQxIDAgNS4wNy0uNSA3LjUxLTEuNTMuMzctLjE2LjY2LjI0LjMyLjUxIj48L3BhdGg+PHBhdGggZD0iTTIxLjA3IDE4LjVjLS4yOC0uMzYtMS44NS0uMTctMi41Ny0uMDgtLjE5LjAyLS4yMi0uMTYtLjAzLS4zIDEuMjQtLjg4IDMuMjktLjYyIDMuNTMtLjMzLjI0LjMtLjA3IDIuMzUtMS4yNCAzLjMyLS4xOC4xNi0uMzUuMDctLjI2LS4xMS4yNi0uNjcuODUtMi4xNC41Ny0yLjV6Ij48L3BhdGg+PC9zdmc+", + docUrl = "https://thingsboard.io/docs/user-guide/rule-engine-2-0/nodes/external/aws-sns/" ) public class TbSnsNode extends TbAbstractExternalNode { @@ -125,4 +126,5 @@ public class TbSnsNode extends TbAbstractExternalNode { } } } + } diff --git a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/aws/sqs/TbSqsNode.java b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/aws/sqs/TbSqsNode.java index 5d538df932..2183f20c1c 100644 --- a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/aws/sqs/TbSqsNode.java +++ b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/aws/sqs/TbSqsNode.java @@ -53,7 +53,8 @@ import static org.thingsboard.common.util.DonAsynchron.withCallback; ", sequenceNumber) in the Message Metadata from the AWS SQS." + " For example requestId field can be accessed with metadata.requestId.", configDirective = "tbExternalNodeSqsConfig", - iconUrl = "data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNCAyNCIgd2lkdGg9IjQ4IiBoZWlnaHQ9IjQ4Ij48cGF0aCBkPSJNMTMuMjMgMTAuNTZWMTBjLTEuOTQgMC0zLjk5LjM5LTMuOTkgMi42NyAwIDEuMTYuNjEgMS45NSAxLjYzIDEuOTUuNzYgMCAxLjQzLS40NyAxLjg2LTEuMjIuNTItLjkzLjUtMS44LjUtMi44NG0yLjcgNi41M2MtLjE4LjE2LS40My4xNy0uNjMuMDYtLjg5LS43NC0xLjA1LTEuMDgtMS41NC0xLjc5LTEuNDcgMS41LTIuNTEgMS45NS00LjQyIDEuOTUtMi4yNSAwLTQuMDEtMS4zOS00LjAxLTQuMTcgMC0yLjE4IDEuMTctMy42NCAyLjg2LTQuMzggMS40Ni0uNjQgMy40OS0uNzYgNS4wNC0uOTNWNy41YzAtLjY2LjA1LTEuNDEtLjMzLTEuOTYtLjMyLS40OS0uOTUtLjctMS41LS43LTEuMDIgMC0xLjkzLjUzLTIuMTUgMS42MS0uMDUuMjQtLjI1LjQ4LS40Ny40OWwtMi42LS4yOGMtLjIyLS4wNS0uNDYtLjIyLS40LS41Ni42LTMuMTUgMy40NS00LjEgNi00LjEgMS4zIDAgMyAuMzUgNC4wMyAxLjMzQzE3LjExIDQuNTUgMTcgNi4xOCAxNyA3Ljk1djQuMTdjMCAxLjI1LjUgMS44MSAxIDIuNDguMTcuMjUuMjEuNTQgMCAuNzFsLTIuMDYgMS43OGgtLjAxIj48L3BhdGg+PHBhdGggZD0iTTIwLjE2IDE5LjU0QzE4IDIxLjE0IDE0LjgyIDIyIDEyLjEgMjJjLTMuODEgMC03LjI1LTEuNDEtOS44NS0zLjc2LS4yLS4xOC0uMDItLjQzLjI1LS4yOSAyLjc4IDEuNjMgNi4yNSAyLjYxIDkuODMgMi42MSAyLjQxIDAgNS4wNy0uNSA3LjUxLTEuNTMuMzctLjE2LjY2LjI0LjMyLjUxIj48L3BhdGg+PHBhdGggZD0iTTIxLjA3IDE4LjVjLS4yOC0uMzYtMS44NS0uMTctMi41Ny0uMDgtLjE5LjAyLS4yMi0uMTYtLjAzLS4zIDEuMjQtLjg4IDMuMjktLjYyIDMuNTMtLjMzLjI0LjMtLjA3IDIuMzUtMS4yNCAzLjMyLS4xOC4xNi0uMzUuMDctLjI2LS4xMS4yNi0uNjcuODUtMi4xNC41Ny0yLjV6Ij48L3BhdGg+PC9zdmc+" + iconUrl = "data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNCAyNCIgd2lkdGg9IjQ4IiBoZWlnaHQ9IjQ4Ij48cGF0aCBkPSJNMTMuMjMgMTAuNTZWMTBjLTEuOTQgMC0zLjk5LjM5LTMuOTkgMi42NyAwIDEuMTYuNjEgMS45NSAxLjYzIDEuOTUuNzYgMCAxLjQzLS40NyAxLjg2LTEuMjIuNTItLjkzLjUtMS44LjUtMi44NG0yLjcgNi41M2MtLjE4LjE2LS40My4xNy0uNjMuMDYtLjg5LS43NC0xLjA1LTEuMDgtMS41NC0xLjc5LTEuNDcgMS41LTIuNTEgMS45NS00LjQyIDEuOTUtMi4yNSAwLTQuMDEtMS4zOS00LjAxLTQuMTcgMC0yLjE4IDEuMTctMy42NCAyLjg2LTQuMzggMS40Ni0uNjQgMy40OS0uNzYgNS4wNC0uOTNWNy41YzAtLjY2LjA1LTEuNDEtLjMzLTEuOTYtLjMyLS40OS0uOTUtLjctMS41LS43LTEuMDIgMC0xLjkzLjUzLTIuMTUgMS42MS0uMDUuMjQtLjI1LjQ4LS40Ny40OWwtMi42LS4yOGMtLjIyLS4wNS0uNDYtLjIyLS40LS41Ni42LTMuMTUgMy40NS00LjEgNi00LjEgMS4zIDAgMyAuMzUgNC4wMyAxLjMzQzE3LjExIDQuNTUgMTcgNi4xOCAxNyA3Ljk1djQuMTdjMCAxLjI1LjUgMS44MSAxIDIuNDguMTcuMjUuMjEuNTQgMCAuNzFsLTIuMDYgMS43OGgtLjAxIj48L3BhdGg+PHBhdGggZD0iTTIwLjE2IDE5LjU0QzE4IDIxLjE0IDE0LjgyIDIyIDEyLjEgMjJjLTMuODEgMC03LjI1LTEuNDEtOS44NS0zLjc2LS4yLS4xOC0uMDItLjQzLjI1LS4yOSAyLjc4IDEuNjMgNi4yNSAyLjYxIDkuODMgMi42MSAyLjQxIDAgNS4wNy0uNSA3LjUxLTEuNTMuMzctLjE2LjY2LjI0LjMyLjUxIj48L3BhdGg+PHBhdGggZD0iTTIxLjA3IDE4LjVjLS4yOC0uMzYtMS44NS0uMTctMi41Ny0uMDgtLjE5LjAyLS4yMi0uMTYtLjAzLS4zIDEuMjQtLjg4IDMuMjktLjYyIDMuNTMtLjMzLjI0LjMtLjA3IDIuMzUtMS4yNCAzLjMyLS4xOC4xNi0uMzUuMDctLjI2LS4xMS4yNi0uNjcuODUtMi4xNC41Ny0yLjV6Ij48L3BhdGg+PC9zdmc+", + docUrl = "https://thingsboard.io/docs/user-guide/rule-engine-2-0/nodes/external/aws-sqs/" ) public class TbSqsNode extends TbAbstractExternalNode { @@ -156,4 +157,5 @@ public class TbSqsNode extends TbAbstractExternalNode { } } } + } diff --git a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/debug/TbMsgGeneratorNode.java b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/debug/TbMsgGeneratorNode.java index 90ee0c9048..34514fd644 100644 --- a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/debug/TbMsgGeneratorNode.java +++ b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/debug/TbMsgGeneratorNode.java @@ -63,9 +63,9 @@ import static org.thingsboard.server.common.data.DataConstants.QUEUE_NAME; nodeDetails = "Generates messages with configurable period. Javascript function used for message generation.", inEnabled = false, configDirective = "tbActionNodeGeneratorConfig", - icon = "repeat" + icon = "repeat", + docUrl = "https://thingsboard.io/docs/user-guide/rule-engine-2-0/nodes/action/generator/" ) - public class TbMsgGeneratorNode implements TbNode { private static final Set supportedEntityTypes = EnumSet.of(EntityType.DEVICE, EntityType.ASSET, EntityType.ENTITY_VIEW, diff --git a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/deduplication/TbMsgDeduplicationNode.java b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/deduplication/TbMsgDeduplicationNode.java index ce5fba102a..3a78004820 100644 --- a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/deduplication/TbMsgDeduplicationNode.java +++ b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/deduplication/TbMsgDeduplicationNode.java @@ -46,6 +46,7 @@ import java.util.concurrent.TimeUnit; import static org.thingsboard.server.common.data.DataConstants.QUEUE_NAME; +@Slf4j @RuleNode( type = ComponentType.TRANSFORMATION, name = "deduplication", @@ -59,9 +60,9 @@ import static org.thingsboard.server.common.data.DataConstants.QUEUE_NAME; "
  • ALL - return all messages as a single JSON array message. " + "Where each element represents object with msg and metadata inner properties.
  • ", icon = "content_copy", - configDirective = "tbTransformationNodeDeduplicationConfig" + configDirective = "tbTransformationNodeDeduplicationConfig", + docUrl = "https://thingsboard.io/docs/user-guide/rule-engine-2-0/nodes/transformation/deduplication/" ) -@Slf4j public class TbMsgDeduplicationNode implements TbNode { public static final long TB_MSG_DEDUPLICATION_RETRY_DELAY = 10L; diff --git a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/delay/TbMsgDelayNode.java b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/delay/TbMsgDelayNode.java index 3507f7c500..4cda58e290 100644 --- a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/delay/TbMsgDelayNode.java +++ b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/delay/TbMsgDelayNode.java @@ -15,7 +15,6 @@ */ package org.thingsboard.rule.engine.delay; -import lombok.extern.slf4j.Slf4j; import org.apache.commons.lang3.math.NumberUtils; import org.thingsboard.rule.engine.api.RuleNode; import org.thingsboard.rule.engine.api.TbContext; @@ -34,7 +33,6 @@ import java.util.Map; import java.util.UUID; import java.util.concurrent.TimeUnit; -@Slf4j @RuleNode( type = ComponentType.ACTION, name = "delay (deprecated)", @@ -45,7 +43,8 @@ import java.util.concurrent.TimeUnit; "Deprecated because the acknowledged message still stays in memory (to be delayed) and this " + "does not guarantee that message will be processed even if the \"retry failures and timeouts\" processing strategy will be chosen.", icon = "pause", - configDirective = "tbActionNodeMsgDelayConfig" + configDirective = "tbActionNodeMsgDelayConfig", + docUrl = "https://thingsboard.io/docs/user-guide/rule-engine-2-0/nodes/action/delay/" ) public class TbMsgDelayNode implements TbNode { @@ -109,4 +108,5 @@ public class TbMsgDelayNode implements TbNode { public void destroy() { pendingMsgs.clear(); } + } diff --git a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/edge/TbMsgPushToCloudNode.java b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/edge/TbMsgPushToCloudNode.java index 668a8403da..8edb3368c6 100644 --- a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/edge/TbMsgPushToCloudNode.java +++ b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/edge/TbMsgPushToCloudNode.java @@ -16,7 +16,6 @@ package org.thingsboard.rule.engine.edge; import com.fasterxml.jackson.databind.JsonNode; -import lombok.extern.slf4j.Slf4j; import org.thingsboard.rule.engine.api.RuleNode; import org.thingsboard.rule.engine.api.TbContext; import org.thingsboard.server.common.data.EntityType; @@ -28,7 +27,6 @@ import org.thingsboard.server.common.msg.TbMsg; import java.util.UUID; -@Slf4j @RuleNode( type = ComponentType.ACTION, name = "push to cloud", @@ -48,7 +46,8 @@ import java.util.UUID; "In case successful storage cloud event to database message will be routed via Success route.", configDirective = "tbActionNodePushToCloudConfig", icon = "cloud_upload", - ruleChainTypes = RuleChainType.EDGE + ruleChainTypes = RuleChainType.EDGE, + docUrl = "https://thingsboard.io/docs/user-guide/rule-engine-2-0/nodes/action/push-to-cloud/" ) public class TbMsgPushToCloudNode extends AbstractTbMsgPushNode { @@ -80,7 +79,6 @@ public class TbMsgPushToCloudNode extends AbstractTbMsgPushNodeSuccess route.", configDirective = "tbActionNodePushToEdgeConfig", icon = "cloud_download", - ruleChainTypes = RuleChainType.CORE + ruleChainTypes = RuleChainType.CORE, + docUrl = "https://thingsboard.io/docs/user-guide/rule-engine-2-0/nodes/action/push-to-edge/" ) public class TbMsgPushToEdgeNode extends AbstractTbMsgPushNode { @@ -113,7 +115,7 @@ public class TbMsgPushToEdgeNode extends AbstractTbMsgPushNodeAsset profile name or Failure", - configDirective = "tbNodeEmptyConfig") + configDirective = "tbNodeEmptyConfig", + docUrl = "https://thingsboard.io/docs/user-guide/rule-engine-2-0/nodes/filter/asset-profile-switch/" +) public class TbAssetTypeSwitchNode extends TbAbstractTypeSwitchNode { @Override diff --git a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/filter/TbCheckAlarmStatusNode.java b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/filter/TbCheckAlarmStatusNode.java index 9fb60ca671..8f0f35ea46 100644 --- a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/filter/TbCheckAlarmStatusNode.java +++ b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/filter/TbCheckAlarmStatusNode.java @@ -43,7 +43,9 @@ import java.util.Objects; nodeDescription = "Checks alarm status.", nodeDetails = "Checks the alarm status to match one of the specified statuses.

    " + "Output connections: True, False, Failure.", - configDirective = "tbFilterNodeCheckAlarmStatusConfig") + configDirective = "tbFilterNodeCheckAlarmStatusConfig", + docUrl = "https://thingsboard.io/docs/user-guide/rule-engine-2-0/nodes/filter/alarm-status-filter/" +) public class TbCheckAlarmStatusNode implements TbNode { private TbCheckAlarmStatusNodeConfig config; diff --git a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/filter/TbCheckMessageNode.java b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/filter/TbCheckMessageNode.java index d59f2bb0e4..8e23dae658 100644 --- a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/filter/TbCheckMessageNode.java +++ b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/filter/TbCheckMessageNode.java @@ -16,7 +16,6 @@ package org.thingsboard.rule.engine.filter; import com.google.gson.Gson; -import lombok.extern.slf4j.Slf4j; import org.thingsboard.rule.engine.api.RuleNode; import org.thingsboard.rule.engine.api.TbContext; import org.thingsboard.rule.engine.api.TbNode; @@ -30,7 +29,6 @@ import org.thingsboard.server.common.msg.TbMsg; import java.util.List; import java.util.Map; -@Slf4j @RuleNode( type = ComponentType.FILTER, name = "check fields presence", @@ -40,7 +38,9 @@ import java.util.Map; nodeDetails = "By default, the rule node checks that all specified fields are present. " + "Uncheck the 'Check that all selected fields are present' if the presence of at least one field is sufficient.

    " + "Output connections: True, False, Failure", - configDirective = "tbFilterNodeCheckMessageConfig") + configDirective = "tbFilterNodeCheckMessageConfig", + docUrl = "https://thingsboard.io/docs/user-guide/rule-engine-2-0/nodes/filter/check-fields-presence/" +) public class TbCheckMessageNode implements TbNode { private static final Gson gson = new Gson(); @@ -127,4 +127,4 @@ public class TbCheckMessageNode implements TbNode { return (Map) gson.fromJson(msg.getData(), Map.class); } -} \ No newline at end of file +} diff --git a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/filter/TbCheckRelationNode.java b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/filter/TbCheckRelationNode.java index a5f694373a..6186fe80df 100644 --- a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/filter/TbCheckRelationNode.java +++ b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/filter/TbCheckRelationNode.java @@ -19,7 +19,6 @@ 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; -import lombok.extern.slf4j.Slf4j; import org.thingsboard.rule.engine.api.RuleNode; import org.thingsboard.rule.engine.api.TbContext; import org.thingsboard.rule.engine.api.TbNode; @@ -41,10 +40,6 @@ import java.util.List; import static org.thingsboard.common.util.DonAsynchron.withCallback; -/** - * Created by ashvayka on 19.01.18. - */ -@Slf4j @RuleNode( type = ComponentType.FILTER, name = "check relation presence", @@ -56,7 +51,9 @@ import static org.thingsboard.common.util.DonAsynchron.withCallback; "Otherwise, the rule node checks the presence of a relation to any entity. " + "In both cases, relation lookup is based on configured direction and type.

    " + "Output connections: True, False, Failure", - configDirective = "tbFilterNodeCheckRelationConfig") + configDirective = "tbFilterNodeCheckRelationConfig", + docUrl = "https://thingsboard.io/docs/user-guide/rule-engine-2-0/nodes/filter/check-relation-presence/" +) public class TbCheckRelationNode implements TbNode { private TbCheckRelationNodeConfiguration config; diff --git a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/filter/TbDeviceTypeSwitchNode.java b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/filter/TbDeviceTypeSwitchNode.java index 8f6954956a..d12055c422 100644 --- a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/filter/TbDeviceTypeSwitchNode.java +++ b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/filter/TbDeviceTypeSwitchNode.java @@ -15,7 +15,6 @@ */ package org.thingsboard.rule.engine.filter; -import lombok.extern.slf4j.Slf4j; import org.thingsboard.rule.engine.api.EmptyNodeConfiguration; import org.thingsboard.rule.engine.api.RuleNode; import org.thingsboard.rule.engine.api.TbContext; @@ -26,7 +25,6 @@ import org.thingsboard.server.common.data.id.DeviceId; import org.thingsboard.server.common.data.id.EntityId; import org.thingsboard.server.common.data.plugin.ComponentType; -@Slf4j @RuleNode( type = ComponentType.FILTER, name = "device profile switch", @@ -36,7 +34,9 @@ import org.thingsboard.server.common.data.plugin.ComponentType; nodeDescription = "Route incoming messages based on the name of the device profile", nodeDetails = "Route incoming messages based on the name of the device profile. The device profile name is case-sensitive

    " + "Output connections: Device profile name or Failure", - configDirective = "tbNodeEmptyConfig") + configDirective = "tbNodeEmptyConfig", + docUrl = "https://thingsboard.io/docs/user-guide/rule-engine-2-0/nodes/filter/device-profile-switch/" +) public class TbDeviceTypeSwitchNode extends TbAbstractTypeSwitchNode { @Override diff --git a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/filter/TbJsFilterNode.java b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/filter/TbJsFilterNode.java index e36494a2b6..5165eba35f 100644 --- a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/filter/TbJsFilterNode.java +++ b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/filter/TbJsFilterNode.java @@ -15,7 +15,6 @@ */ package org.thingsboard.rule.engine.filter; -import lombok.extern.slf4j.Slf4j; import org.thingsboard.rule.engine.api.RuleNode; import org.thingsboard.rule.engine.api.ScriptEngine; import org.thingsboard.rule.engine.api.TbContext; @@ -30,7 +29,6 @@ import org.thingsboard.server.common.msg.TbMsg; import static org.thingsboard.common.util.DonAsynchron.withCallback; -@Slf4j @RuleNode( type = ComponentType.FILTER, name = "script", @@ -44,18 +42,17 @@ import static org.thingsboard.common.util.DonAsynchron.withCallback; "Message metadata can be accessed via metadata property. For example metadata.customerName === 'John';
    " + "Message type can be accessed via msgType property.

    " + "Output connections: True, False, Failure", - configDirective = "tbFilterNodeScriptConfig" + configDirective = "tbFilterNodeScriptConfig", + docUrl = "https://thingsboard.io/docs/user-guide/rule-engine-2-0/nodes/filter/script/" ) public class TbJsFilterNode implements TbNode { - private TbJsFilterNodeConfiguration config; private ScriptEngine scriptEngine; @Override public void init(TbContext ctx, TbNodeConfiguration configuration) throws TbNodeException { - this.config = TbNodeUtils.convert(configuration, TbJsFilterNodeConfiguration.class); - scriptEngine = ctx.createScriptEngine(config.getScriptLang(), - ScriptLanguage.TBEL.equals(config.getScriptLang()) ? config.getTbelScript() : config.getJsScript()); + var config = TbNodeUtils.convert(configuration, TbJsFilterNodeConfiguration.class); + scriptEngine = ctx.createScriptEngine(config.getScriptLang(), ScriptLanguage.TBEL.equals(config.getScriptLang()) ? config.getTbelScript() : config.getJsScript()); } @Override @@ -75,4 +72,5 @@ public class TbJsFilterNode implements TbNode { scriptEngine.destroy(); } } + } diff --git a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/filter/TbJsSwitchNode.java b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/filter/TbJsSwitchNode.java index e039eaeb14..87bb2113d9 100644 --- a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/filter/TbJsSwitchNode.java +++ b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/filter/TbJsSwitchNode.java @@ -18,7 +18,6 @@ package org.thingsboard.rule.engine.filter; import com.google.common.util.concurrent.FutureCallback; import com.google.common.util.concurrent.Futures; import com.google.common.util.concurrent.MoreExecutors; -import lombok.extern.slf4j.Slf4j; import org.checkerframework.checker.nullness.qual.Nullable; import org.thingsboard.rule.engine.api.RuleNode; import org.thingsboard.rule.engine.api.ScriptEngine; @@ -33,7 +32,6 @@ import org.thingsboard.server.common.msg.TbMsg; import java.util.Set; -@Slf4j @RuleNode( type = ComponentType.FILTER, name = "switch", customRelations = true, @@ -46,17 +44,17 @@ import java.util.Set; "Message metadata can be accessed via metadata property. For example metadata.customerName === 'John';
    " + "Message type can be accessed via msgType property.

    " + "Output connections: Custom connection(s) defined by switch node or Failure", - configDirective = "tbFilterNodeSwitchConfig") + configDirective = "tbFilterNodeSwitchConfig", + docUrl = "https://thingsboard.io/docs/user-guide/rule-engine-2-0/nodes/filter/switch/" +) public class TbJsSwitchNode implements TbNode { - private TbJsSwitchNodeConfiguration config; private ScriptEngine scriptEngine; @Override public void init(TbContext ctx, TbNodeConfiguration configuration) throws TbNodeException { - this.config = TbNodeUtils.convert(configuration, TbJsSwitchNodeConfiguration.class); - this.scriptEngine = ctx.createScriptEngine(config.getScriptLang(), - ScriptLanguage.TBEL.equals(config.getScriptLang()) ? config.getTbelScript() : config.getJsScript()); + var config = TbNodeUtils.convert(configuration, TbJsSwitchNodeConfiguration.class); + scriptEngine = ctx.createScriptEngine(config.getScriptLang(), ScriptLanguage.TBEL.equals(config.getScriptLang()) ? config.getTbelScript() : config.getJsScript()); } @Override @@ -84,4 +82,5 @@ public class TbJsSwitchNode implements TbNode { scriptEngine.destroy(); } } + } diff --git a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/filter/TbMsgTypeFilterNode.java b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/filter/TbMsgTypeFilterNode.java index 0e26a89afb..09479de488 100644 --- a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/filter/TbMsgTypeFilterNode.java +++ b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/filter/TbMsgTypeFilterNode.java @@ -15,7 +15,6 @@ */ package org.thingsboard.rule.engine.filter; -import lombok.extern.slf4j.Slf4j; import org.thingsboard.rule.engine.api.RuleNode; import org.thingsboard.rule.engine.api.TbContext; import org.thingsboard.rule.engine.api.TbNode; @@ -26,10 +25,6 @@ import org.thingsboard.server.common.data.msg.TbNodeConnectionType; import org.thingsboard.server.common.data.plugin.ComponentType; import org.thingsboard.server.common.msg.TbMsg; -/** - * Created by ashvayka on 19.01.18. - */ -@Slf4j @RuleNode( type = ComponentType.FILTER, name = "message type filter", @@ -38,14 +33,16 @@ import org.thingsboard.server.common.msg.TbMsg; nodeDescription = "Filter incoming messages by Message Type", nodeDetails = "If incoming message type is expected - send Message via True chain, otherwise False chain is used.

    " + "Output connections: True, False, Failure", - configDirective = "tbFilterNodeMessageTypeConfig") + configDirective = "tbFilterNodeMessageTypeConfig", + docUrl = "https://thingsboard.io/docs/user-guide/rule-engine-2-0/nodes/filter/message-type-filter/" +) public class TbMsgTypeFilterNode implements TbNode { - TbMsgTypeFilterNodeConfiguration config; + private TbMsgTypeFilterNodeConfiguration config; @Override public void init(TbContext ctx, TbNodeConfiguration configuration) throws TbNodeException { - this.config = TbNodeUtils.convert(configuration, TbMsgTypeFilterNodeConfiguration.class); + config = TbNodeUtils.convert(configuration, TbMsgTypeFilterNodeConfiguration.class); } @Override diff --git a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/filter/TbMsgTypeSwitchNode.java b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/filter/TbMsgTypeSwitchNode.java index f8d8278cbd..b82eba4e50 100644 --- a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/filter/TbMsgTypeSwitchNode.java +++ b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/filter/TbMsgTypeSwitchNode.java @@ -15,18 +15,14 @@ */ package org.thingsboard.rule.engine.filter; -import lombok.extern.slf4j.Slf4j; import org.thingsboard.rule.engine.api.EmptyNodeConfiguration; import org.thingsboard.rule.engine.api.RuleNode; import org.thingsboard.rule.engine.api.TbContext; import org.thingsboard.rule.engine.api.TbNode; import org.thingsboard.rule.engine.api.TbNodeConfiguration; -import org.thingsboard.rule.engine.api.TbNodeException; -import org.thingsboard.rule.engine.api.util.TbNodeUtils; import org.thingsboard.server.common.data.plugin.ComponentType; import org.thingsboard.server.common.msg.TbMsg; -@Slf4j @RuleNode( type = ComponentType.FILTER, name = "message type switch", @@ -36,15 +32,13 @@ import org.thingsboard.server.common.msg.TbMsg; nodeDetails = "Sends messages with message types \"Post attributes\", \"Post telemetry\", \"RPC Request\"" + " etc. via corresponding chain, otherwise Other chain is used.

    " + "Output connections: Message type connection, Other - if message type is custom or Failure", - configDirective = "tbNodeEmptyConfig") + configDirective = "tbNodeEmptyConfig", + docUrl = "https://thingsboard.io/docs/user-guide/rule-engine-2-0/nodes/filter/message-type-switch/" +) public class TbMsgTypeSwitchNode implements TbNode { - EmptyNodeConfiguration config; - @Override - public void init(TbContext ctx, TbNodeConfiguration configuration) throws TbNodeException { - this.config = TbNodeUtils.convert(configuration, EmptyNodeConfiguration.class); - } + public void init(TbContext ctx, TbNodeConfiguration configuration) {} @Override public void onMsg(TbContext ctx, TbMsg msg) { diff --git a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/filter/TbOriginatorTypeFilterNode.java b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/filter/TbOriginatorTypeFilterNode.java index ffaea592f0..57cce66e21 100644 --- a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/filter/TbOriginatorTypeFilterNode.java +++ b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/filter/TbOriginatorTypeFilterNode.java @@ -15,7 +15,6 @@ */ package org.thingsboard.rule.engine.filter; -import lombok.extern.slf4j.Slf4j; import org.thingsboard.rule.engine.api.RuleNode; import org.thingsboard.rule.engine.api.TbContext; import org.thingsboard.rule.engine.api.TbNode; @@ -27,7 +26,6 @@ import org.thingsboard.server.common.data.msg.TbNodeConnectionType; import org.thingsboard.server.common.data.plugin.ComponentType; import org.thingsboard.server.common.msg.TbMsg; -@Slf4j @RuleNode( type = ComponentType.FILTER, name = "entity type filter", @@ -36,14 +34,16 @@ import org.thingsboard.server.common.msg.TbMsg; nodeDescription = "Filter incoming messages by the type of message originator entity", nodeDetails = "Checks that the entity type of the incoming message originator matches one of the values specified in the filter.

    " + "Output connections: True, False, Failure", - configDirective = "tbFilterNodeOriginatorTypeConfig") + configDirective = "tbFilterNodeOriginatorTypeConfig", + docUrl = "https://thingsboard.io/docs/user-guide/rule-engine-2-0/nodes/filter/entity-type-filter/" +) public class TbOriginatorTypeFilterNode implements TbNode { - TbOriginatorTypeFilterNodeConfiguration config; + private TbOriginatorTypeFilterNodeConfiguration config; @Override public void init(TbContext ctx, TbNodeConfiguration configuration) throws TbNodeException { - this.config = TbNodeUtils.convert(configuration, TbOriginatorTypeFilterNodeConfiguration.class); + config = TbNodeUtils.convert(configuration, TbOriginatorTypeFilterNodeConfiguration.class); } @Override diff --git a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/filter/TbOriginatorTypeSwitchNode.java b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/filter/TbOriginatorTypeSwitchNode.java index 5c2a887b1d..5cee36cf14 100644 --- a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/filter/TbOriginatorTypeSwitchNode.java +++ b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/filter/TbOriginatorTypeSwitchNode.java @@ -15,14 +15,12 @@ */ package org.thingsboard.rule.engine.filter; -import lombok.extern.slf4j.Slf4j; import org.thingsboard.rule.engine.api.EmptyNodeConfiguration; import org.thingsboard.rule.engine.api.RuleNode; import org.thingsboard.rule.engine.api.TbContext; import org.thingsboard.server.common.data.id.EntityId; import org.thingsboard.server.common.data.plugin.ComponentType; -@Slf4j @RuleNode( type = ComponentType.FILTER, name = "entity type switch", @@ -31,7 +29,9 @@ import org.thingsboard.server.common.data.plugin.ComponentType; nodeDescription = "Route incoming messages by Message Originator Type", nodeDetails = "Routes messages to chain according to the entity type ('Device', 'Asset', etc.).

    " + "Output connections: Message originator type or Failure", - configDirective = "tbNodeEmptyConfig") + configDirective = "tbNodeEmptyConfig", + docUrl = "https://thingsboard.io/docs/user-guide/rule-engine-2-0/nodes/filter/entity-type-switch/" +) public class TbOriginatorTypeSwitchNode extends TbAbstractTypeSwitchNode { @Override diff --git a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/flow/TbAckNode.java b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/flow/TbAckNode.java index 8692cd6c55..b3d1ca71cf 100644 --- a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/flow/TbAckNode.java +++ b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/flow/TbAckNode.java @@ -15,34 +15,27 @@ */ package org.thingsboard.rule.engine.flow; -import lombok.extern.slf4j.Slf4j; import org.thingsboard.rule.engine.api.EmptyNodeConfiguration; import org.thingsboard.rule.engine.api.RuleNode; import org.thingsboard.rule.engine.api.TbContext; import org.thingsboard.rule.engine.api.TbNode; import org.thingsboard.rule.engine.api.TbNodeConfiguration; -import org.thingsboard.rule.engine.api.TbNodeException; -import org.thingsboard.rule.engine.api.util.TbNodeUtils; import org.thingsboard.server.common.data.plugin.ComponentType; import org.thingsboard.server.common.msg.TbMsg; -@Slf4j @RuleNode( type = ComponentType.FLOW, name = "acknowledge", configClazz = EmptyNodeConfiguration.class, nodeDescription = "Acknowledges the incoming message", nodeDetails = "After acknowledgement, the message is pushed to related rule nodes. Useful if you don't care what happens to this message next.", - configDirective = "tbNodeEmptyConfig" + configDirective = "tbNodeEmptyConfig", + docUrl = "https://thingsboard.io/docs/user-guide/rule-engine-2-0/nodes/flow/acknowledge/" ) public class TbAckNode implements TbNode { - EmptyNodeConfiguration config; - @Override - public void init(TbContext ctx, TbNodeConfiguration configuration) throws TbNodeException { - this.config = TbNodeUtils.convert(configuration, EmptyNodeConfiguration.class); - } + public void init(TbContext ctx, TbNodeConfiguration configuration) {} @Override public void onMsg(TbContext ctx, TbMsg msg) { diff --git a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/flow/TbCheckpointNode.java b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/flow/TbCheckpointNode.java index d7da783172..dee340af13 100644 --- a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/flow/TbCheckpointNode.java +++ b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/flow/TbCheckpointNode.java @@ -17,7 +17,6 @@ package org.thingsboard.rule.engine.flow; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.node.ObjectNode; -import lombok.extern.slf4j.Slf4j; import org.thingsboard.rule.engine.api.EmptyNodeConfiguration; import org.thingsboard.rule.engine.api.RuleNode; import org.thingsboard.rule.engine.api.TbContext; @@ -31,7 +30,6 @@ import org.thingsboard.server.common.msg.TbMsg; import static org.thingsboard.server.common.data.DataConstants.QUEUE_NAME; -@Slf4j @RuleNode( type = ComponentType.FLOW, name = "checkpoint", @@ -40,7 +38,8 @@ import static org.thingsboard.server.common.data.DataConstants.QUEUE_NAME; hasQueueName = true, nodeDescription = "transfers the message to another queue", nodeDetails = "After successful transfer incoming message is automatically acknowledged. Queue name is configurable.", - configDirective = "tbNodeEmptyConfig" + configDirective = "tbNodeEmptyConfig", + docUrl = "https://thingsboard.io/docs/user-guide/rule-engine-2-0/nodes/flow/checkpoint/" ) public class TbCheckpointNode implements TbNode { @@ -48,7 +47,7 @@ public class TbCheckpointNode implements TbNode { @Override public void init(TbContext ctx, TbNodeConfiguration configuration) throws TbNodeException { - this.queueName = ctx.getQueueName(); + queueName = ctx.getQueueName(); } @Override diff --git a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/flow/TbRuleChainInputNode.java b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/flow/TbRuleChainInputNode.java index 3c72699884..a8efa6ddd7 100644 --- a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/flow/TbRuleChainInputNode.java +++ b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/flow/TbRuleChainInputNode.java @@ -17,7 +17,6 @@ package org.thingsboard.rule.engine.flow; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.node.ObjectNode; -import lombok.extern.slf4j.Slf4j; import org.thingsboard.rule.engine.api.RuleNode; import org.thingsboard.rule.engine.api.TbContext; import org.thingsboard.rule.engine.api.TbNode; @@ -34,7 +33,6 @@ import org.thingsboard.server.common.msg.TbMsg; import java.util.Optional; import java.util.UUID; -@Slf4j @RuleNode( type = ComponentType.FLOW, name = "rule chain", @@ -49,7 +47,8 @@ import java.util.UUID; configDirective = "tbFlowNodeRuleChainInputConfig", relationTypes = {}, ruleChainNode = true, - customRelations = true + customRelations = true, + docUrl = "https://thingsboard.io/docs/user-guide/rule-engine-2-0/nodes/flow/rule-chain/" ) public class TbRuleChainInputNode implements TbNode { @@ -106,4 +105,5 @@ public class TbRuleChainInputNode implements TbNode { default -> null; }); } + } diff --git a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/flow/TbRuleChainOutputNode.java b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/flow/TbRuleChainOutputNode.java index 968ba741a3..88eba3573b 100644 --- a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/flow/TbRuleChainOutputNode.java +++ b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/flow/TbRuleChainOutputNode.java @@ -15,17 +15,14 @@ */ package org.thingsboard.rule.engine.flow; -import lombok.extern.slf4j.Slf4j; import org.thingsboard.rule.engine.api.EmptyNodeConfiguration; import org.thingsboard.rule.engine.api.RuleNode; import org.thingsboard.rule.engine.api.TbContext; import org.thingsboard.rule.engine.api.TbNode; import org.thingsboard.rule.engine.api.TbNodeConfiguration; -import org.thingsboard.rule.engine.api.TbNodeException; import org.thingsboard.server.common.data.plugin.ComponentType; import org.thingsboard.server.common.msg.TbMsg; -@Slf4j @RuleNode( type = ComponentType.FLOW, name = "output", @@ -35,13 +32,13 @@ import org.thingsboard.server.common.msg.TbMsg; "The output is forwarded to the caller rule chain, as an output of the corresponding \"input\" rule node. " + "The output rule node name corresponds to the relation type of the output message, and it is used to forward messages to other rule nodes in the caller rule chain. ", configDirective = "tbFlowNodeRuleChainOutputConfig", - outEnabled = false + outEnabled = false, + docUrl = "https://thingsboard.io/docs/user-guide/rule-engine-2-0/nodes/flow/output/" ) public class TbRuleChainOutputNode implements TbNode { @Override - public void init(TbContext ctx, TbNodeConfiguration configuration) throws TbNodeException { - } + public void init(TbContext ctx, TbNodeConfiguration configuration) {} @Override public void onMsg(TbContext ctx, TbMsg msg) { diff --git a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/gcp/pubsub/TbPubSubNode.java b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/gcp/pubsub/TbPubSubNode.java index ea893cc71b..2c170f7a2d 100644 --- a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/gcp/pubsub/TbPubSubNode.java +++ b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/gcp/pubsub/TbPubSubNode.java @@ -53,7 +53,8 @@ import java.util.concurrent.TimeUnit; "(messageId in the Message Metadata from the GCP PubSub. " + "messageId field can be accessed with metadata.messageId.", configDirective = "tbExternalNodePubSubConfig", - iconUrl = "data:image/svg+xml;base64,PHN2ZyBpZD0iTGF5ZXJfMSIgZGF0YS1uYW1lPSJMYXllciAxIiB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIxMjgiIGhlaWdodD0iMTI4IiB2aWV3Qm94PSIwIDAgMTI4IDEyOCI+Cjx0aXRsZT5DbG91ZCBQdWJTdWI8L3RpdGxlPgo8Zz4KPHBhdGggZD0iTTEyNi40Nyw1OC4xMmwtMjYuMy00NS43NEExMS41NiwxMS41NiwwLDAsMCw5MC4zMSw2LjVIMzcuN2ExMS41NSwxMS41NSwwLDAsMC05Ljg2LDUuODhMMS41Myw1OGExMS40OCwxMS40OCwwLDAsMCwwLDExLjQ0bDI2LjMsNDZhMTEuNzcsMTEuNzcsMCwwLDAsOS44Niw2LjA5SDkwLjNhMTEuNzMsMTEuNzMsMCwwLDAsOS44Ny02LjA2bDI2LjMtNDUuNzRBMTEuNzMsMTEuNzMsMCwwLDAsMTI2LjQ3LDU4LjEyWiIgc3R5bGU9ImZpbGw6ICM3MzViMmYiLz4KPHBhdGggZD0iTTg5LjIyLDQ3Ljc0LDgzLjM2LDQ5bC0xNC42LTE0LjZMNjQuMDksNDMuMSw2MS41NSw1My4ybDQuMjksNC4yOUw1Ny42LDU5LjE4LDQ2LjMsNDcuODhsLTcuNjcsNy4zOEw1Mi43Niw2OS4zN2wtMTUsMTEuOUw3OCwxMjEuNUg5MC4zYTExLjczLDExLjczLDAsMCwwLDkuODctNi4wNmwyMC43Mi0zNloiIHN0eWxlPSJvcGFjaXR5OiAwLjA3MDAwMDAwMDI5ODAyMztpc29sYXRpb246IGlzb2xhdGUiLz4KPHBhdGggZD0iTTgyLjg2LDQ3YTUuMzIsNS4zMiwwLDEsMS0xLjk1LDcuMjdBNS4zMiw1LjMyLDAsMCwxLDgyLjg2LDQ3IiBzdHlsZT0iZmlsbDogI2ZmZiIvPgo8cGF0aCBkPSJNMzkuODIsNTYuMThhNS4zMiw1LjMyLDAsMSwxLDcuMjctMS45NSw1LjMyLDUuMzIsMCwwLDEtNy4yNywxLjk1IiBzdHlsZT0iZmlsbDogI2ZmZiIvPgo8cGF0aCBkPSJNNjkuMzIsODguODVBNS4zMiw1LjMyLDAsMSwxLDY0LDgzLjUyYTUuMzIsNS4zMiwwLDAsMSw1LjMyLDUuMzIiIHN0eWxlPSJmaWxsOiAjZmZmIi8+CjxnPgo8cGF0aCBkPSJNNjQsNTIuOTRhMTEuMDYsMTEuMDYsMCwwLDEsMi40Ni4yOFYzOS4xNUg2MS41NFY1My4yMkExMS4wNiwxMS4wNiwwLDAsMSw2NCw1Mi45NFoiIHN0eWxlPSJmaWxsOiAjZmZmIi8+CjxwYXRoIGQ9Ik03NC41Nyw2Ny4yNmExMSwxMSwwLDAsMS0yLjQ3LDQuMjVsMTIuMTksNywyLjQ2LTQuMjZaIiBzdHlsZT0iZmlsbDogI2ZmZiIvPgo8cGF0aCBkPSJNNTMuNDMsNjcuMjZsLTEyLjE4LDcsMi40Niw0LjI2LDEyLjE5LTdBMTEsMTEsMCwwLDEsNTMuNDMsNjcuMjZaIiBzdHlsZT0iZmlsbDogI2ZmZiIvPgo8L2c+CjxwYXRoIGQ9Ik03Mi42LDY0QTguNiw4LjYsMCwxLDEsNjQsNTUuNCw4LjYsOC42LDAsMCwxLDcyLjYsNjQiIHN0eWxlPSJmaWxsOiAjZmZmIi8+CjxwYXRoIGQ9Ik0zOS4xLDcwLjU3YTYuNzYsNi43NiwwLDEsMS0yLjQ3LDkuMjMsNi43Niw2Ljc2LDAsMCwxLDIuNDctOS4yMyIgc3R5bGU9ImZpbGw6ICNmZmYiLz4KPHBhdGggZD0iTTgyLjE0LDgyLjI3YTYuNzYsNi43NiwwLDEsMSw5LjIzLTIuNDcsNi43NSw2Ljc1LDAsMCwxLTkuMjMsMi40NyIgc3R5bGU9ImZpbGw6ICNmZmYiLz4KPHBhdGggZD0iTTcwLjc2LDM5LjE1QTYuNzYsNi43NiwwLDEsMSw2NCwzMi4zOWE2Ljc2LDYuNzYsMCwwLDEsNi43Niw2Ljc2IiBzdHlsZT0iZmlsbDogI2ZmZiIvPgo8L2c+Cjwvc3ZnPgo=" + iconUrl = "data:image/svg+xml;base64,PHN2ZyBpZD0iTGF5ZXJfMSIgZGF0YS1uYW1lPSJMYXllciAxIiB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIxMjgiIGhlaWdodD0iMTI4IiB2aWV3Qm94PSIwIDAgMTI4IDEyOCI+Cjx0aXRsZT5DbG91ZCBQdWJTdWI8L3RpdGxlPgo8Zz4KPHBhdGggZD0iTTEyNi40Nyw1OC4xMmwtMjYuMy00NS43NEExMS41NiwxMS41NiwwLDAsMCw5MC4zMSw2LjVIMzcuN2ExMS41NSwxMS41NSwwLDAsMC05Ljg2LDUuODhMMS41Myw1OGExMS40OCwxMS40OCwwLDAsMCwwLDExLjQ0bDI2LjMsNDZhMTEuNzcsMTEuNzcsMCwwLDAsOS44Niw2LjA5SDkwLjNhMTEuNzMsMTEuNzMsMCwwLDAsOS44Ny02LjA2bDI2LjMtNDUuNzRBMTEuNzMsMTEuNzMsMCwwLDAsMTI2LjQ3LDU4LjEyWiIgc3R5bGU9ImZpbGw6ICM3MzViMmYiLz4KPHBhdGggZD0iTTg5LjIyLDQ3Ljc0LDgzLjM2LDQ5bC0xNC42LTE0LjZMNjQuMDksNDMuMSw2MS41NSw1My4ybDQuMjksNC4yOUw1Ny42LDU5LjE4LDQ2LjMsNDcuODhsLTcuNjcsNy4zOEw1Mi43Niw2OS4zN2wtMTUsMTEuOUw3OCwxMjEuNUg5MC4zYTExLjczLDExLjczLDAsMCwwLDkuODctNi4wNmwyMC43Mi0zNloiIHN0eWxlPSJvcGFjaXR5OiAwLjA3MDAwMDAwMDI5ODAyMztpc29sYXRpb246IGlzb2xhdGUiLz4KPHBhdGggZD0iTTgyLjg2LDQ3YTUuMzIsNS4zMiwwLDEsMS0xLjk1LDcuMjdBNS4zMiw1LjMyLDAsMCwxLDgyLjg2LDQ3IiBzdHlsZT0iZmlsbDogI2ZmZiIvPgo8cGF0aCBkPSJNMzkuODIsNTYuMThhNS4zMiw1LjMyLDAsMSwxLDcuMjctMS45NSw1LjMyLDUuMzIsMCwwLDEtNy4yNywxLjk1IiBzdHlsZT0iZmlsbDogI2ZmZiIvPgo8cGF0aCBkPSJNNjkuMzIsODguODVBNS4zMiw1LjMyLDAsMSwxLDY0LDgzLjUyYTUuMzIsNS4zMiwwLDAsMSw1LjMyLDUuMzIiIHN0eWxlPSJmaWxsOiAjZmZmIi8+CjxnPgo8cGF0aCBkPSJNNjQsNTIuOTRhMTEuMDYsMTEuMDYsMCwwLDEsMi40Ni4yOFYzOS4xNUg2MS41NFY1My4yMkExMS4wNiwxMS4wNiwwLDAsMSw2NCw1Mi45NFoiIHN0eWxlPSJmaWxsOiAjZmZmIi8+CjxwYXRoIGQ9Ik03NC41Nyw2Ny4yNmExMSwxMSwwLDAsMS0yLjQ3LDQuMjVsMTIuMTksNywyLjQ2LTQuMjZaIiBzdHlsZT0iZmlsbDogI2ZmZiIvPgo8cGF0aCBkPSJNNTMuNDMsNjcuMjZsLTEyLjE4LDcsMi40Niw0LjI2LDEyLjE5LTdBMTEsMTEsMCwwLDEsNTMuNDMsNjcuMjZaIiBzdHlsZT0iZmlsbDogI2ZmZiIvPgo8L2c+CjxwYXRoIGQ9Ik03Mi42LDY0QTguNiw4LjYsMCwxLDEsNjQsNTUuNCw4LjYsOC42LDAsMCwxLDcyLjYsNjQiIHN0eWxlPSJmaWxsOiAjZmZmIi8+CjxwYXRoIGQ9Ik0zOS4xLDcwLjU3YTYuNzYsNi43NiwwLDEsMS0yLjQ3LDkuMjMsNi43Niw2Ljc2LDAsMCwxLDIuNDctOS4yMyIgc3R5bGU9ImZpbGw6ICNmZmYiLz4KPHBhdGggZD0iTTgyLjE0LDgyLjI3YTYuNzYsNi43NiwwLDEsMSw5LjIzLTIuNDcsNi43NSw2Ljc1LDAsMCwxLTkuMjMsMi40NyIgc3R5bGU9ImZpbGw6ICNmZmYiLz4KPHBhdGggZD0iTTcwLjc2LDM5LjE1QTYuNzYsNi43NiwwLDEsMSw2NCwzMi4zOWE2Ljc2LDYuNzYsMCwwLDEsNi43Niw2Ljc2IiBzdHlsZT0iZmlsbDogI2ZmZiIvPgo8L2c+Cjwvc3ZnPgo=", + docUrl = "https://thingsboard.io/docs/user-guide/rule-engine-2-0/nodes/external/gcp-pubsub/" ) public class TbPubSubNode extends TbAbstractExternalNode { @@ -155,4 +156,5 @@ public class TbPubSubNode extends TbAbstractExternalNode { .setExecutorProvider(FixedExecutorProvider.create(ctx.getPubSubRuleNodeExecutorProvider().getExecutor())) .build(); } + } diff --git a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/geo/TbGpsGeofencingActionNode.java b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/geo/TbGpsGeofencingActionNode.java index b362b60b8e..488ee2123e 100644 --- a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/geo/TbGpsGeofencingActionNode.java +++ b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/geo/TbGpsGeofencingActionNode.java @@ -20,7 +20,6 @@ import com.fasterxml.jackson.databind.node.ObjectNode; import com.google.gson.Gson; import com.google.gson.JsonObject; import com.google.gson.JsonParser; -import lombok.extern.slf4j.Slf4j; import org.thingsboard.rule.engine.api.RuleNode; import org.thingsboard.rule.engine.api.TbContext; import org.thingsboard.rule.engine.api.TbNodeException; @@ -34,10 +33,10 @@ import org.thingsboard.server.common.data.util.TbPair; import org.thingsboard.server.common.msg.TbMsg; import java.util.Collections; -import java.util.HashMap; import java.util.List; -import java.util.Map; import java.util.Optional; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ConcurrentMap; import java.util.concurrent.ExecutionException; import java.util.concurrent.TimeUnit; import java.util.concurrent.TimeoutException; @@ -47,10 +46,6 @@ import static org.thingsboard.rule.engine.util.GpsGeofencingEvents.INSIDE; import static org.thingsboard.rule.engine.util.GpsGeofencingEvents.LEFT; import static org.thingsboard.rule.engine.util.GpsGeofencingEvents.OUTSIDE; -/** - * Created by ashvayka on 19.01.18. - */ -@Slf4j @RuleNode( type = ComponentType.ACTION, name = "gps geofencing events", @@ -66,12 +61,13 @@ import static org.thingsboard.rule.engine.util.GpsGeofencingEvents.OUTSIDE; "If the presence monitoring strategy \"On each message\" is selected, sends messages via rule node connection type Inside or Outside every time the geofencing condition is satisfied. " + "

    " + "Output connections: Entered, Left, Inside, Outside, Success", - configDirective = "tbActionNodeGpsGeofencingConfig" + configDirective = "tbActionNodeGpsGeofencingConfig", + docUrl = "https://thingsboard.io/docs/user-guide/rule-engine-2-0/nodes/action/gps-geofencing-events/" ) public class TbGpsGeofencingActionNode extends AbstractGeofencingNode { private static final String REPORT_PRESENCE_STATUS_ON_EACH_MESSAGE = "reportPresenceStatusOnEachMessage"; - private final Map entityStates = new HashMap<>(); + private final ConcurrentMap entityStates = new ConcurrentHashMap<>(); private final Gson gson = new Gson(); @Override diff --git a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/geo/TbGpsGeofencingFilterNode.java b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/geo/TbGpsGeofencingFilterNode.java index 19c901483c..7c1a561492 100644 --- a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/geo/TbGpsGeofencingFilterNode.java +++ b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/geo/TbGpsGeofencingFilterNode.java @@ -15,7 +15,6 @@ */ package org.thingsboard.rule.engine.geo; -import lombok.extern.slf4j.Slf4j; import org.thingsboard.rule.engine.api.RuleNode; import org.thingsboard.rule.engine.api.TbContext; import org.thingsboard.rule.engine.api.TbNodeException; @@ -23,10 +22,6 @@ import org.thingsboard.server.common.data.msg.TbNodeConnectionType; import org.thingsboard.server.common.data.plugin.ComponentType; import org.thingsboard.server.common.msg.TbMsg; -/** - * Created by ashvayka on 19.01.18. - */ -@Slf4j @RuleNode( type = ComponentType.FILTER, name = "gps geofencing filter", @@ -60,7 +55,9 @@ import org.thingsboard.server.common.msg.TbMsg; "

    " + "Available radius units: METER, KILOMETER, FOOT, MILE, NAUTICAL_MILE;

    " + "Output connections: True, False, Failure", - configDirective = "tbFilterNodeGpsGeofencingConfig") + configDirective = "tbFilterNodeGpsGeofencingConfig", + docUrl = "https://thingsboard.io/docs/user-guide/rule-engine-2-0/nodes/filter/gps-geofencing-filter/" +) public class TbGpsGeofencingFilterNode extends AbstractGeofencingNode { @Override @@ -72,4 +69,5 @@ public class TbGpsGeofencingFilterNode extends AbstractGeofencingNode getConfigClazz() { return TbGpsGeofencingFilterNodeConfiguration.class; } + } diff --git a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/kafka/TbKafkaNode.java b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/kafka/TbKafkaNode.java index d544d0647d..a6549dbd15 100644 --- a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/kafka/TbKafkaNode.java +++ b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/kafka/TbKafkaNode.java @@ -58,7 +58,8 @@ import java.util.Properties; "Outbound message will contain response fields (offset, partition, topic)" + " from the Kafka in the Message Metadata. For example partition field can be accessed with metadata.partition.", configDirective = "tbExternalNodeKafkaConfig", - iconUrl = "data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTUzOCIgaGVpZ2h0PSIyNTAwIiB2aWV3Qm94PSIwIDAgMjU2IDQxNiIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIiBwcmVzZXJ2ZUFzcGVjdFJhdGlvPSJ4TWlkWU1pZCI+PHBhdGggZD0iTTIwMS44MTYgMjMwLjIxNmMtMTYuMTg2IDAtMzAuNjk3IDcuMTcxLTQwLjYzNCAxOC40NjFsLTI1LjQ2My0xOC4wMjZjMi43MDMtNy40NDIgNC4yNTUtMTUuNDMzIDQuMjU1LTIzLjc5NyAwLTguMjE5LTEuNDk4LTE2LjA3Ni00LjExMi0yMy40MDhsMjUuNDA2LTE3LjgzNWM5LjkzNiAxMS4yMzMgMjQuNDA5IDE4LjM2NSA0MC41NDggMTguMzY1IDI5Ljg3NSAwIDU0LjE4NC0yNC4zMDUgNTQuMTg0LTU0LjE4NCAwLTI5Ljg3OS0yNC4zMDktNTQuMTg0LTU0LjE4NC01NC4xODQtMjkuODc1IDAtNTQuMTg0IDI0LjMwNS01NC4xODQgNTQuMTg0IDAgNS4zNDguODA4IDEwLjUwNSAyLjI1OCAxNS4zODlsLTI1LjQyMyAxNy44NDRjLTEwLjYyLTEzLjE3NS0yNS45MTEtMjIuMzc0LTQzLjMzMy0yNS4xODJ2LTMwLjY0YzI0LjU0NC01LjE1NSA0My4wMzctMjYuOTYyIDQzLjAzNy01My4wMTlDMTI0LjE3MSAyNC4zMDUgOTkuODYyIDAgNjkuOTg3IDAgNDAuMTEyIDAgMTUuODAzIDI0LjMwNSAxNS44MDMgNTQuMTg0YzAgMjUuNzA4IDE4LjAxNCA0Ny4yNDYgNDIuMDY3IDUyLjc2OXYzMS4wMzhDMjUuMDQ0IDE0My43NTMgMCAxNzIuNDAxIDAgMjA2Ljg1NGMwIDM0LjYyMSAyNS4yOTIgNjMuMzc0IDU4LjM1NSA2OC45NHYzMi43NzRjLTI0LjI5OSA1LjM0MS00Mi41NTIgMjcuMDExLTQyLjU1MiA1Mi44OTQgMCAyOS44NzkgMjQuMzA5IDU0LjE4NCA1NC4xODQgNTQuMTg0IDI5Ljg3NSAwIDU0LjE4NC0yNC4zMDUgNTQuMTg0LTU0LjE4NCAwLTI1Ljg4My0xOC4yNTMtNDcuNTUzLTQyLjU1Mi01Mi44OTR2LTMyLjc3NWE2OS45NjUgNjkuOTY1IDAgMCAwIDQyLjYtMjQuNzc2bDI1LjYzMyAxOC4xNDNjLTEuNDIzIDQuODQtMi4yMiA5Ljk0Ni0yLjIyIDE1LjI0IDAgMjkuODc5IDI0LjMwOSA1NC4xODQgNTQuMTg0IDU0LjE4NCAyOS44NzUgMCA1NC4xODQtMjQuMzA1IDU0LjE4NC01NC4xODQgMC0yOS44NzktMjQuMzA5LTU0LjE4NC01NC4xODQtNTQuMTg0em0wLTEyNi42OTVjMTQuNDg3IDAgMjYuMjcgMTEuNzg4IDI2LjI3IDI2LjI3MXMtMTEuNzgzIDI2LjI3LTI2LjI3IDI2LjI3LTI2LjI3LTExLjc4Ny0yNi4yNy0yNi4yN2MwLTE0LjQ4MyAxMS43ODMtMjYuMjcxIDI2LjI3LTI2LjI3MXptLTE1OC4xLTQ5LjMzN2MwLTE0LjQ4MyAxMS43ODQtMjYuMjcgMjYuMjcxLTI2LjI3czI2LjI3IDExLjc4NyAyNi4yNyAyNi4yN2MwIDE0LjQ4My0xMS43ODMgMjYuMjctMjYuMjcgMjYuMjdzLTI2LjI3MS0xMS43ODctMjYuMjcxLTI2LjI3em01Mi41NDEgMzA3LjI3OGMwIDE0LjQ4My0xMS43ODMgMjYuMjctMjYuMjcgMjYuMjdzLTI2LjI3MS0xMS43ODctMjYuMjcxLTI2LjI3YzAtMTQuNDgzIDExLjc4NC0yNi4yNyAyNi4yNzEtMjYuMjdzMjYuMjcgMTEuNzg3IDI2LjI3IDI2LjI3em0tMjYuMjcyLTExNy45N2MtMjAuMjA1IDAtMzYuNjQyLTE2LjQzNC0zNi42NDItMzYuNjM4IDAtMjAuMjA1IDE2LjQzNy0zNi42NDIgMzYuNjQyLTM2LjY0MiAyMC4yMDQgMCAzNi42NDEgMTYuNDM3IDM2LjY0MSAzNi42NDIgMCAyMC4yMDQtMTYuNDM3IDM2LjYzOC0zNi42NDEgMzYuNjM4em0xMzEuODMxIDY3LjE3OWMtMTQuNDg3IDAtMjYuMjctMTEuNzg4LTI2LjI3LTI2LjI3MXMxMS43ODMtMjYuMjcgMjYuMjctMjYuMjcgMjYuMjcgMTEuNzg3IDI2LjI3IDI2LjI3YzAgMTQuNDgzLTExLjc4MyAyNi4yNzEtMjYuMjcgMjYuMjcxeiIvPjwvc3ZnPg==" + iconUrl = "data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTUzOCIgaGVpZ2h0PSIyNTAwIiB2aWV3Qm94PSIwIDAgMjU2IDQxNiIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIiBwcmVzZXJ2ZUFzcGVjdFJhdGlvPSJ4TWlkWU1pZCI+PHBhdGggZD0iTTIwMS44MTYgMjMwLjIxNmMtMTYuMTg2IDAtMzAuNjk3IDcuMTcxLTQwLjYzNCAxOC40NjFsLTI1LjQ2My0xOC4wMjZjMi43MDMtNy40NDIgNC4yNTUtMTUuNDMzIDQuMjU1LTIzLjc5NyAwLTguMjE5LTEuNDk4LTE2LjA3Ni00LjExMi0yMy40MDhsMjUuNDA2LTE3LjgzNWM5LjkzNiAxMS4yMzMgMjQuNDA5IDE4LjM2NSA0MC41NDggMTguMzY1IDI5Ljg3NSAwIDU0LjE4NC0yNC4zMDUgNTQuMTg0LTU0LjE4NCAwLTI5Ljg3OS0yNC4zMDktNTQuMTg0LTU0LjE4NC01NC4xODQtMjkuODc1IDAtNTQuMTg0IDI0LjMwNS01NC4xODQgNTQuMTg0IDAgNS4zNDguODA4IDEwLjUwNSAyLjI1OCAxNS4zODlsLTI1LjQyMyAxNy44NDRjLTEwLjYyLTEzLjE3NS0yNS45MTEtMjIuMzc0LTQzLjMzMy0yNS4xODJ2LTMwLjY0YzI0LjU0NC01LjE1NSA0My4wMzctMjYuOTYyIDQzLjAzNy01My4wMTlDMTI0LjE3MSAyNC4zMDUgOTkuODYyIDAgNjkuOTg3IDAgNDAuMTEyIDAgMTUuODAzIDI0LjMwNSAxNS44MDMgNTQuMTg0YzAgMjUuNzA4IDE4LjAxNCA0Ny4yNDYgNDIuMDY3IDUyLjc2OXYzMS4wMzhDMjUuMDQ0IDE0My43NTMgMCAxNzIuNDAxIDAgMjA2Ljg1NGMwIDM0LjYyMSAyNS4yOTIgNjMuMzc0IDU4LjM1NSA2OC45NHYzMi43NzRjLTI0LjI5OSA1LjM0MS00Mi41NTIgMjcuMDExLTQyLjU1MiA1Mi44OTQgMCAyOS44NzkgMjQuMzA5IDU0LjE4NCA1NC4xODQgNTQuMTg0IDI5Ljg3NSAwIDU0LjE4NC0yNC4zMDUgNTQuMTg0LTU0LjE4NCAwLTI1Ljg4My0xOC4yNTMtNDcuNTUzLTQyLjU1Mi01Mi44OTR2LTMyLjc3NWE2OS45NjUgNjkuOTY1IDAgMCAwIDQyLjYtMjQuNzc2bDI1LjYzMyAxOC4xNDNjLTEuNDIzIDQuODQtMi4yMiA5Ljk0Ni0yLjIyIDE1LjI0IDAgMjkuODc5IDI0LjMwOSA1NC4xODQgNTQuMTg0IDU0LjE4NCAyOS44NzUgMCA1NC4xODQtMjQuMzA1IDU0LjE4NC01NC4xODQgMC0yOS44NzktMjQuMzA5LTU0LjE4NC01NC4xODQtNTQuMTg0em0wLTEyNi42OTVjMTQuNDg3IDAgMjYuMjcgMTEuNzg4IDI2LjI3IDI2LjI3MXMtMTEuNzgzIDI2LjI3LTI2LjI3IDI2LjI3LTI2LjI3LTExLjc4Ny0yNi4yNy0yNi4yN2MwLTE0LjQ4MyAxMS43ODMtMjYuMjcxIDI2LjI3LTI2LjI3MXptLTE1OC4xLTQ5LjMzN2MwLTE0LjQ4MyAxMS43ODQtMjYuMjcgMjYuMjcxLTI2LjI3czI2LjI3IDExLjc4NyAyNi4yNyAyNi4yN2MwIDE0LjQ4My0xMS43ODMgMjYuMjctMjYuMjcgMjYuMjdzLTI2LjI3MS0xMS43ODctMjYuMjcxLTI2LjI3em01Mi41NDEgMzA3LjI3OGMwIDE0LjQ4My0xMS43ODMgMjYuMjctMjYuMjcgMjYuMjdzLTI2LjI3MS0xMS43ODctMjYuMjcxLTI2LjI3YzAtMTQuNDgzIDExLjc4NC0yNi4yNyAyNi4yNzEtMjYuMjdzMjYuMjcgMTEuNzg3IDI2LjI3IDI2LjI3em0tMjYuMjcyLTExNy45N2MtMjAuMjA1IDAtMzYuNjQyLTE2LjQzNC0zNi42NDItMzYuNjM4IDAtMjAuMjA1IDE2LjQzNy0zNi42NDIgMzYuNjQyLTM2LjY0MiAyMC4yMDQgMCAzNi42NDEgMTYuNDM3IDM2LjY0MSAzNi42NDIgMCAyMC4yMDQtMTYuNDM3IDM2LjYzOC0zNi42NDEgMzYuNjM4em0xMzEuODMxIDY3LjE3OWMtMTQuNDg3IDAtMjYuMjctMTEuNzg4LTI2LjI3LTI2LjI3MXMxMS43ODMtMjYuMjcgMjYuMjctMjYuMjcgMjYuMjcgMTEuNzg3IDI2LjI3IDI2LjI3YzAgMTQuNDgzLTExLjc4MyAyNi4yNzEtMjYuMjcgMjYuMjcxeiIvPjwvc3ZnPg==", + docUrl = "https://thingsboard.io/docs/user-guide/rule-engine-2-0/nodes/external/kafka/" ) public class TbKafkaNode extends TbAbstractExternalNode { diff --git a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/mail/TbMsgToEmailNode.java b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/mail/TbMsgToEmailNode.java index f20aeca55f..4867735a2b 100644 --- a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/mail/TbMsgToEmailNode.java +++ b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/mail/TbMsgToEmailNode.java @@ -16,7 +16,6 @@ package org.thingsboard.rule.engine.mail; import com.fasterxml.jackson.core.type.TypeReference; -import lombok.extern.slf4j.Slf4j; import org.thingsboard.common.util.JacksonUtil; import org.thingsboard.rule.engine.api.RuleNode; import org.thingsboard.rule.engine.api.TbContext; @@ -34,7 +33,6 @@ import org.thingsboard.server.common.msg.TbMsg; import java.util.HashMap; import java.util.Map; -@Slf4j @RuleNode( type = ComponentType.TRANSFORMATION, name = "to email", @@ -43,7 +41,8 @@ import java.util.Map; nodeDetails = "Transforms message to email message. If transformation completed successfully output message type will be set to SEND_EMAIL.

    " + "Output connections: Success, Failure.", configDirective = "tbTransformationNodeToEmailConfig", - icon = "email" + icon = "email", + docUrl = "https://thingsboard.io/docs/user-guide/rule-engine-2-0/nodes/transformation/to-email/" ) public class TbMsgToEmailNode implements TbNode { @@ -55,20 +54,15 @@ public class TbMsgToEmailNode implements TbNode { @Override public void init(TbContext ctx, TbNodeConfiguration configuration) throws TbNodeException { - this.config = TbNodeUtils.convert(configuration, TbMsgToEmailNodeConfiguration.class); - this.dynamicMailBodyType = DYNAMIC.equals(this.config.getMailBodyType()); - } + config = TbNodeUtils.convert(configuration, TbMsgToEmailNodeConfiguration.class); + dynamicMailBodyType = DYNAMIC.equals(config.getMailBodyType()); + } @Override public void onMsg(TbContext ctx, TbMsg msg) { - try { - TbEmail email = convert(msg); - TbMsg emailMsg = buildEmailMsg(ctx, msg, email); - ctx.tellNext(emailMsg, TbNodeConnectionType.SUCCESS); - } catch (Exception ex) { - log.warn("Can not convert message to email " + ex.getMessage()); - ctx.tellFailure(msg, ex); - } + TbEmail email = convert(msg); + TbMsg emailMsg = buildEmailMsg(ctx, msg, email); + ctx.tellNext(emailMsg, TbNodeConnectionType.SUCCESS); } private TbMsg buildEmailMsg(TbContext ctx, TbMsg msg, TbEmail email) { diff --git a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/mail/TbSendEmailNode.java b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/mail/TbSendEmailNode.java index 630d87cb54..198808297e 100644 --- a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/mail/TbSendEmailNode.java +++ b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/mail/TbSendEmailNode.java @@ -45,7 +45,8 @@ import static org.thingsboard.common.util.DonAsynchron.withCallback; " where created using to Email transformation Node, please connect this Node " + "with to Email Node using Successful chain.", configDirective = "tbExternalNodeSendEmailConfig", - icon = "send" + icon = "send", + docUrl = "https://thingsboard.io/docs/user-guide/rule-engine-2-0/nodes/external/send-email/" ) public class TbSendEmailNode extends TbAbstractExternalNode { @@ -91,7 +92,7 @@ public class TbSendEmailNode extends TbAbstractExternalNode { } } - private TbEmail getEmail(TbMsg msg) throws IOException { + private TbEmail getEmail(TbMsg msg) { TbEmail email = JacksonUtil.fromString(msg.getData(), TbEmail.class); if (StringUtils.isBlank(email.getTo())) { throw new IllegalStateException("Email destination can not be blank [" + email.getTo() + "]"); @@ -141,4 +142,5 @@ public class TbSendEmailNode extends TbAbstractExternalNode { } return javaMailProperties; } + } diff --git a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/math/TbMathNode.java b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/math/TbMathNode.java index 76ba71163a..997bbfa2e2 100644 --- a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/math/TbMathNode.java +++ b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/math/TbMathNode.java @@ -20,7 +20,6 @@ import com.google.common.util.concurrent.Futures; import com.google.common.util.concurrent.ListenableFuture; import com.google.common.util.concurrent.MoreExecutors; import com.google.common.util.concurrent.SettableFuture; -import lombok.extern.slf4j.Slf4j; import net.objecthunter.exp4j.Expression; import net.objecthunter.exp4j.ExpressionBuilder; import org.springframework.util.ConcurrentReferenceHashMap; @@ -56,8 +55,6 @@ import java.util.stream.Collectors; import static org.thingsboard.common.util.ExpressionFunctionsUtil.userDefinedFunctions; import static org.thingsboard.rule.engine.math.TbMathArgumentType.CONSTANT; -@SuppressWarnings("UnstableApiUsage") -@Slf4j @RuleNode( type = ComponentType.ACTION, name = "math function", @@ -78,8 +75,8 @@ import static org.thingsboard.rule.engine.math.TbMathArgumentType.CONSTANT; "The execution is synchronized in scope of message originator (e.g. device) and server node. " + "If you have rule nodes in different rule chains, they will process messages from the same originator synchronously in the scope of the server node.", configDirective = "tbActionNodeMathFunctionConfig", - icon = "calculate" - + icon = "calculate", + docUrl = "https://thingsboard.io/docs/user-guide/rule-engine-2-0/nodes/action/math-function/" ) public class TbMathNode implements TbNode { diff --git a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/metadata/CalculateDeltaNode.java b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/metadata/CalculateDeltaNode.java index 29c7dc51e5..dd1888f3de 100644 --- a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/metadata/CalculateDeltaNode.java +++ b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/metadata/CalculateDeltaNode.java @@ -20,7 +20,6 @@ import com.fasterxml.jackson.databind.node.ObjectNode; import com.google.common.util.concurrent.Futures; import com.google.common.util.concurrent.ListenableFuture; import com.google.common.util.concurrent.MoreExecutors; -import lombok.extern.slf4j.Slf4j; import org.springframework.util.ConcurrentReferenceHashMap; import org.thingsboard.common.util.JacksonUtil; import org.thingsboard.rule.engine.api.RuleNode; @@ -43,8 +42,8 @@ import java.math.BigDecimal; import java.math.RoundingMode; import java.util.Map; -@Slf4j -@RuleNode(type = ComponentType.ENRICHMENT, +@RuleNode( + type = ComponentType.ENRICHMENT, name = "calculate delta", version = 1, relationTypes = {TbNodeConnectionType.SUCCESS, TbNodeConnectionType.FAILURE, TbNodeConnectionType.OTHER}, @@ -53,7 +52,9 @@ import java.util.Map; "and current value for this key from the incoming message", nodeDetails = "Useful for metering use cases, when you need to calculate consumption based on pulse counter reading.

    " + "Output connections: Success, Other or Failure.", - configDirective = "tbEnrichmentNodeCalculateDeltaConfig") + configDirective = "tbEnrichmentNodeCalculateDeltaConfig", + docUrl = "https://thingsboard.io/docs/user-guide/rule-engine-2-0/nodes/enrichment/calculate-delta/" +) public class CalculateDeltaNode implements TbNode { private Map cache; @@ -189,7 +190,6 @@ public class CalculateDeltaNode implements TbNode { return fetchLatestValueAsync(ctx, originator); } - private record ValueWithTs(long ts, double value) { - } + private record ValueWithTs(long ts, double value) {} } diff --git a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/metadata/TbFetchDeviceCredentialsNode.java b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/metadata/TbFetchDeviceCredentialsNode.java index 294354279a..421da9c768 100644 --- a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/metadata/TbFetchDeviceCredentialsNode.java +++ b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/metadata/TbFetchDeviceCredentialsNode.java @@ -16,7 +16,6 @@ package org.thingsboard.rule.engine.metadata; import com.fasterxml.jackson.databind.JsonNode; -import lombok.extern.slf4j.Slf4j; import org.thingsboard.common.util.JacksonUtil; import org.thingsboard.rule.engine.api.RuleNode; import org.thingsboard.rule.engine.api.TbContext; @@ -33,7 +32,6 @@ import org.thingsboard.server.common.msg.TbMsg; import java.util.concurrent.ExecutionException; -@Slf4j @RuleNode( type = ComponentType.ENRICHMENT, name = "fetch device credentials", @@ -45,7 +43,9 @@ import java.util.concurrent.ExecutionException; "Useful when you need to fetch device credentials and use them for further message processing. " + "For example, use device credentials to interact with external systems.

    " + "Output connections: Success, Failure.", - configDirective = "tbEnrichmentNodeFetchDeviceCredentialsConfig") + configDirective = "tbEnrichmentNodeFetchDeviceCredentialsConfig", + docUrl = "https://thingsboard.io/docs/user-guide/rule-engine-2-0/nodes/enrichment/fetch-device-credentials/" +) public class TbFetchDeviceCredentialsNode extends TbAbstractNodeWithFetchTo { private static final String CREDENTIALS = "credentials"; diff --git a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/metadata/TbGetAttributesNode.java b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/metadata/TbGetAttributesNode.java index 68ec3bb373..8af44f67f0 100644 --- a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/metadata/TbGetAttributesNode.java +++ b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/metadata/TbGetAttributesNode.java @@ -18,7 +18,6 @@ package org.thingsboard.rule.engine.metadata; import com.fasterxml.jackson.databind.JsonNode; import com.google.common.util.concurrent.Futures; import com.google.common.util.concurrent.ListenableFuture; -import lombok.extern.slf4j.Slf4j; import org.thingsboard.rule.engine.api.RuleNode; import org.thingsboard.rule.engine.api.TbContext; import org.thingsboard.rule.engine.api.TbNodeConfiguration; @@ -30,11 +29,8 @@ import org.thingsboard.server.common.data.plugin.ComponentType; import org.thingsboard.server.common.data.util.TbPair; import org.thingsboard.server.common.msg.TbMsg; -/** - * Created by ashvayka on 19.01.18. - */ -@Slf4j -@RuleNode(type = ComponentType.ENRICHMENT, +@RuleNode( + type = ComponentType.ENRICHMENT, name = "originator attributes", configClazz = TbGetAttributesNodeConfiguration.class, version = 1, @@ -43,7 +39,9 @@ import org.thingsboard.server.common.msg.TbMsg; "that are not included in the incoming message to use them for further message processing. " + "For example to filter messages based on the threshold value stored in the attributes.

    " + "Output connections: Success, Failure.", - configDirective = "tbEnrichmentNodeOriginatorAttributesConfig") + configDirective = "tbEnrichmentNodeOriginatorAttributesConfig", + docUrl = "https://thingsboard.io/docs/user-guide/rule-engine-2-0/nodes/enrichment/originator-attributes/" +) public class TbGetAttributesNode extends TbAbstractGetAttributesNode { @Override diff --git a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/metadata/TbGetCustomerAttributeNode.java b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/metadata/TbGetCustomerAttributeNode.java index 60ca50f2b3..d779bacb77 100644 --- a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/metadata/TbGetCustomerAttributeNode.java +++ b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/metadata/TbGetCustomerAttributeNode.java @@ -18,7 +18,6 @@ package org.thingsboard.rule.engine.metadata; import com.fasterxml.jackson.databind.JsonNode; import com.google.common.util.concurrent.Futures; import com.google.common.util.concurrent.ListenableFuture; -import lombok.extern.slf4j.Slf4j; import org.thingsboard.rule.engine.api.RuleNode; import org.thingsboard.rule.engine.api.TbContext; import org.thingsboard.rule.engine.api.TbNodeConfiguration; @@ -30,7 +29,6 @@ import org.thingsboard.server.common.data.id.EntityId; import org.thingsboard.server.common.data.plugin.ComponentType; import org.thingsboard.server.common.data.util.TbPair; -@Slf4j @RuleNode( type = ComponentType.ENRICHMENT, name = "customer attributes", @@ -41,7 +39,9 @@ import org.thingsboard.server.common.data.util.TbPair; "that is stored as customer attributes or telemetry data and used for dynamic message filtering, transformation, " + "or actions such as alarm creation if the threshold is exceeded.

    " + "Output connections: Success, Failure.", - configDirective = "tbEnrichmentNodeCustomerAttributesConfig") + configDirective = "tbEnrichmentNodeCustomerAttributesConfig", + docUrl = "https://thingsboard.io/docs/user-guide/rule-engine-2-0/nodes/enrichment/customer-attributes/" +) public class TbGetCustomerAttributeNode extends TbAbstractGetEntityDataNode { private static final String CUSTOMER_NOT_FOUND_MESSAGE = "Failed to find customer for entity with id: %s and type: %s"; diff --git a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/metadata/TbGetCustomerDetailsNode.java b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/metadata/TbGetCustomerDetailsNode.java index 55def65d46..7848c3c1c6 100644 --- a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/metadata/TbGetCustomerDetailsNode.java +++ b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/metadata/TbGetCustomerDetailsNode.java @@ -18,7 +18,6 @@ package org.thingsboard.rule.engine.metadata; import com.fasterxml.jackson.databind.JsonNode; import com.google.common.util.concurrent.Futures; import com.google.common.util.concurrent.ListenableFuture; -import lombok.extern.slf4j.Slf4j; import org.thingsboard.rule.engine.api.RuleNode; import org.thingsboard.rule.engine.api.TbContext; import org.thingsboard.rule.engine.api.TbNodeConfiguration; @@ -41,8 +40,8 @@ import org.thingsboard.server.common.msg.TbMsg; import java.util.NoSuchElementException; -@Slf4j -@RuleNode(type = ComponentType.ENRICHMENT, +@RuleNode( + type = ComponentType.ENRICHMENT, name = "customer details", configClazz = TbGetCustomerDetailsNodeConfiguration.class, version = 1, @@ -50,7 +49,9 @@ import java.util.NoSuchElementException; nodeDetails = "Useful in multi-customer solutions where we need dynamically use customer contact information " + "such as email, phone, address, etc., for notifications via email, SMS, and other notification providers.

    " + "Output connections: Success, Failure.", - configDirective = "tbEnrichmentNodeEntityDetailsConfig") + configDirective = "tbEnrichmentNodeEntityDetailsConfig", + docUrl = "https://thingsboard.io/docs/user-guide/rule-engine-2-0/nodes/enrichment/customer-details/" +) public class TbGetCustomerDetailsNode extends TbAbstractGetEntityDetailsNode { private static final String CUSTOMER_PREFIX = "customer_"; diff --git a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/metadata/TbGetDeviceAttrNode.java b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/metadata/TbGetDeviceAttrNode.java index 031aae8859..cf370114c3 100644 --- a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/metadata/TbGetDeviceAttrNode.java +++ b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/metadata/TbGetDeviceAttrNode.java @@ -18,7 +18,6 @@ package org.thingsboard.rule.engine.metadata; import com.fasterxml.jackson.databind.JsonNode; import com.google.common.util.concurrent.Futures; import com.google.common.util.concurrent.ListenableFuture; -import lombok.extern.slf4j.Slf4j; import org.thingsboard.rule.engine.api.RuleNode; import org.thingsboard.rule.engine.api.TbContext; import org.thingsboard.rule.engine.api.TbNodeConfiguration; @@ -31,8 +30,8 @@ import org.thingsboard.server.common.data.plugin.ComponentType; import org.thingsboard.server.common.data.util.TbPair; import org.thingsboard.server.common.msg.TbMsg; -@Slf4j -@RuleNode(type = ComponentType.ENRICHMENT, +@RuleNode( + type = ComponentType.ENRICHMENT, name = "related device attributes", configClazz = TbGetDeviceAttrNodeConfiguration.class, version = 1, @@ -42,7 +41,9 @@ import org.thingsboard.server.common.msg.TbMsg; "Useful when you need to retrieve attributes and/or latest telemetry values from device that has a relation " + "to the message originator and use them for further message processing.

    " + "Output connections: Success, Failure.", - configDirective = "tbEnrichmentNodeDeviceAttributesConfig") + configDirective = "tbEnrichmentNodeDeviceAttributesConfig", + docUrl = "https://thingsboard.io/docs/user-guide/rule-engine-2-0/nodes/enrichment/related-device-attributes/" +) public class TbGetDeviceAttrNode extends TbAbstractGetAttributesNode { private static final String RELATED_DEVICE_NOT_FOUND_MESSAGE = "Failed to find related device to message originator using relation query specified in the configuration!"; diff --git a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/metadata/TbGetOriginatorFieldsNode.java b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/metadata/TbGetOriginatorFieldsNode.java index 816b198836..1c3269db9d 100644 --- a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/metadata/TbGetOriginatorFieldsNode.java +++ b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/metadata/TbGetOriginatorFieldsNode.java @@ -17,7 +17,6 @@ package org.thingsboard.rule.engine.metadata; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.node.ObjectNode; -import lombok.extern.slf4j.Slf4j; import org.thingsboard.rule.engine.api.RuleNode; import org.thingsboard.rule.engine.api.TbContext; import org.thingsboard.rule.engine.api.TbNodeConfiguration; @@ -31,11 +30,8 @@ import org.thingsboard.server.common.msg.TbMsg; import java.util.concurrent.ExecutionException; -/** - * Created by ashvayka on 19.01.18. - */ -@Slf4j -@RuleNode(type = ComponentType.ENRICHMENT, +@RuleNode( + type = ComponentType.ENRICHMENT, name = "originator fields", configClazz = TbGetOriginatorFieldsConfiguration.class, version = 1, @@ -43,7 +39,9 @@ import java.util.concurrent.ExecutionException; nodeDetails = "Fetches fields values specified in the mapping. If specified field is not part of originator fields it will be ignored. " + "Useful when you need to retrieve originator fields and use them for further message processing.

    " + "Output connections: Success, Failure.", - configDirective = "tbEnrichmentNodeOriginatorFieldsConfig") + configDirective = "tbEnrichmentNodeOriginatorFieldsConfig", + docUrl = "https://thingsboard.io/docs/user-guide/rule-engine-2-0/nodes/enrichment/originator-fields/" +) public class TbGetOriginatorFieldsNode extends TbAbstractGetMappedDataNode { protected final static String DATA_MAPPING_PROPERTY_NAME = "dataMapping"; diff --git a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/metadata/TbGetRelatedAttributeNode.java b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/metadata/TbGetRelatedAttributeNode.java index d6df8123c2..dffcaf35ba 100644 --- a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/metadata/TbGetRelatedAttributeNode.java +++ b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/metadata/TbGetRelatedAttributeNode.java @@ -18,7 +18,6 @@ package org.thingsboard.rule.engine.metadata; import com.fasterxml.jackson.databind.JsonNode; import com.google.common.util.concurrent.Futures; import com.google.common.util.concurrent.ListenableFuture; -import lombok.extern.slf4j.Slf4j; import org.thingsboard.rule.engine.api.RuleNode; import org.thingsboard.rule.engine.api.TbContext; import org.thingsboard.rule.engine.api.TbNodeConfiguration; @@ -31,7 +30,6 @@ import org.thingsboard.server.common.data.util.TbPair; import java.util.Arrays; -@Slf4j @RuleNode( type = ComponentType.ENRICHMENT, name = "related entity data", @@ -42,7 +40,9 @@ import java.util.Arrays; "If multiple related entities are found, only first entity is used for message enrichment, other entities are discarded. " + "Useful when you need to retrieve data from an entity that has a relation to the message originator and use them for further message processing.

    " + "Output connections: Success, Failure.", - configDirective = "tbEnrichmentNodeRelatedAttributesConfig") + configDirective = "tbEnrichmentNodeRelatedAttributesConfig", + docUrl = "https://thingsboard.io/docs/user-guide/rule-engine-2-0/nodes/enrichment/related-entity-data/" +) public class TbGetRelatedAttributeNode extends TbAbstractGetEntityDataNode { private static final String RELATED_ENTITY_NOT_FOUND_MESSAGE = "Failed to find related entity to message originator using relation query specified in the configuration!"; diff --git a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/metadata/TbGetTelemetryNode.java b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/metadata/TbGetTelemetryNode.java index cc6988580a..3102b3b3fd 100644 --- a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/metadata/TbGetTelemetryNode.java +++ b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/metadata/TbGetTelemetryNode.java @@ -21,7 +21,6 @@ import com.fasterxml.jackson.databind.node.ObjectNode; import com.google.common.util.concurrent.ListenableFuture; import lombok.Data; import lombok.NoArgsConstructor; -import lombok.extern.slf4j.Slf4j; import org.apache.commons.lang3.math.NumberUtils; import org.thingsboard.common.util.DonAsynchron; import org.thingsboard.common.util.JacksonUtil; @@ -45,11 +44,8 @@ import java.util.List; import java.util.concurrent.TimeUnit; import java.util.stream.Collectors; -/** - * Created by mshvayka on 04.09.18. - */ -@Slf4j -@RuleNode(type = ComponentType.ENRICHMENT, +@RuleNode( + type = ComponentType.ENRICHMENT, name = "originator telemetry", configClazz = TbGetTelemetryNodeConfiguration.class, version = 2, @@ -58,7 +54,9 @@ import java.util.stream.Collectors; "instead of fetching just the latest telemetry or if you need to get the closest telemetry to the fetch interval start or end. " + "Also, this node can be used for telemetry aggregation within configured fetch interval.

    " + "Output connections: Success, Failure.", - configDirective = "tbEnrichmentNodeGetTelemetryFromDatabase") + configDirective = "tbEnrichmentNodeGetTelemetryFromDatabase", + docUrl = "https://thingsboard.io/docs/user-guide/rule-engine-2-0/nodes/enrichment/originator-telemetry/" +) public class TbGetTelemetryNode implements TbNode { private TbGetTelemetryNodeConfiguration config; diff --git a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/metadata/TbGetTenantAttributeNode.java b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/metadata/TbGetTenantAttributeNode.java index 2fa065abf4..cc7327074f 100644 --- a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/metadata/TbGetTenantAttributeNode.java +++ b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/metadata/TbGetTenantAttributeNode.java @@ -18,7 +18,6 @@ package org.thingsboard.rule.engine.metadata; import com.fasterxml.jackson.databind.JsonNode; import com.google.common.util.concurrent.Futures; import com.google.common.util.concurrent.ListenableFuture; -import lombok.extern.slf4j.Slf4j; import org.thingsboard.rule.engine.api.RuleNode; import org.thingsboard.rule.engine.api.TbContext; import org.thingsboard.rule.engine.api.TbNodeConfiguration; @@ -29,7 +28,6 @@ import org.thingsboard.server.common.data.id.TenantId; import org.thingsboard.server.common.data.plugin.ComponentType; import org.thingsboard.server.common.data.util.TbPair; -@Slf4j @RuleNode( type = ComponentType.ENRICHMENT, name = "tenant attributes", @@ -39,7 +37,9 @@ import org.thingsboard.server.common.data.util.TbPair; nodeDetails = "Useful when you need to retrieve some common configuration or threshold set " + "that is stored as tenant attributes or telemetry data and use it for further message processing.

    " + "Output connections: Success, Failure.", - configDirective = "tbEnrichmentNodeTenantAttributesConfig") + configDirective = "tbEnrichmentNodeTenantAttributesConfig", + docUrl = "https://thingsboard.io/docs/user-guide/rule-engine-2-0/nodes/enrichment/tenant-attributes/" +) public class TbGetTenantAttributeNode extends TbAbstractGetEntityDataNode { @Override diff --git a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/metadata/TbGetTenantDetailsNode.java b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/metadata/TbGetTenantDetailsNode.java index 4808529436..3eafb8d165 100644 --- a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/metadata/TbGetTenantDetailsNode.java +++ b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/metadata/TbGetTenantDetailsNode.java @@ -17,7 +17,6 @@ package org.thingsboard.rule.engine.metadata; import com.fasterxml.jackson.databind.JsonNode; import com.google.common.util.concurrent.ListenableFuture; -import lombok.extern.slf4j.Slf4j; import org.thingsboard.rule.engine.api.RuleNode; import org.thingsboard.rule.engine.api.TbContext; import org.thingsboard.rule.engine.api.TbNodeConfiguration; @@ -30,8 +29,8 @@ import org.thingsboard.server.common.data.plugin.ComponentType; import org.thingsboard.server.common.data.util.TbPair; import org.thingsboard.server.common.msg.TbMsg; -@Slf4j -@RuleNode(type = ComponentType.ENRICHMENT, +@RuleNode( + type = ComponentType.ENRICHMENT, name = "tenant details", configClazz = TbGetTenantDetailsNodeConfiguration.class, version = 1, @@ -39,7 +38,9 @@ import org.thingsboard.server.common.msg.TbMsg; nodeDetails = "Useful when we need to retrieve contact information from your tenant " + "such as email, phone, address, etc., for notifications via email, SMS, and other notification providers.

    " + "Output connections: Success, Failure.", - configDirective = "tbEnrichmentNodeEntityDetailsConfig") + configDirective = "tbEnrichmentNodeEntityDetailsConfig", + docUrl = "https://thingsboard.io/docs/user-guide/rule-engine-2-0/nodes/enrichment/tenant-details/" +) public class TbGetTenantDetailsNode extends TbAbstractGetEntityDetailsNode { private static final String TENANT_PREFIX = "tenant_"; diff --git a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/mqtt/TbMqttNode.java b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/mqtt/TbMqttNode.java index 87643ae46d..e4a1c2c1c4 100644 --- a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/mqtt/TbMqttNode.java +++ b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/mqtt/TbMqttNode.java @@ -22,7 +22,6 @@ import io.netty.handler.codec.mqtt.MqttQoS; import io.netty.handler.codec.mqtt.MqttVersion; import io.netty.handler.ssl.SslContext; import io.netty.util.concurrent.Promise; -import lombok.extern.slf4j.Slf4j; import org.thingsboard.common.util.JacksonUtil; import org.thingsboard.mqtt.MqttClient; import org.thingsboard.mqtt.MqttClientConfig; @@ -49,7 +48,6 @@ import java.nio.charset.StandardCharsets; import java.util.concurrent.TimeUnit; import java.util.concurrent.TimeoutException; -@Slf4j @RuleNode( type = ComponentType.EXTERNAL, name = "mqtt", @@ -59,7 +57,8 @@ import java.util.concurrent.TimeoutException; nodeDescription = "Publish messages to the MQTT broker", nodeDetails = "Will publish message payload to the MQTT broker with QoS AT_LEAST_ONCE.", configDirective = "tbExternalNodeMqttConfig", - icon = "call_split" + icon = "call_split", + docUrl = "https://thingsboard.io/docs/user-guide/rule-engine-2-0/nodes/external/mqtt/" ) public class TbMqttNode extends TbAbstractExternalNode { diff --git a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/mqtt/azure/TbAzureIotHubNode.java b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/mqtt/azure/TbAzureIotHubNode.java index 26c5b3fa42..34d06f7192 100644 --- a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/mqtt/azure/TbAzureIotHubNode.java +++ b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/mqtt/azure/TbAzureIotHubNode.java @@ -19,7 +19,6 @@ import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.node.ObjectNode; import com.google.common.annotations.VisibleForTesting; import io.netty.handler.codec.mqtt.MqttVersion; -import lombok.extern.slf4j.Slf4j; import org.thingsboard.common.util.AzureIotHubUtil; import org.thingsboard.mqtt.MqttClient; import org.thingsboard.mqtt.MqttClientConfig; @@ -39,7 +38,6 @@ import org.thingsboard.server.common.data.util.TbPair; import java.time.Clock; -@Slf4j @RuleNode( type = ComponentType.EXTERNAL, name = "azure iot hub", @@ -48,7 +46,8 @@ import java.time.Clock; clusteringMode = ComponentClusteringMode.SINGLETON, nodeDescription = "Publish messages to the Azure IoT Hub", nodeDetails = "Will publish message payload to the Azure IoT Hub with QoS AT_LEAST_ONCE.", - configDirective = "tbExternalNodeAzureIotHubConfig" + configDirective = "tbExternalNodeAzureIotHubConfig", + docUrl = "https://thingsboard.io/docs/user-guide/rule-engine-2-0/nodes/external/azure-iot-hub/" ) public class TbAzureIotHubNode extends TbMqttNode { diff --git a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/notification/TbNotificationNode.java b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/notification/TbNotificationNode.java index 6343191aa2..9838610d5b 100644 --- a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/notification/TbNotificationNode.java +++ b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/notification/TbNotificationNode.java @@ -42,7 +42,8 @@ import java.util.concurrent.ExecutionException; nodeDescription = "Sends notification to targets using the template", nodeDetails = "Will send notification to the specified targets using the template", configDirective = "tbExternalNodeNotificationConfig", - icon = "notifications" + icon = "notifications", + docUrl = "https://thingsboard.io/docs/user-guide/rule-engine-2-0/nodes/external/send-notification/" ) public class TbNotificationNode extends TbAbstractExternalNode { @@ -51,7 +52,7 @@ public class TbNotificationNode extends TbAbstractExternalNode { @Override public void init(TbContext ctx, TbNodeConfiguration configuration) throws TbNodeException { super.init(ctx); - this.config = TbNodeUtils.convert(configuration, TbNotificationNodeConfiguration.class); + config = TbNodeUtils.convert(configuration, TbNotificationNodeConfiguration.class); } @Override diff --git a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/notification/TbSlackNode.java b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/notification/TbSlackNode.java index 0abbc48974..f6303a51f2 100644 --- a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/notification/TbSlackNode.java +++ b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/notification/TbSlackNode.java @@ -34,7 +34,8 @@ import java.util.concurrent.ExecutionException; nodeDescription = "Send message via Slack", nodeDetails = "Sends message to a Slack channel or user", configDirective = "tbExternalNodeSlackConfig", - iconUrl = "data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNCAyNCI+PHBhdGggZD0iTTYsMTVBMiwyIDAgMCwxIDQsMTdBMiwyIDAgMCwxIDIsMTVBMiwyIDAgMCwxIDQsMTNINlYxNU03LDE1QTIsMiAwIDAsMSA5LDEzQTIsMiAwIDAsMSAxMSwxNVYyMEEyLDIgMCAwLDEgOSwyMkEyLDIgMCAwLDEgNywyMFYxNU05LDdBMiwyIDAgMCwxIDcsNUEyLDIgMCAwLDEgOSwzQTIsMiAwIDAsMSAxMSw1VjdIOU05LDhBMiwyIDAgMCwxIDExLDEwQTIsMiAwIDAsMSA5LDEySDRBMiwyIDAgMCwxIDIsMTBBMiwyIDAgMCwxIDQsOEg5TTE3LDEwQTIsMiAwIDAsMSAxOSw4QTIsMiAwIDAsMSAyMSwxMEEyLDIgMCAwLDEgMTksMTJIMTdWMTBNMTYsMTBBMiwyIDAgMCwxIDE0LDEyQTIsMiAwIDAsMSAxMiwxMFY1QTIsMiAwIDAsMSAxNCwzQTIsMiAwIDAsMSAxNiw1VjEwTTE0LDE4QTIsMiAwIDAsMSAxNiwyMEEyLDIgMCAwLDEgMTQsMjJBMiwyIDAgMCwxIDEyLDIwVjE4SDE0TTE0LDE3QTIsMiAwIDAsMSAxMiwxNUEyLDIgMCAwLDEgMTQsMTNIMTlBMiwyIDAgMCwxIDIxLDE1QTIsMiAwIDAsMSAxOSwxN0gxNFoiIC8+PC9zdmc+" + iconUrl = "data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNCAyNCI+PHBhdGggZD0iTTYsMTVBMiwyIDAgMCwxIDQsMTdBMiwyIDAgMCwxIDIsMTVBMiwyIDAgMCwxIDQsMTNINlYxNU03LDE1QTIsMiAwIDAsMSA5LDEzQTIsMiAwIDAsMSAxMSwxNVYyMEEyLDIgMCAwLDEgOSwyMkEyLDIgMCAwLDEgNywyMFYxNU05LDdBMiwyIDAgMCwxIDcsNUEyLDIgMCAwLDEgOSwzQTIsMiAwIDAsMSAxMSw1VjdIOU05LDhBMiwyIDAgMCwxIDExLDEwQTIsMiAwIDAsMSA5LDEySDRBMiwyIDAgMCwxIDIsMTBBMiwyIDAgMCwxIDQsOEg5TTE3LDEwQTIsMiAwIDAsMSAxOSw4QTIsMiAwIDAsMSAyMSwxMEEyLDIgMCAwLDEgMTksMTJIMTdWMTBNMTYsMTBBMiwyIDAgMCwxIDE0LDEyQTIsMiAwIDAsMSAxMiwxMFY1QTIsMiAwIDAsMSAxNCwzQTIsMiAwIDAsMSAxNiw1VjEwTTE0LDE4QTIsMiAwIDAsMSAxNiwyMEEyLDIgMCAwLDEgMTQsMjJBMiwyIDAgMCwxIDEyLDIwVjE4SDE0TTE0LDE3QTIsMiAwIDAsMSAxMiwxNUEyLDIgMCAwLDEgMTQsMTNIMTlBMiwyIDAgMCwxIDIxLDE1QTIsMiAwIDAsMSAxOSwxN0gxNFoiIC8+PC9zdmc+", + docUrl = "https://thingsboard.io/docs/user-guide/rule-engine-2-0/nodes/external/send-to-slack/" ) public class TbSlackNode extends TbAbstractExternalNode { @@ -43,7 +44,7 @@ public class TbSlackNode extends TbAbstractExternalNode { @Override public void init(TbContext ctx, TbNodeConfiguration configuration) throws TbNodeException { super.init(ctx); - this.config = TbNodeUtils.convert(configuration, TbSlackNodeConfiguration.class); + config = TbNodeUtils.convert(configuration, TbSlackNodeConfiguration.class); } @Override diff --git a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/profile/TbDeviceProfileNode.java b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/profile/TbDeviceProfileNode.java index 9a1d04eefa..2bb172bd5b 100644 --- a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/profile/TbDeviceProfileNode.java +++ b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/profile/TbDeviceProfileNode.java @@ -59,7 +59,8 @@ import java.util.concurrent.TimeUnit; nodeDescription = "Process device messages based on device profile settings", nodeDetails = "Create and clear alarms based on alarm rules defined in device profile. The output relation type is either " + "'Alarm Created', 'Alarm Updated', 'Alarm Severity Updated' and 'Alarm Cleared' or simply 'Success' if no alarms were affected.", - configDirective = "tbActionNodeDeviceProfileConfig" + configDirective = "tbActionNodeDeviceProfileConfig", + docUrl = "https://thingsboard.io/docs/user-guide/rule-engine-2-0/nodes/action/device-profile/" ) public class TbDeviceProfileNode implements TbNode { @@ -137,7 +138,7 @@ public class TbDeviceProfileNode implements TbNode { if (deviceState != null) { deviceState.process(ctx, msg); } else { - log.info("Device was not found! Most probably device [" + deviceId + "] has been removed from the database. Acknowledging msg."); + log.info("Device was not found! Most probably device [{}] has been removed from the database. Acknowledging msg.", deviceId); ctx.ack(msg); } } @@ -160,7 +161,7 @@ public class TbDeviceProfileNode implements TbNode { deviceStates.clear(); } - protected DeviceState getOrCreateDeviceState(TbContext ctx, DeviceId deviceId, RuleNodeState rns, boolean printNewlyAddedDeviceStates) { + private DeviceState getOrCreateDeviceState(TbContext ctx, DeviceId deviceId, RuleNodeState rns, boolean printNewlyAddedDeviceStates) { DeviceState deviceState = deviceStates.get(deviceId); if (deviceState == null) { DeviceProfile deviceProfile = cache.get(ctx.getTenantId(), deviceId); diff --git a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/rabbitmq/TbRabbitMqNode.java b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/rabbitmq/TbRabbitMqNode.java index a067bb43bd..6ae0f48658 100644 --- a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/rabbitmq/TbRabbitMqNode.java +++ b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/rabbitmq/TbRabbitMqNode.java @@ -46,7 +46,8 @@ import static org.thingsboard.common.util.DonAsynchron.withCallback; nodeDescription = "Publish messages to the RabbitMQ", nodeDetails = "Will publish message payload to RabbitMQ queue.", configDirective = "tbExternalNodeRabbitMqConfig", - iconUrl = "data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHhtbDpzcGFjZT0icHJlc2VydmUiIHZlcnNpb249IjEuMSIgeT0iMHB4IiB4PSIwcHgiIHZpZXdCb3g9IjAgMCAxMDAwIDEwMDAiPjxwYXRoIHN0cm9rZS13aWR0aD0iLjg0OTU2IiBkPSJtODYwLjQ3IDQxNi4zMmgtMjYyLjAxYy0xMi45MTMgMC0yMy42MTgtMTAuNzA0LTIzLjYxOC0yMy42MTh2LTI3Mi43MWMwLTIwLjMwNS0xNi4yMjctMzYuMjc2LTM2LjI3Ni0zNi4yNzZoLTkzLjc5MmMtMjAuMzA1IDAtMzYuMjc2IDE2LjIyNy0zNi4yNzYgMzYuMjc2djI3MC44NGMtMC4yNTQ4NyAxNC4xMDMtMTEuNDY5IDI1LjU3Mi0yNS43NDIgMjUuNTcybC04NS42MzYgMC42Nzk2NWMtMTQuMTAzIDAtMjUuNTcyLTExLjQ2OS0yNS41NzItMjUuNTcybDAuNjc5NjUtMjcxLjUyYzAtMjAuMzA1LTE2LjIyNy0zNi4yNzYtMzYuMjc2LTM2LjI3NmgtOTMuNTM3Yy0yMC4zMDUgMC0zNi4yNzYgMTYuMjI3LTM2LjI3NiAzNi4yNzZ2NzYzLjg0YzAgMTguMDk2IDE0Ljc4MiAzMi40NTMgMzIuNDUzIDMyLjQ1M2g3MjIuODFjMTguMDk2IDAgMzIuNDUzLTE0Ljc4MiAzMi40NTMtMzIuNDUzdi00MzUuMzFjLTEuMTg5NC0xOC4xODEtMTUuMjkyLTMyLjE5OC0zMy4zODgtMzIuMTk4em0tMTIyLjY4IDI4Ny4wN2MwIDIzLjYxOC0xOC44NiA0Mi40NzgtNDIuNDc4IDQyLjQ3OGgtNzMuOTk3Yy0yMy42MTggMC00Mi40NzgtMTguODYtNDIuNDc4LTQyLjQ3OHYtNzQuMjUyYzAtMjMuNjE4IDE4Ljg2LTQyLjQ3OCA0Mi40NzgtNDIuNDc4aDczLjk5N2MyMy42MTggMCA0Mi40NzggMTguODYgNDIuNDc4IDQyLjQ3OHoiLz48L3N2Zz4=" + iconUrl = "data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHhtbDpzcGFjZT0icHJlc2VydmUiIHZlcnNpb249IjEuMSIgeT0iMHB4IiB4PSIwcHgiIHZpZXdCb3g9IjAgMCAxMDAwIDEwMDAiPjxwYXRoIHN0cm9rZS13aWR0aD0iLjg0OTU2IiBkPSJtODYwLjQ3IDQxNi4zMmgtMjYyLjAxYy0xMi45MTMgMC0yMy42MTgtMTAuNzA0LTIzLjYxOC0yMy42MTh2LTI3Mi43MWMwLTIwLjMwNS0xNi4yMjctMzYuMjc2LTM2LjI3Ni0zNi4yNzZoLTkzLjc5MmMtMjAuMzA1IDAtMzYuMjc2IDE2LjIyNy0zNi4yNzYgMzYuMjc2djI3MC44NGMtMC4yNTQ4NyAxNC4xMDMtMTEuNDY5IDI1LjU3Mi0yNS43NDIgMjUuNTcybC04NS42MzYgMC42Nzk2NWMtMTQuMTAzIDAtMjUuNTcyLTExLjQ2OS0yNS41NzItMjUuNTcybDAuNjc5NjUtMjcxLjUyYzAtMjAuMzA1LTE2LjIyNy0zNi4yNzYtMzYuMjc2LTM2LjI3NmgtOTMuNTM3Yy0yMC4zMDUgMC0zNi4yNzYgMTYuMjI3LTM2LjI3NiAzNi4yNzZ2NzYzLjg0YzAgMTguMDk2IDE0Ljc4MiAzMi40NTMgMzIuNDUzIDMyLjQ1M2g3MjIuODFjMTguMDk2IDAgMzIuNDUzLTE0Ljc4MiAzMi40NTMtMzIuNDUzdi00MzUuMzFjLTEuMTg5NC0xOC4xODEtMTUuMjkyLTMyLjE5OC0zMy4zODgtMzIuMTk4em0tMTIyLjY4IDI4Ny4wN2MwIDIzLjYxOC0xOC44NiA0Mi40NzgtNDIuNDc4IDQyLjQ3OGgtNzMuOTk3Yy0yMy42MTggMC00Mi40NzgtMTguODYtNDIuNDc4LTQyLjQ3OHYtNzQuMjUyYzAtMjMuNjE4IDE4Ljg2LTQyLjQ3OCA0Mi40NzgtNDIuNDc4aDczLjk5N2MyMy42MTggMCA0Mi40NzggMTguODYgNDIuNDc4IDQyLjQ3OHoiLz48L3N2Zz4=", + docUrl = "https://thingsboard.io/docs/user-guide/rule-engine-2-0/nodes/external/rabbitmq/" ) public class TbRabbitMqNode extends TbAbstractExternalNode { @@ -99,10 +100,10 @@ public class TbRabbitMqNode extends TbAbstractExternalNode { } private ListenableFuture publishMessageAsync(TbContext ctx, TbMsg msg) { - return ctx.getExternalCallExecutor().executeAsync(() -> publishMessage(ctx, msg)); + return ctx.getExternalCallExecutor().executeAsync(() -> publishMessage(msg)); } - private TbMsg publishMessage(TbContext ctx, TbMsg msg) throws Exception { + private TbMsg publishMessage(TbMsg msg) throws Exception { String exchangeName = ""; if (!StringUtils.isEmpty(this.config.getExchangeNamePattern())) { exchangeName = TbNodeUtils.processPattern(this.config.getExchangeNamePattern(), msg); @@ -143,23 +144,15 @@ public class TbRabbitMqNode extends TbAbstractExternalNode { } static AMQP.BasicProperties convert(String name) throws TbNodeException { - switch (name) { - case "BASIC": - return MessageProperties.BASIC; - case "TEXT_PLAIN": - return MessageProperties.TEXT_PLAIN; - case "MINIMAL_BASIC": - return MessageProperties.MINIMAL_BASIC; - case "MINIMAL_PERSISTENT_BASIC": - return MessageProperties.MINIMAL_PERSISTENT_BASIC; - case "PERSISTENT_BASIC": - return MessageProperties.PERSISTENT_BASIC; - case "PERSISTENT_TEXT_PLAIN": - return MessageProperties.PERSISTENT_TEXT_PLAIN; - default: - throw new TbNodeException("Undefined message properties type '" + name + - "'! Only " + supportedPropertiesStr + " message properties types are supported!"); - } + return switch (name) { + case "BASIC" -> MessageProperties.BASIC; + case "TEXT_PLAIN" -> MessageProperties.TEXT_PLAIN; + case "MINIMAL_BASIC" -> MessageProperties.MINIMAL_BASIC; + case "MINIMAL_PERSISTENT_BASIC" -> MessageProperties.MINIMAL_PERSISTENT_BASIC; + case "PERSISTENT_BASIC" -> MessageProperties.PERSISTENT_BASIC; + case "PERSISTENT_TEXT_PLAIN" -> MessageProperties.PERSISTENT_TEXT_PLAIN; + default -> throw new TbNodeException("Undefined message properties type '" + name + "'! Only " + supportedPropertiesStr + " message properties types are supported!"); + }; } -} +} diff --git a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/rest/TbRestApiCallNode.java b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/rest/TbRestApiCallNode.java index 8341547ef0..a2290a4eda 100644 --- a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/rest/TbRestApiCallNode.java +++ b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/rest/TbRestApiCallNode.java @@ -17,7 +17,6 @@ package org.thingsboard.rule.engine.rest; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.node.ObjectNode; -import lombok.extern.slf4j.Slf4j; import org.thingsboard.rule.engine.api.RuleNode; import org.thingsboard.rule.engine.api.TbContext; import org.thingsboard.rule.engine.api.TbNodeConfiguration; @@ -30,7 +29,6 @@ import org.thingsboard.server.common.msg.TbMsg; import java.util.List; -@Slf4j @RuleNode( type = ComponentType.EXTERNAL, name = "rest api call", @@ -46,7 +44,8 @@ import java.util.List; "
    Note- if you use system proxy properties, the next system proxy properties should be added: \"http.proxyHost\" and \"http.proxyPort\" or \"https.proxyHost\" and \"https.proxyPort\" or \"socksProxyHost\" and \"socksProxyPort\"," + "and if your proxy with auth, the next ones should be added: \"tb.proxy.user\" and \"tb.proxy.password\" to the thingsboard.conf file.", configDirective = "tbExternalNodeRestApiCallConfig", - iconUrl = "data:image/svg+xml;base64,PHN2ZyBzdHlsZT0iZW5hYmxlLWJhY2tncm91bmQ6bmV3IDAgMCA1MTIgNTEyIiB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHhtbDpzcGFjZT0icHJlc2VydmUiIHZpZXdCb3g9IjAgMCA1MTIgNTEyIiB2ZXJzaW9uPSIxLjEiIHk9IjBweCIgeD0iMHB4Ij48ZyB0cmFuc2Zvcm09Im1hdHJpeCguOTQ5NzUgMCAwIC45NDk3NSAxNy4xMiAyNi40OTIpIj48cGF0aCBkPSJtMTY5LjExIDEwOC41NGMtOS45MDY2IDAuMDczNC0xOS4wMTQgNi41NzI0LTIyLjAxNCAxNi40NjlsLTY5Ljk5MyAyMzEuMDhjLTMuNjkwNCAxMi4xODEgMy4yODkyIDI1LjIyIDE1LjQ2OSAyOC45MSAyLjIyNTkgMC42NzQ4MSA0LjQ5NjkgMSA2LjcyODUgMSA5Ljk3MjEgMCAxOS4xNjUtNi41MTUzIDIyLjE4Mi0xNi40NjdhNi41MjI0IDYuNTIyNCAwIDAgMCAwLjAwMiAtMC4wMDJsNjkuOTktMjMxLjA3YTYuNTIyNCA2LjUyMjQgMCAwIDAgMCAtMC4wMDJjMy42ODU1LTEyLjE4MS0zLjI4Ny0yNS4yMjUtMTUuNDcxLTI4LjkxMi0yLjI4MjUtMC42OTE0NS00LjYxMTYtMS4wMTY5LTYuODk4NC0xem04NC45ODggMGMtOS45MDQ4IDAuMDczNC0xOS4wMTggNi41Njc1LTIyLjAxOCAxNi40NjlsLTY5Ljk4NiAyMzEuMDhjLTMuNjg5OCAxMi4xNzkgMy4yODUzIDI1LjIxNyAxNS40NjUgMjguOTA4IDIuMjI5NyAwLjY3NjQ3IDQuNTAwOCAxLjAwMiA2LjczMjQgMS4wMDIgOS45NzIxIDAgMTkuMTY1LTYuNTE1MyAyMi4xODItMTYuNDY3YTYuNTIyNCA2LjUyMjQgMCAwIDAgMC4wMDIgLTAuMDAybDY5Ljk4OC0yMzEuMDdjMy42OTA4LTEyLjE4MS0zLjI4NTItMjUuMjIzLTE1LjQ2Ny0yOC45MTItMi4yODE0LTAuNjkyMzEtNC42MTA4LTEuMDE4OS02Ljg5ODQtMS4wMDJ6bS0yMTcuMjkgNDIuMjNjLTEyLjcyOS0wLjAwMDg3LTIzLjE4OCAxMC40NTYtMjMuMTg4IDIzLjE4NiAwLjAwMSAxMi43MjggMTAuNDU5IDIzLjE4NiAyMy4xODggMjMuMTg2IDEyLjcyNy0wLjAwMSAyMy4xODMtMTAuNDU5IDIzLjE4NC0yMy4xODYgMC4wMDA4NzYtMTIuNzI4LTEwLjQ1Ni0yMy4xODUtMjMuMTg0LTIzLjE4NnptMCAxNDYuNjRjLTEyLjcyNy0wLjAwMDg3LTIzLjE4NiAxMC40NTUtMjMuMTg4IDIzLjE4NC0wLjAwMDg3MyAxMi43MjkgMTAuNDU4IDIzLjE4OCAyMy4xODggMjMuMTg4IDEyLjcyOC0wLjAwMSAyMy4xODQtMTAuNDYgMjMuMTg0LTIzLjE4OC0wLjAwMS0xMi43MjYtMTAuNDU3LTIzLjE4My0yMy4xODQtMjMuMTg0em0yNzAuNzkgNDIuMjExYy0xMi43MjcgMC0yMy4xODQgMTAuNDU3LTIzLjE4NCAyMy4xODRzMTAuNDU1IDIzLjE4OCAyMy4xODQgMjMuMTg4aDE1NC45OGMxMi43MjkgMCAyMy4xODYtMTAuNDYgMjMuMTg2LTIzLjE4OCAwLjAwMS0xMi43MjgtMTAuNDU4LTIzLjE4NC0yMy4xODYtMjMuMTg0eiIgdHJhbnNmb3JtPSJtYXRyaXgoMS4wMzc2IDAgMCAxLjAzNzYgLTcuNTY3NiAtMTQuOTI1KSIgc3Ryb2tlLXdpZHRoPSIxLjI2OTMiLz48L2c+PC9zdmc+" + iconUrl = "data:image/svg+xml;base64,PHN2ZyBzdHlsZT0iZW5hYmxlLWJhY2tncm91bmQ6bmV3IDAgMCA1MTIgNTEyIiB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHhtbDpzcGFjZT0icHJlc2VydmUiIHZpZXdCb3g9IjAgMCA1MTIgNTEyIiB2ZXJzaW9uPSIxLjEiIHk9IjBweCIgeD0iMHB4Ij48ZyB0cmFuc2Zvcm09Im1hdHJpeCguOTQ5NzUgMCAwIC45NDk3NSAxNy4xMiAyNi40OTIpIj48cGF0aCBkPSJtMTY5LjExIDEwOC41NGMtOS45MDY2IDAuMDczNC0xOS4wMTQgNi41NzI0LTIyLjAxNCAxNi40NjlsLTY5Ljk5MyAyMzEuMDhjLTMuNjkwNCAxMi4xODEgMy4yODkyIDI1LjIyIDE1LjQ2OSAyOC45MSAyLjIyNTkgMC42NzQ4MSA0LjQ5NjkgMSA2LjcyODUgMSA5Ljk3MjEgMCAxOS4xNjUtNi41MTUzIDIyLjE4Mi0xNi40NjdhNi41MjI0IDYuNTIyNCAwIDAgMCAwLjAwMiAtMC4wMDJsNjkuOTktMjMxLjA3YTYuNTIyNCA2LjUyMjQgMCAwIDAgMCAtMC4wMDJjMy42ODU1LTEyLjE4MS0zLjI4Ny0yNS4yMjUtMTUuNDcxLTI4LjkxMi0yLjI4MjUtMC42OTE0NS00LjYxMTYtMS4wMTY5LTYuODk4NC0xem04NC45ODggMGMtOS45MDQ4IDAuMDczNC0xOS4wMTggNi41Njc1LTIyLjAxOCAxNi40NjlsLTY5Ljk4NiAyMzEuMDhjLTMuNjg5OCAxMi4xNzkgMy4yODUzIDI1LjIxNyAxNS40NjUgMjguOTA4IDIuMjI5NyAwLjY3NjQ3IDQuNTAwOCAxLjAwMiA2LjczMjQgMS4wMDIgOS45NzIxIDAgMTkuMTY1LTYuNTE1MyAyMi4xODItMTYuNDY3YTYuNTIyNCA2LjUyMjQgMCAwIDAgMC4wMDIgLTAuMDAybDY5Ljk4OC0yMzEuMDdjMy42OTA4LTEyLjE4MS0zLjI4NTItMjUuMjIzLTE1LjQ2Ny0yOC45MTItMi4yODE0LTAuNjkyMzEtNC42MTA4LTEuMDE4OS02Ljg5ODQtMS4wMDJ6bS0yMTcuMjkgNDIuMjNjLTEyLjcyOS0wLjAwMDg3LTIzLjE4OCAxMC40NTYtMjMuMTg4IDIzLjE4NiAwLjAwMSAxMi43MjggMTAuNDU5IDIzLjE4NiAyMy4xODggMjMuMTg2IDEyLjcyNy0wLjAwMSAyMy4xODMtMTAuNDU5IDIzLjE4NC0yMy4xODYgMC4wMDA4NzYtMTIuNzI4LTEwLjQ1Ni0yMy4xODUtMjMuMTg0LTIzLjE4NnptMCAxNDYuNjRjLTEyLjcyNy0wLjAwMDg3LTIzLjE4NiAxMC40NTUtMjMuMTg4IDIzLjE4NC0wLjAwMDg3MyAxMi43MjkgMTAuNDU4IDIzLjE4OCAyMy4xODggMjMuMTg4IDEyLjcyOC0wLjAwMSAyMy4xODQtMTAuNDYgMjMuMTg0LTIzLjE4OC0wLjAwMS0xMi43MjYtMTAuNDU3LTIzLjE4My0yMy4xODQtMjMuMTg0em0yNzAuNzkgNDIuMjExYy0xMi43MjcgMC0yMy4xODQgMTAuNDU3LTIzLjE4NCAyMy4xODRzMTAuNDU1IDIzLjE4OCAyMy4xODQgMjMuMTg4aDE1NC45OGMxMi43MjkgMCAyMy4xODYtMTAuNDYgMjMuMTg2LTIzLjE4OCAwLjAwMS0xMi43MjgtMTAuNDU4LTIzLjE4NC0yMy4xODYtMjMuMTg0eiIgdHJhbnNmb3JtPSJtYXRyaXgoMS4wMzc2IDAgMCAxLjAzNzYgLTcuNTY3NiAtMTQuOTI1KSIgc3Ryb2tlLXdpZHRoPSIxLjI2OTMiLz48L2c+PC9zdmc+", + docUrl = "https://thingsboard.io/docs/user-guide/rule-engine-2-0/nodes/external/rest-api-call/" ) public class TbRestApiCallNode extends TbAbstractExternalNode { diff --git a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/rest/TbSendRestApiCallReplyNode.java b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/rest/TbSendRestApiCallReplyNode.java index 5be2768a0f..8148e5ea58 100644 --- a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/rest/TbSendRestApiCallReplyNode.java +++ b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/rest/TbSendRestApiCallReplyNode.java @@ -15,7 +15,6 @@ */ package org.thingsboard.rule.engine.rest; -import lombok.extern.slf4j.Slf4j; import org.thingsboard.rule.engine.api.RuleNode; import org.thingsboard.rule.engine.api.TbContext; import org.thingsboard.rule.engine.api.TbNode; @@ -28,7 +27,6 @@ import org.thingsboard.server.common.msg.TbMsg; import java.util.UUID; -@Slf4j @RuleNode( type = ComponentType.ACTION, name = "rest call reply", @@ -36,7 +34,8 @@ import java.util.UUID; nodeDescription = "Sends reply to REST API call to rule engine", nodeDetails = "Expects messages with any message type. Forwards incoming message as a reply to REST API call sent to rule engine.", configDirective = "tbActionNodeSendRestApiCallReplyConfig", - icon = "call_merge" + icon = "call_merge", + docUrl = "https://thingsboard.io/docs/user-guide/rule-engine-2-0/nodes/action/rest-call-reply/" ) public class TbSendRestApiCallReplyNode implements TbNode { @@ -44,7 +43,7 @@ public class TbSendRestApiCallReplyNode implements TbNode { @Override public void init(TbContext ctx, TbNodeConfiguration configuration) throws TbNodeException { - this.config = TbNodeUtils.convert(configuration, TbSendRestApiCallReplyNodeConfiguration.class); + config = TbNodeUtils.convert(configuration, TbSendRestApiCallReplyNodeConfiguration.class); } @Override @@ -62,4 +61,5 @@ public class TbSendRestApiCallReplyNode implements TbNode { ctx.tellSuccess(msg); } } + } diff --git a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/rpc/TbSendRPCReplyNode.java b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/rpc/TbSendRPCReplyNode.java index a8e7142cfb..0f136772d4 100644 --- a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/rpc/TbSendRPCReplyNode.java +++ b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/rpc/TbSendRPCReplyNode.java @@ -19,7 +19,7 @@ import com.fasterxml.jackson.databind.node.ObjectNode; import com.google.common.util.concurrent.FutureCallback; import com.google.common.util.concurrent.Futures; import com.google.common.util.concurrent.ListenableFuture; -import lombok.extern.slf4j.Slf4j; +import org.checkerframework.checker.nullness.qual.NonNull; import org.thingsboard.common.util.JacksonUtil; import org.thingsboard.rule.engine.api.RuleNode; import org.thingsboard.rule.engine.api.TbContext; @@ -41,7 +41,6 @@ import org.thingsboard.server.common.msg.TbMsg; import java.util.UUID; -@Slf4j @RuleNode( type = ComponentType.ACTION, name = "rpc call reply", @@ -49,7 +48,8 @@ import java.util.UUID; nodeDescription = "Sends reply to RPC call from device", nodeDetails = "Expects messages with any message type. Will forward message body to the device.", configDirective = "tbActionNodeRpcReplyConfig", - icon = "call_merge" + icon = "call_merge", + docUrl = "https://thingsboard.io/docs/user-guide/rule-engine-2-0/nodes/action/rpc-call-reply/" ) public class TbSendRPCReplyNode implements TbNode { @@ -57,7 +57,7 @@ public class TbSendRPCReplyNode implements TbNode { @Override public void init(TbContext ctx, TbNodeConfiguration configuration) throws TbNodeException { - this.config = TbNodeUtils.convert(configuration, TbSendRpcReplyNodeConfiguration.class); + config = TbNodeUtils.convert(configuration, TbSendRpcReplyNodeConfiguration.class); } @Override @@ -103,7 +103,7 @@ public class TbSendRPCReplyNode implements TbNode { body.put("requestId", requestIdStr); body.put("response", msg.getData()); EdgeEvent edgeEvent = EdgeUtils.constructEdgeEvent(ctx.getTenantId(), edgeId, EdgeEventType.DEVICE, - EdgeEventActionType.RPC_CALL, deviceId, JacksonUtil.valueToTree(body)); + EdgeEventActionType.RPC_CALL, deviceId, JacksonUtil.valueToTree(body)); ListenableFuture future = ctx.getEdgeEventService().saveAsync(edgeEvent); Futures.addCallback(future, new FutureCallback<>() { @Override @@ -112,9 +112,10 @@ public class TbSendRPCReplyNode implements TbNode { } @Override - public void onFailure(Throwable t) { + public void onFailure(@NonNull Throwable t) { ctx.tellFailure(msg, t); } }, ctx.getDbCallbackExecutor()); } + } diff --git a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/rpc/TbSendRPCRequestNode.java b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/rpc/TbSendRPCRequestNode.java index 51f5166653..ead79a3793 100644 --- a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/rpc/TbSendRPCRequestNode.java +++ b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/rpc/TbSendRPCRequestNode.java @@ -20,7 +20,6 @@ import com.google.gson.Gson; import com.google.gson.JsonElement; import com.google.gson.JsonObject; import com.google.gson.JsonParser; -import lombok.extern.slf4j.Slf4j; import org.thingsboard.rule.engine.api.RuleEngineDeviceRpcRequest; import org.thingsboard.rule.engine.api.RuleNode; import org.thingsboard.rule.engine.api.TbContext; @@ -41,7 +40,6 @@ import java.util.Random; import java.util.UUID; import java.util.concurrent.TimeUnit; -@Slf4j @RuleNode( type = ComponentType.ACTION, name = "rpc call request", @@ -50,17 +48,18 @@ import java.util.concurrent.TimeUnit; nodeDetails = "Expects messages with \"method\" and \"params\". Will forward response from device to next nodes." + "If the RPC call request is originated by REST API call from user, will forward the response to user immediately.", configDirective = "tbActionNodeRpcRequestConfig", - icon = "call_made" + icon = "call_made", + docUrl = "https://thingsboard.io/docs/user-guide/rule-engine-2-0/nodes/action/rpc-call-request/" ) public class TbSendRPCRequestNode implements TbNode { - private Random random = new Random(); - private Gson gson = new Gson(); + private final Random random = new Random(); + private final Gson gson = new Gson(); private TbSendRpcRequestNodeConfiguration config; @Override public void init(TbContext ctx, TbNodeConfiguration configuration) throws TbNodeException { - this.config = TbNodeUtils.convert(configuration, TbSendRpcRequestNodeConfiguration.class); + config = TbNodeUtils.convert(configuration, TbSendRpcRequestNodeConfiguration.class); } @Override diff --git a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/sms/TbSendSmsNode.java b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/sms/TbSendSmsNode.java index d5583037ac..3033c9d382 100644 --- a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/sms/TbSendSmsNode.java +++ b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/sms/TbSendSmsNode.java @@ -15,7 +15,6 @@ */ package org.thingsboard.rule.engine.sms; -import lombok.extern.slf4j.Slf4j; import org.thingsboard.rule.engine.api.RuleNode; import org.thingsboard.rule.engine.api.TbContext; import org.thingsboard.rule.engine.api.TbNodeConfiguration; @@ -28,7 +27,6 @@ import org.thingsboard.server.common.msg.TbMsg; import static org.thingsboard.common.util.DonAsynchron.withCallback; -@Slf4j @RuleNode( type = ComponentType.EXTERNAL, name = "send sms", @@ -36,7 +34,8 @@ import static org.thingsboard.common.util.DonAsynchron.withCallback; nodeDescription = "Sends SMS message via SMS provider.", nodeDetails = "Will send SMS message by populating target phone numbers and sms message fields using values derived from message metadata.", configDirective = "tbExternalNodeSendSmsConfig", - icon = "sms" + icon = "sms", + docUrl = "https://thingsboard.io/docs/user-guide/rule-engine-2-0/nodes/external/send-sms/" ) public class TbSendSmsNode extends TbAbstractExternalNode { diff --git a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/telemetry/TbCalculatedFieldsNode.java b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/telemetry/TbCalculatedFieldsNode.java index e703e9dd25..c819b98466 100644 --- a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/telemetry/TbCalculatedFieldsNode.java +++ b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/telemetry/TbCalculatedFieldsNode.java @@ -16,16 +16,13 @@ package org.thingsboard.rule.engine.telemetry; import com.google.gson.JsonParser; -import lombok.extern.slf4j.Slf4j; import org.thingsboard.rule.engine.api.AttributesSaveRequest; import org.thingsboard.rule.engine.api.EmptyNodeConfiguration; import org.thingsboard.rule.engine.api.RuleNode; import org.thingsboard.rule.engine.api.TbContext; import org.thingsboard.rule.engine.api.TbNode; import org.thingsboard.rule.engine.api.TbNodeConfiguration; -import org.thingsboard.rule.engine.api.TbNodeException; import org.thingsboard.rule.engine.api.TimeseriesSaveRequest; -import org.thingsboard.rule.engine.api.util.TbNodeUtils; import org.thingsboard.server.common.adaptor.JsonConverter; import org.thingsboard.server.common.data.AttributeScope; import org.thingsboard.server.common.data.kv.AttributeKvEntry; @@ -41,7 +38,6 @@ import java.util.Map; import static org.thingsboard.server.common.data.DataConstants.SCOPE; -@Slf4j @RuleNode( type = ComponentType.ACTION, name = "calculated fields", @@ -52,16 +48,13 @@ import static org.thingsboard.server.common.data.DataConstants.SCOPE; "This rule node accepts the same messages as these nodes but allows you to trigger the processing of calculated " + "fields independently, ensuring that derived data can be computed and utilized in real time without storing the original message in the database.", configDirective = "tbNodeEmptyConfig", - icon = "published_with_changes" + icon = "published_with_changes", + docUrl = "https://thingsboard.io/docs/user-guide/rule-engine-2-0/nodes/action/calculated-fields/" ) public class TbCalculatedFieldsNode implements TbNode { - private EmptyNodeConfiguration config; - @Override - public void init(TbContext ctx, TbNodeConfiguration configuration) throws TbNodeException { - this.config = TbNodeUtils.convert(configuration, EmptyNodeConfiguration.class); - } + public void init(TbContext ctx, TbNodeConfiguration configuration) {} @Override public void onMsg(TbContext ctx, TbMsg msg) { diff --git a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/telemetry/TbMsgAttributesNode.java b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/telemetry/TbMsgAttributesNode.java index 280d8de824..533f7d13dd 100644 --- a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/telemetry/TbMsgAttributesNode.java +++ b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/telemetry/TbMsgAttributesNode.java @@ -21,7 +21,6 @@ import com.google.common.util.concurrent.FutureCallback; import com.google.common.util.concurrent.ListenableFuture; import com.google.common.util.concurrent.MoreExecutors; import com.google.gson.JsonParser; -import lombok.extern.slf4j.Slf4j; import org.thingsboard.common.util.DonAsynchron; import org.thingsboard.common.util.JacksonUtil; import org.thingsboard.rule.engine.api.AttributesSaveRequest; @@ -56,7 +55,6 @@ import static org.thingsboard.server.common.data.DataConstants.NOTIFY_DEVICE_MET import static org.thingsboard.server.common.data.DataConstants.SCOPE; import static org.thingsboard.server.common.data.msg.TbMsgType.POST_ATTRIBUTES_REQUEST; -@Slf4j @RuleNode( type = ComponentType.ACTION, name = "save attributes", @@ -107,7 +105,8 @@ import static org.thingsboard.server.common.data.msg.TbMsgType.POST_ATTRIBUTES_R Output connections: Success, Failure. """, configDirective = "tbActionNodeAttributesConfig", - icon = "file_upload" + icon = "file_upload", + docUrl = "https://thingsboard.io/docs/user-guide/rule-engine-2-0/nodes/action/save-attributes/" ) public class TbMsgAttributesNode implements TbNode { diff --git a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/telemetry/TbMsgDeleteAttributesNode.java b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/telemetry/TbMsgDeleteAttributesNode.java index 32d700dc1d..331c28b686 100644 --- a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/telemetry/TbMsgDeleteAttributesNode.java +++ b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/telemetry/TbMsgDeleteAttributesNode.java @@ -15,7 +15,6 @@ */ package org.thingsboard.rule.engine.telemetry; -import lombok.extern.slf4j.Slf4j; import org.thingsboard.rule.engine.api.AttributesDeleteRequest; import org.thingsboard.rule.engine.api.RuleNode; import org.thingsboard.rule.engine.api.TbContext; @@ -35,7 +34,6 @@ import java.util.stream.Collectors; import static org.thingsboard.server.common.data.DataConstants.NOTIFY_DEVICE_METADATA_KEY; import static org.thingsboard.server.common.data.DataConstants.SCOPE; -@Slf4j @RuleNode( type = ComponentType.ACTION, name = "delete attributes", @@ -46,7 +44,8 @@ import static org.thingsboard.server.common.data.DataConstants.SCOPE; " rule node will send the \"Attributes Deleted\" event to the root chain of the message originator and " + " send the incoming message via Success chain, otherwise, Failure chain is used.", configDirective = "tbActionNodeDeleteAttributesConfig", - icon = "remove_circle" + icon = "remove_circle", + docUrl = "https://thingsboard.io/docs/user-guide/rule-engine-2-0/nodes/action/delete-attributes/" ) public class TbMsgDeleteAttributesNode implements TbNode { @@ -55,8 +54,8 @@ public class TbMsgDeleteAttributesNode implements TbNode { @Override public void init(TbContext ctx, TbNodeConfiguration configuration) throws TbNodeException { - this.config = TbNodeUtils.convert(configuration, TbMsgDeleteAttributesNodeConfiguration.class); - this.keys = config.getKeys(); + config = TbNodeUtils.convert(configuration, TbMsgDeleteAttributesNodeConfiguration.class); + keys = config.getKeys(); } @Override diff --git a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/telemetry/TbMsgTimeseriesNode.java b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/telemetry/TbMsgTimeseriesNode.java index 8017be9a04..13dab98c54 100644 --- a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/telemetry/TbMsgTimeseriesNode.java +++ b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/telemetry/TbMsgTimeseriesNode.java @@ -18,7 +18,6 @@ package org.thingsboard.rule.engine.telemetry; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.node.ObjectNode; import com.google.gson.JsonParser; -import lombok.extern.slf4j.Slf4j; import org.thingsboard.common.util.JacksonUtil; import org.thingsboard.rule.engine.api.RuleNode; import org.thingsboard.rule.engine.api.TbContext; @@ -52,7 +51,6 @@ import static org.thingsboard.rule.engine.telemetry.settings.TimeseriesProcessin import static org.thingsboard.rule.engine.telemetry.settings.TimeseriesProcessingSettings.WebSocketsOnly; import static org.thingsboard.server.common.data.msg.TbMsgType.POST_TELEMETRY_REQUEST; -@Slf4j @RuleNode( type = ComponentType.ACTION, name = "save time series", @@ -103,7 +101,8 @@ import static org.thingsboard.server.common.data.msg.TbMsgType.POST_TELEMETRY_RE """, configDirective = "tbActionNodeTimeseriesConfig", icon = "file_upload", - version = 1 + version = 1, + docUrl = "https://thingsboard.io/docs/user-guide/rule-engine-2-0/nodes/action/save-timeseries/" ) public class TbMsgTimeseriesNode implements TbNode { diff --git a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/transform/TbChangeOriginatorNode.java b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/transform/TbChangeOriginatorNode.java index eff08d09e7..e3ab60a6e0 100644 --- a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/transform/TbChangeOriginatorNode.java +++ b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/transform/TbChangeOriginatorNode.java @@ -55,7 +55,8 @@ import static org.thingsboard.rule.engine.transform.OriginatorSource.RELATED; "'Device', 'Asset', 'Entity View', 'Edge' or 'User'." + "Output connections: Success, Failure.", configDirective = "tbTransformationNodeChangeOriginatorConfig", - icon = "find_replace" + icon = "find_replace", + docUrl = "https://thingsboard.io/docs/user-guide/rule-engine-2-0/nodes/transformation/change-originator/" ) public class TbChangeOriginatorNode extends TbAbstractTransformNode { diff --git a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/transform/TbCopyKeysNode.java b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/transform/TbCopyKeysNode.java index 28864ad2db..d9e821e77f 100644 --- a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/transform/TbCopyKeysNode.java +++ b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/transform/TbCopyKeysNode.java @@ -47,22 +47,22 @@ import java.util.stream.Collectors; "Regular expressions can be used to define which keys-value pairs to copy. Any configured key not found in the source will be ignored.

    " + "Output connections: Success, Failure.", configDirective = "tbTransformationNodeCopyKeysConfig", - icon = "content_copy" + icon = "content_copy", + docUrl = "https://thingsboard.io/docs/user-guide/rule-engine-2-0/nodes/transformation/copy-key-value-pairs/" ) public class TbCopyKeysNode extends TbAbstractTransformNodeWithTbMsgSource { - private TbCopyKeysNodeConfiguration config; private TbMsgSource copyFrom; private List compiledKeyPatterns; @Override public void init(TbContext ctx, TbNodeConfiguration configuration) throws TbNodeException { - this.config = TbNodeUtils.convert(configuration, TbCopyKeysNodeConfiguration.class); - this.copyFrom = config.getCopyFrom(); + var config = TbNodeUtils.convert(configuration, TbCopyKeysNodeConfiguration.class); + copyFrom = config.getCopyFrom(); if (copyFrom == null) { throw new TbNodeException("CopyFrom can't be null! Allowed values: " + Arrays.toString(TbMsgSource.values())); } - this.compiledKeyPatterns = config.getKeys().stream().map(Pattern::compile).collect(Collectors.toList()); + compiledKeyPatterns = config.getKeys().stream().map(Pattern::compile).collect(Collectors.toList()); } @Override diff --git a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/transform/TbDeleteKeysNode.java b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/transform/TbDeleteKeysNode.java index 91f16f0b3b..c2eead99fe 100644 --- a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/transform/TbDeleteKeysNode.java +++ b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/transform/TbDeleteKeysNode.java @@ -47,22 +47,22 @@ import java.util.stream.Collectors; "keys and/or regular expressions.

    " + "Output connections: Success, Failure.", configDirective = "tbTransformationNodeDeleteKeysConfig", - icon = "remove_circle" + icon = "remove_circle", + docUrl = "https://thingsboard.io/docs/user-guide/rule-engine-2-0/nodes/transformation/delete-key-value-pairs/" ) public class TbDeleteKeysNode extends TbAbstractTransformNodeWithTbMsgSource { - private TbDeleteKeysNodeConfiguration config; private TbMsgSource deleteFrom; private List compiledKeyPatterns; @Override public void init(TbContext ctx, TbNodeConfiguration configuration) throws TbNodeException { - this.config = TbNodeUtils.convert(configuration, TbDeleteKeysNodeConfiguration.class); - this.deleteFrom = config.getDeleteFrom(); + var config = TbNodeUtils.convert(configuration, TbDeleteKeysNodeConfiguration.class); + deleteFrom = config.getDeleteFrom(); if (deleteFrom == null) { throw new TbNodeException("DeleteFrom can't be null! Allowed values: " + Arrays.toString(TbMsgSource.values())); } - this.compiledKeyPatterns = config.getKeys().stream().map(Pattern::compile).collect(Collectors.toList()); + compiledKeyPatterns = config.getKeys().stream().map(Pattern::compile).collect(Collectors.toList()); } @Override @@ -76,7 +76,7 @@ public class TbDeleteKeysNode extends TbAbstractTransformNodeWithTbMsgSource { var mdKeysToDelete = metaDataMap.keySet() .stream() .filter(this::matches) - .collect(Collectors.toList()); + .toList(); mdKeysToDelete.forEach(metaDataMap::remove); metaDataCopy = new TbMsgMetaData(metaDataMap); hasNoChanges = mdKeysToDelete.isEmpty(); diff --git a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/transform/TbJsonPathNode.java b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/transform/TbJsonPathNode.java index eb6c0b7a68..717bdf70f2 100644 --- a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/transform/TbJsonPathNode.java +++ b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/transform/TbJsonPathNode.java @@ -19,7 +19,6 @@ import com.jayway.jsonpath.Configuration; import com.jayway.jsonpath.JsonPath; import com.jayway.jsonpath.PathNotFoundException; import com.jayway.jsonpath.spi.json.JacksonJsonNodeJsonProvider; -import lombok.extern.slf4j.Slf4j; import org.thingsboard.common.util.JacksonUtil; import org.thingsboard.rule.engine.api.RuleNode; import org.thingsboard.rule.engine.api.TbContext; @@ -32,7 +31,6 @@ import org.thingsboard.server.common.msg.TbMsg; import java.util.concurrent.ExecutionException; -@Slf4j @RuleNode( type = ComponentType.TRANSFORMATION, name = "json path", @@ -41,32 +39,32 @@ import java.util.concurrent.ExecutionException; nodeDetails = "JSONPath expression specifies a path to an element or a set of elements in a JSON structure.

    " + "Output connections: Success, Failure.", icon = "functions", - configDirective = "tbTransformationNodeJsonPathConfig" + configDirective = "tbTransformationNodeJsonPathConfig", + docUrl = "https://thingsboard.io/docs/user-guide/rule-engine-2-0/nodes/transformation/json-path/" ) public class TbJsonPathNode implements TbNode { - private TbJsonPathNodeConfiguration config; private Configuration configurationJsonPath; private JsonPath jsonPath; private String jsonPathValue; @Override public void init(TbContext ctx, TbNodeConfiguration configuration) throws TbNodeException { - this.config = TbNodeUtils.convert(configuration, TbJsonPathNodeConfiguration.class); - this.jsonPathValue = config.getJsonPath(); - if (!TbJsonPathNodeConfiguration.DEFAULT_JSON_PATH.equals(this.jsonPathValue)) { - this.configurationJsonPath = Configuration.builder() + var config = TbNodeUtils.convert(configuration, TbJsonPathNodeConfiguration.class); + jsonPathValue = config.getJsonPath(); + if (!TbJsonPathNodeConfiguration.DEFAULT_JSON_PATH.equals(jsonPathValue)) { + configurationJsonPath = Configuration.builder() .jsonProvider(new JacksonJsonNodeJsonProvider()) .build(); - this.jsonPath = JsonPath.compile(config.getJsonPath()); + jsonPath = JsonPath.compile(config.getJsonPath()); } } @Override public void onMsg(TbContext ctx, TbMsg msg) throws ExecutionException, InterruptedException, TbNodeException { - if (!TbJsonPathNodeConfiguration.DEFAULT_JSON_PATH.equals(this.jsonPathValue)) { + if (!TbJsonPathNodeConfiguration.DEFAULT_JSON_PATH.equals(jsonPathValue)) { try { - Object jsonPathData = jsonPath.read(msg.getData(), this.configurationJsonPath); + Object jsonPathData = jsonPath.read(msg.getData(), configurationJsonPath); ctx.tellSuccess(msg.transform() .data(JacksonUtil.toString(jsonPathData)) .build()); @@ -77,4 +75,5 @@ public class TbJsonPathNode implements TbNode { ctx.tellSuccess(msg); } } + } diff --git a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/transform/TbRenameKeysNode.java b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/transform/TbRenameKeysNode.java index f2865bfc0b..9966ff6bb4 100644 --- a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/transform/TbRenameKeysNode.java +++ b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/transform/TbRenameKeysNode.java @@ -44,19 +44,19 @@ import java.util.concurrent.ExecutionException; "If key to rename doesn't exist in the specified source (message or message metadata) it will be ignored.

    " + "Output connections: Success, Failure.", configDirective = "tbTransformationNodeRenameKeysConfig", - icon = "find_replace" + icon = "find_replace", + docUrl = "https://thingsboard.io/docs/user-guide/rule-engine-2-0/nodes/transformation/rename-keys/" ) public class TbRenameKeysNode extends TbAbstractTransformNodeWithTbMsgSource { - private TbRenameKeysNodeConfiguration config; private Map renameKeysMapping; private TbMsgSource renameIn; @Override public void init(TbContext ctx, TbNodeConfiguration configuration) throws TbNodeException { - this.config = TbNodeUtils.convert(configuration, TbRenameKeysNodeConfiguration.class); - this.renameIn = config.getRenameIn(); - this.renameKeysMapping = config.getRenameKeysMapping(); + var config = TbNodeUtils.convert(configuration, TbRenameKeysNodeConfiguration.class); + renameIn = config.getRenameIn(); + renameKeysMapping = config.getRenameKeysMapping(); if (renameIn == null) { throw new TbNodeException("RenameIn can't be null! Allowed values: " + Arrays.toString(TbMsgSource.values())); } diff --git a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/transform/TbSplitArrayMsgNode.java b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/transform/TbSplitArrayMsgNode.java index 0eac8b073d..a1d94782fc 100644 --- a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/transform/TbSplitArrayMsgNode.java +++ b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/transform/TbSplitArrayMsgNode.java @@ -17,7 +17,6 @@ package org.thingsboard.rule.engine.transform; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.node.ArrayNode; -import lombok.extern.slf4j.Slf4j; import org.thingsboard.common.util.JacksonUtil; import org.thingsboard.rule.engine.api.EmptyNodeConfiguration; import org.thingsboard.rule.engine.api.RuleNode; @@ -25,7 +24,6 @@ import org.thingsboard.rule.engine.api.TbContext; import org.thingsboard.rule.engine.api.TbNode; import org.thingsboard.rule.engine.api.TbNodeConfiguration; import org.thingsboard.rule.engine.api.TbNodeException; -import org.thingsboard.rule.engine.api.util.TbNodeUtils; import org.thingsboard.server.common.data.msg.TbNodeConnectionType; import org.thingsboard.server.common.data.plugin.ComponentType; import org.thingsboard.server.common.msg.TbMsg; @@ -34,7 +32,6 @@ import org.thingsboard.server.common.msg.queue.TbMsgCallback; import java.util.concurrent.ExecutionException; -@Slf4j @RuleNode( type = ComponentType.TRANSFORMATION, name = "split array msg", @@ -44,16 +41,13 @@ import java.util.concurrent.ExecutionException; "All outbound messages will have the same type and metadata as the original array message.

    " + "Output connections: Success, Failure.", icon = "content_copy", - configDirective = "tbNodeEmptyConfig" + configDirective = "tbNodeEmptyConfig", + docUrl = "https://thingsboard.io/docs/user-guide/rule-engine-2-0/nodes/transformation/split-array-msg/" ) public class TbSplitArrayMsgNode implements TbNode { - private EmptyNodeConfiguration config; - @Override - public void init(TbContext ctx, TbNodeConfiguration configuration) throws TbNodeException { - this.config = TbNodeUtils.convert(configuration, EmptyNodeConfiguration.class); - } + public void init(TbContext ctx, TbNodeConfiguration configuration) {} @Override public void onMsg(TbContext ctx, TbMsg msg) throws ExecutionException, InterruptedException, TbNodeException { @@ -89,4 +83,5 @@ public class TbSplitArrayMsgNode implements TbNode { ctx.tellFailure(msg, new RuntimeException("Msg data is not a JSON Array!")); } } + } diff --git a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/transform/TbTransformMsgNode.java b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/transform/TbTransformMsgNode.java index 9373149cf5..487d92a2b0 100644 --- a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/transform/TbTransformMsgNode.java +++ b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/transform/TbTransformMsgNode.java @@ -41,7 +41,8 @@ import java.util.List; "{ msg: new payload,
       metadata: new metadata,
       msgType: new msgType }

    " + "All fields in resulting object are optional and will be taken from original message if not specified.

    " + "Output connections: Success, Failure.", - configDirective = "tbTransformationNodeScriptConfig" + configDirective = "tbTransformationNodeScriptConfig", + docUrl = "https://thingsboard.io/docs/user-guide/rule-engine-2-0/nodes/transformation/script/" ) public class TbTransformMsgNode extends TbAbstractTransformNode { @@ -71,4 +72,5 @@ public class TbTransformMsgNode extends TbAbstractTransformNode entityStates; + private ConcurrentMap entityStates; private boolean msgInside; private boolean reportPresenceStatusOnEachMessage; @@ -33,7 +33,8 @@ public class GpsGeofencingActionTestCase { this.entityId = entityId; this.msgInside = msgInside; this.reportPresenceStatusOnEachMessage = reportPresenceStatusOnEachMessage; - this.entityStates = new HashMap<>(); + this.entityStates = new ConcurrentHashMap<>(); this.entityStates.put(entityId, entityGeofencingState); } + } diff --git a/ui-ngx/src/app/shared/models/constants.ts b/ui-ngx/src/app/shared/models/constants.ts index 76a501cc63..34deb3a7d5 100644 --- a/ui-ngx/src/app/shared/models/constants.ts +++ b/ui-ngx/src/app/shared/models/constants.ts @@ -94,76 +94,83 @@ export const HelpLinks = { oauth2Apple: 'https://developer.apple.com/sign-in-with-apple/get-started/', oauth2Facebook: 'https://developers.facebook.com/docs/facebook-login/web#logindialog', oauth2Github: 'https://docs.github.com/en/apps/oauth-apps/building-oauth-apps/creating-an-oauth-app', - oauth2Google: 'https://developers.google.com/google-ads/api/docs/start', + oauth2Google: 'https://developers.google.com/identity/protocols/oauth2', ruleEngine: `${helpBaseUrl}/docs${docPlatformPrefix}/user-guide/rule-engine-2-0/overview/`, - ruleNodeCheckRelation: `${helpBaseUrl}/docs${docPlatformPrefix}/user-guide/rule-engine-2-0/filter-nodes/#check-relation-filter-node`, - ruleNodeCheckExistenceFields: `${helpBaseUrl}/docs${docPlatformPrefix}/user-guide/rule-engine-2-0/filter-nodes/#check-existence-fields-node`, - ruleNodeGpsGeofencingFilter: `${helpBaseUrl}/docs${docPlatformPrefix}/user-guide/rule-engine-2-0/filter-nodes/#gps-geofencing-filter-node`, - ruleNodeJsFilter: `${helpBaseUrl}/docs${docPlatformPrefix}/user-guide/rule-engine-2-0/filter-nodes/#script-filter-node`, - ruleNodeJsSwitch: `${helpBaseUrl}/docs${docPlatformPrefix}/user-guide/rule-engine-2-0/filter-nodes/#switch-node`, - ruleNodeAssetProfileSwitch: `${helpBaseUrl}/docs${docPlatformPrefix}/user-guide/rule-engine-2-0/filter-nodes/#asset-profile-switch`, - ruleNodeDeviceProfileSwitch: `${helpBaseUrl}/docs${docPlatformPrefix}/user-guide/rule-engine-2-0/filter-nodes/#device-profile-switch`, - ruleNodeCheckAlarmStatus: `${helpBaseUrl}/docs${docPlatformPrefix}/user-guide/rule-engine-2-0/filter-nodes/#check-alarm-status`, - ruleNodeMessageTypeFilter: `${helpBaseUrl}/docs${docPlatformPrefix}/user-guide/rule-engine-2-0/filter-nodes/#message-type-filter-node`, - ruleNodeMessageTypeSwitch: `${helpBaseUrl}/docs${docPlatformPrefix}/user-guide/rule-engine-2-0/filter-nodes/#message-type-switch-node`, - ruleNodeOriginatorTypeFilter: `${helpBaseUrl}/docs${docPlatformPrefix}/user-guide/rule-engine-2-0/filter-nodes/#originator-type-filter-node`, - ruleNodeOriginatorTypeSwitch: `${helpBaseUrl}/docs${docPlatformPrefix}/user-guide/rule-engine-2-0/filter-nodes/#originator-type-switch-node`, - ruleNodeOriginatorAttributes: `${helpBaseUrl}/docs${docPlatformPrefix}/user-guide/rule-engine-2-0/enrichment-nodes/#originator-attributes`, - ruleNodeOriginatorFields: `${helpBaseUrl}/docs${docPlatformPrefix}/user-guide/rule-engine-2-0/enrichment-nodes/#originator-fields`, - ruleNodeOriginatorTelemetry: `${helpBaseUrl}/docs${docPlatformPrefix}/user-guide/rule-engine-2-0/enrichment-nodes/#originator-telemetry`, - ruleNodeCustomerAttributes: `${helpBaseUrl}/docs${docPlatformPrefix}/user-guide/rule-engine-2-0/enrichment-nodes/#customer-attributes`, - ruleNodeCustomerDetails: `${helpBaseUrl}/docs${docPlatformPrefix}/user-guide/rule-engine-2-0/enrichment-nodes/#customer-details`, - ruleNodeFetchDeviceCredentials: `${helpBaseUrl}/docs${docPlatformPrefix}/user-guide/rule-engine-2-0/enrichment-nodes/#fetch-device-credentials`, - ruleNodeDeviceAttributes: `${helpBaseUrl}/docs${docPlatformPrefix}/user-guide/rule-engine-2-0/enrichment-nodes/#device-attributes`, - ruleNodeRelatedAttributes: `${helpBaseUrl}/docs${docPlatformPrefix}/user-guide/rule-engine-2-0/enrichment-nodes/#related-attributes`, - ruleNodeTenantAttributes: `${helpBaseUrl}/docs${docPlatformPrefix}/user-guide/rule-engine-2-0/enrichment-nodes/#tenant-attributes`, - ruleNodeTenantDetails: `${helpBaseUrl}/docs${docPlatformPrefix}/user-guide/rule-engine-2-0/enrichment-nodes/#tenant-details`, - ruleNodeChangeOriginator: `${helpBaseUrl}/docs${docPlatformPrefix}/user-guide/rule-engine-2-0/transformation-nodes/#change-originator`, - ruleNodeTransformMsg: `${helpBaseUrl}/docs${docPlatformPrefix}/user-guide/rule-engine-2-0/transformation-nodes/#script-transformation-node`, - ruleNodeMsgToEmail: `${helpBaseUrl}/docs${docPlatformPrefix}/user-guide/rule-engine-2-0/transformation-nodes/#to-email-node`, - ruleNodeAssignToCustomer: `${helpBaseUrl}/docs${docPlatformPrefix}/user-guide/rule-engine-2-0/action-nodes/#assign-to-customer-node`, - ruleNodeUnassignFromCustomer: `${helpBaseUrl}/docs${docPlatformPrefix}/user-guide/rule-engine-2-0/action-nodes/#unassign-from-customer-node`, - ruleNodeCalculatedFields: `${helpBaseUrl}/docs${docPlatformPrefix}/user-guide/rule-engine-2-0/action-nodes/#calculated-fields-node`, - ruleNodeClearAlarm: `${helpBaseUrl}/docs${docPlatformPrefix}/user-guide/rule-engine-2-0/action-nodes/#clear-alarm-node`, - ruleNodeCreateAlarm: `${helpBaseUrl}/docs${docPlatformPrefix}/user-guide/rule-engine-2-0/action-nodes/#create-alarm-node`, - ruleNodeCopyToView: `${helpBaseUrl}/docs${docPlatformPrefix}/user-guide/rule-engine-2-0/action-nodes/#copy-to-view-node`, - ruleNodeCreateRelation: `${helpBaseUrl}/docs${docPlatformPrefix}/user-guide/rule-engine-2-0/action-nodes/#create-relation-node`, - ruleNodeDeleteRelation: `${helpBaseUrl}/docs${docPlatformPrefix}/user-guide/rule-engine-2-0/action-nodes/#delete-relation-node`, - ruleNodeDeviceState: `${helpBaseUrl}/docs${docPlatformPrefix}/user-guide/rule-engine-2-0/action-nodes/#device-state-node`, - ruleNodeMessageCount: `${helpBaseUrl}/docs${docPlatformPrefix}/user-guide/rule-engine-2-0/action-nodes/#message-count-node`, - ruleNodeMsgDelay: `${helpBaseUrl}/docs${docPlatformPrefix}/user-guide/rule-engine-2-0/action-nodes/#delay-node-deprecated`, - ruleNodeMsgGenerator: `${helpBaseUrl}/docs${docPlatformPrefix}/user-guide/rule-engine-2-0/action-nodes/#generator-node`, - ruleNodeGpsGeofencingEvents: `${helpBaseUrl}/docs${docPlatformPrefix}/user-guide/rule-engine-2-0/action-nodes/#gps-geofencing-events-node`, - ruleNodeLog: `${helpBaseUrl}/docs${docPlatformPrefix}/user-guide/rule-engine-2-0/action-nodes/#log-node`, - ruleNodeRpcCallReply: `${helpBaseUrl}/docs${docPlatformPrefix}/user-guide/rule-engine-2-0/action-nodes/#rpc-call-reply-node`, - ruleNodeRpcCallRequest: `${helpBaseUrl}/docs${docPlatformPrefix}/user-guide/rule-engine-2-0/action-nodes/#rpc-call-request-node`, - ruleNodeSaveAttributes: `${helpBaseUrl}/docs${docPlatformPrefix}/user-guide/rule-engine-2-0/action-nodes/#save-attributes-node`, - ruleNodeDeleteAttributes: `${helpBaseUrl}/docs${docPlatformPrefix}/user-guide/rule-engine-2-0/action-nodes/#delete-attributes-node`, - ruleNodeSaveTimeseries: `${helpBaseUrl}/docs${docPlatformPrefix}/user-guide/rule-engine-2-0/action-nodes/#save-timeseries-node`, - ruleNodeSaveToCustomTable: `${helpBaseUrl}/docs${docPlatformPrefix}/user-guide/rule-engine-2-0/action-nodes/#save-to-custom-table-node`, - ruleNodeRuleChain: `${helpBaseUrl}/docs${docPlatformPrefix}/user-guide/rule-engine-2-0/flow-nodes/#rule-chain-node`, - ruleNodeOutputNode: `${helpBaseUrl}/docs${docPlatformPrefix}/user-guide/rule-engine-2-0/flow-nodes/#output-node`, - ruleNodeAiRequest: `${helpBaseUrl}/docs${docPlatformPrefix}/user-guide/rule-engine-2-0/external-nodes/#ai-request-node`, - ruleNodeAwsLambda: `${helpBaseUrl}/docs${docPlatformPrefix}/user-guide/rule-engine-2-0/external-nodes/#aws-lambda-node`, - ruleNodeAwsSns: `${helpBaseUrl}/docs${docPlatformPrefix}/user-guide/rule-engine-2-0/external-nodes/#aws-sns-node`, - ruleNodeAwsSqs: `${helpBaseUrl}/docs${docPlatformPrefix}/user-guide/rule-engine-2-0/external-nodes/#aws-sqs-node`, - ruleNodeKafka: `${helpBaseUrl}/docs${docPlatformPrefix}/user-guide/rule-engine-2-0/external-nodes/#kafka-node`, - ruleNodeMqtt: `${helpBaseUrl}/docs${docPlatformPrefix}/user-guide/rule-engine-2-0/external-nodes/#mqtt-node`, - ruleNodeAzureIotHub: `${helpBaseUrl}/docs${docPlatformPrefix}/user-guide/rule-engine-2-0/external-nodes/#azure-iot-hub-node`, - ruleNodeRabbitMq: `${helpBaseUrl}/docs${docPlatformPrefix}/user-guide/rule-engine-2-0/external-nodes/#rabbitmq-node`, - ruleNodeRestApiCall: `${helpBaseUrl}/docs${docPlatformPrefix}/user-guide/rule-engine-2-0/external-nodes/#rest-api-call-node`, - ruleNodeSendEmail: `${helpBaseUrl}/docs${docPlatformPrefix}/user-guide/rule-engine-2-0/external-nodes/#send-email-node`, - ruleNodeSendSms: `${helpBaseUrl}/docs${docPlatformPrefix}/user-guide/rule-engine-2-0/external-nodes/#send-sms-node`, - ruleNodeMath: `${helpBaseUrl}/docs${docPlatformPrefix}/user-guide/rule-engine-2-0/action-nodes/#math-function-node`, - ruleNodeCalculateDelta: `${helpBaseUrl}/docs${docPlatformPrefix}/user-guide/rule-engine-2-0/enrichment-nodes/#calculate-delta`, - ruleNodeRestCallReply: `${helpBaseUrl}/docs${docPlatformPrefix}/user-guide/rule-engine-2-0/action-nodes/#rest-call-reply-node`, - ruleNodePushToCloud: `${helpBaseUrl}/docs${docPlatformPrefix}/user-guide/rule-engine-2-0/action-nodes/#push-to-cloud`, - ruleNodePushToEdge: `${helpBaseUrl}/docs${docPlatformPrefix}/user-guide/rule-engine-2-0/action-nodes/#push-to-edge`, - ruleNodeDeviceProfile: `${helpBaseUrl}/docs${docPlatformPrefix}/user-guide/rule-engine-2-0/action-nodes/#device-profile-node`, - ruleNodeAcknowledge: `${helpBaseUrl}/docs${docPlatformPrefix}/user-guide/rule-engine-2-0/flow-nodes/#acknowledge-node`, - ruleNodeCheckpoint: `${helpBaseUrl}/docs${docPlatformPrefix}/user-guide/rule-engine-2-0/flow-nodes/#checkpoint-node`, - ruleNodeSendNotification: `${helpBaseUrl}/docs${docPlatformPrefix}/user-guide/rule-engine-2-0/external-nodes/#send-notification-node`, - ruleNodeSendSlack: `${helpBaseUrl}/docs${docPlatformPrefix}/user-guide/rule-engine-2-0/external-nodes/#send-to-slack-node`, + ruleNodeCheckRelation: `${helpBaseUrl}/docs/user-guide/rule-engine-2-0/nodes/filter/check-relation-presence/`, + ruleNodeCheckExistenceFields: `${helpBaseUrl}/docs/user-guide/rule-engine-2-0/nodes/filter/check-fields-presence/`, + ruleNodeGpsGeofencingFilter: `${helpBaseUrl}/docs/user-guide/rule-engine-2-0/nodes/filter/gps-geofencing-filter/`, + ruleNodeJsFilter: `${helpBaseUrl}/docs/user-guide/rule-engine-2-0/nodes/filter/script/`, + ruleNodeJsSwitch: `${helpBaseUrl}/docs/user-guide/rule-engine-2-0/nodes/filter/switch/`, + ruleNodeAssetProfileSwitch: `${helpBaseUrl}/docs/user-guide/rule-engine-2-0/nodes/filter/asset-profile-switch/`, + ruleNodeDeviceProfileSwitch: `${helpBaseUrl}/docs/user-guide/rule-engine-2-0/nodes/filter/device-profile-switch/`, + ruleNodeCheckAlarmStatus: `${helpBaseUrl}/docs/user-guide/rule-engine-2-0/nodes/filter/alarm-status-filter/`, + ruleNodeMessageTypeFilter: `${helpBaseUrl}/docs/user-guide/rule-engine-2-0/nodes/filter/message-type-filter/`, + ruleNodeMessageTypeSwitch: `${helpBaseUrl}/docs/user-guide/rule-engine-2-0/nodes/filter/message-type-switch/`, + ruleNodeOriginatorTypeFilter: `${helpBaseUrl}/docs/user-guide/rule-engine-2-0/nodes/filter/entity-type-filter/`, + ruleNodeOriginatorTypeSwitch: `${helpBaseUrl}/docs/user-guide/rule-engine-2-0/nodes/filter/entity-type-switch/`, + ruleNodeOriginatorAttributes: `${helpBaseUrl}/docs/user-guide/rule-engine-2-0/nodes/enrichment/originator-attributes/`, + ruleNodeOriginatorFields: `${helpBaseUrl}/docs/user-guide/rule-engine-2-0/nodes/enrichment/originator-fields/`, + ruleNodeOriginatorTelemetry: `${helpBaseUrl}/docs/user-guide/rule-engine-2-0/nodes/enrichment/originator-telemetry/`, + ruleNodeCustomerAttributes: `${helpBaseUrl}/docs/user-guide/rule-engine-2-0/nodes/enrichment/customer-attributes/`, + ruleNodeCustomerDetails: `${helpBaseUrl}/docs/user-guide/rule-engine-2-0/nodes/enrichment/customer-details/`, + ruleNodeFetchDeviceCredentials: `${helpBaseUrl}/docs/user-guide/rule-engine-2-0/nodes/enrichment/fetch-device-credentials/`, + ruleNodeDeviceAttributes: `${helpBaseUrl}/docs/user-guide/rule-engine-2-0/nodes/enrichment/related-device-attributes/`, + ruleNodeRelatedAttributes: `${helpBaseUrl}/docs/user-guide/rule-engine-2-0/nodes/enrichment/related-entity-data/`, + ruleNodeTenantAttributes: `${helpBaseUrl}/docs/user-guide/rule-engine-2-0/nodes/enrichment/tenant-attributes/`, + ruleNodeTenantDetails: `${helpBaseUrl}/docs/user-guide/rule-engine-2-0/nodes/enrichment/tenant-details/`, + ruleNodeChangeOriginator: `${helpBaseUrl}/docs/user-guide/rule-engine-2-0/nodes/transformation/change-originator/`, + ruleNodeCopyKeyValuePairs: `${helpBaseUrl}/docs/user-guide/rule-engine-2-0/nodes/transformation/copy-key-value-pairs/`, + ruleNodeDeduplication: `${helpBaseUrl}/docs/user-guide/rule-engine-2-0/nodes/transformation/deduplication/`, + ruleNodeDeleteKeyValuePairs: `${helpBaseUrl}/docs/user-guide/rule-engine-2-0/nodes/transformation/delete-key-value-pairs/`, + ruleNodeJsonPath: `${helpBaseUrl}/docs/user-guide/rule-engine-2-0/nodes/transformation/json-path/`, + ruleNodeRenameKeys: `${helpBaseUrl}/docs/user-guide/rule-engine-2-0/nodes/transformation/rename-keys/`, + ruleNodeTransformMsg: `${helpBaseUrl}/docs/user-guide/rule-engine-2-0/nodes/transformation/script/`, + ruleNodeSplitArrayMsg: `${helpBaseUrl}/docs/user-guide/rule-engine-2-0/nodes/transformation/split-array-msg/`, + ruleNodeMsgToEmail: `${helpBaseUrl}/docs/user-guide/rule-engine-2-0/nodes/transformation/to-email/`, + ruleNodeAssignToCustomer: `${helpBaseUrl}/docs/user-guide/rule-engine-2-0/nodes/action/assign-to-customer/`, + ruleNodeUnassignFromCustomer: `${helpBaseUrl}/docs/user-guide/rule-engine-2-0/nodes/action/unassign-from-customer/`, + ruleNodeCalculatedFields: `${helpBaseUrl}/docs/user-guide/rule-engine-2-0/nodes/action/calculated-fields/`, + ruleNodeClearAlarm: `${helpBaseUrl}/docs/user-guide/rule-engine-2-0/nodes/action/clear-alarm/`, + ruleNodeCreateAlarm: `${helpBaseUrl}/docs/user-guide/rule-engine-2-0/nodes/action/create-alarm/`, + ruleNodeCopyToView: `${helpBaseUrl}/docs/user-guide/rule-engine-2-0/nodes/action/copy-to-view/`, + ruleNodeCreateRelation: `${helpBaseUrl}/docs/user-guide/rule-engine-2-0/nodes/action/create-relation/`, + ruleNodeDeleteRelation: `${helpBaseUrl}/docs/user-guide/rule-engine-2-0/nodes/action/delete-relation/`, + ruleNodeDeviceState: `${helpBaseUrl}/docs/user-guide/rule-engine-2-0/nodes/action/device-state/`, + ruleNodeMessageCount: `${helpBaseUrl}/docs/user-guide/rule-engine-2-0/nodes/action/message-count/`, + ruleNodeMsgDelay: `${helpBaseUrl}/docs/user-guide/rule-engine-2-0/nodes/action/delay/`, + ruleNodeMsgGenerator: `${helpBaseUrl}/docs/user-guide/rule-engine-2-0/nodes/action/generator/`, + ruleNodeGpsGeofencingEvents: `${helpBaseUrl}/docs/user-guide/rule-engine-2-0/nodes/action/gps-geofencing-events/`, + ruleNodeLog: `${helpBaseUrl}/docs/user-guide/rule-engine-2-0/nodes/action/log/`, + ruleNodeRpcCallReply: `${helpBaseUrl}/docs/user-guide/rule-engine-2-0/nodes/action/rpc-call-reply/`, + ruleNodeRpcCallRequest: `${helpBaseUrl}/docs/user-guide/rule-engine-2-0/nodes/action/rpc-call-request/`, + ruleNodeSaveAttributes: `${helpBaseUrl}/docs/user-guide/rule-engine-2-0/nodes/action/save-attributes/`, + ruleNodeDeleteAttributes: `${helpBaseUrl}/docs/user-guide/rule-engine-2-0/nodes/action/delete-attributes/`, + ruleNodeSaveTimeseries: `${helpBaseUrl}/docs/user-guide/rule-engine-2-0/nodes/action/save-timeseries/`, + ruleNodeSaveToCustomTable: `${helpBaseUrl}/docs/user-guide/rule-engine-2-0/nodes/action/save-to-custom-table/`, + ruleNodeRuleChain: `${helpBaseUrl}/docs/user-guide/rule-engine-2-0/nodes/flow/rule-chain/`, + ruleNodeOutputNode: `${helpBaseUrl}/docs/user-guide/rule-engine-2-0/nodes/flow/output/`, + ruleNodeAiRequest: `${helpBaseUrl}/docs/user-guide/rule-engine-2-0/nodes/external/ai-request/`, + ruleNodeAwsLambda: `${helpBaseUrl}/docs/user-guide/rule-engine-2-0/nodes/external/aws-lambda/`, + ruleNodeAwsSns: `${helpBaseUrl}/docs/user-guide/rule-engine-2-0/nodes/external/aws-sns/`, + ruleNodeAwsSqs: `${helpBaseUrl}docs/user-guide/rule-engine-2-0/nodes/external/aws-sqs/`, + ruleNodeKafka: `${helpBaseUrl}/docs/user-guide/rule-engine-2-0/nodes/external/kafka/`, + ruleNodeMqtt: `${helpBaseUrl}/docs/user-guide/rule-engine-2-0/nodes/external/mqtt/`, + ruleNodeAzureIotHub: `${helpBaseUrl}/docs/user-guide/rule-engine-2-0/nodes/external/azure-iot-hub/`, + ruleNodeGcpPubSub: `${helpBaseUrl}/docs/user-guide/rule-engine-2-0/nodes/external/gcp-pubsub/`, + ruleNodeRabbitMq: `${helpBaseUrl}/docs/user-guide/rule-engine-2-0/nodes/external/rabbitmq/`, + ruleNodeRestApiCall: `${helpBaseUrl}/docs/user-guide/rule-engine-2-0/nodes/external/rest-api-call/`, + ruleNodeSendEmail: `${helpBaseUrl}/docs/user-guide/rule-engine-2-0/nodes/external/send-email/`, + ruleNodeSendSms: `${helpBaseUrl}/docs/user-guide/rule-engine-2-0/nodes/external/send-sms/`, + ruleNodeMath: `${helpBaseUrl}/docs/user-guide/rule-engine-2-0/nodes/action/math-function/`, + ruleNodeCalculateDelta: `${helpBaseUrl}/docs/user-guide/rule-engine-2-0/nodes/enrichment/calculate-delta/`, + ruleNodeRestCallReply: `${helpBaseUrl}/docs/user-guide/rule-engine-2-0/nodes/action/rest-call-reply/`, + ruleNodePushToCloud: `${helpBaseUrl}/docs/user-guide/rule-engine-2-0/nodes/action/push-to-cloud/`, + ruleNodePushToEdge: `${helpBaseUrl}/docs/user-guide/rule-engine-2-0/nodes/action/push-to-edge/`, + ruleNodeDeviceProfile: `${helpBaseUrl}/docs/user-guide/rule-engine-2-0/nodes/action/device-profile/`, + ruleNodeAcknowledge: `${helpBaseUrl}/docs/user-guide/rule-engine-2-0/nodes/flow/acknowledge/`, + ruleNodeCheckpoint: `${helpBaseUrl}/docs/user-guide/rule-engine-2-0/nodes/flow/checkpoint/`, + ruleNodeSendNotification: `${helpBaseUrl}/docs/user-guide/rule-engine-2-0/nodes/external/send-notification/`, + ruleNodeSendSlack: `${helpBaseUrl}docs/user-guide/rule-engine-2-0/nodes/external/send-to-slack/`, tenants: `${helpBaseUrl}/docs${docPlatformPrefix}/user-guide/ui/tenants`, tenantProfiles: `${helpBaseUrl}/docs${docPlatformPrefix}/user-guide/ui/tenant-profiles`, customers: `${helpBaseUrl}/docs${docPlatformPrefix}/user-guide/ui/customers`, diff --git a/ui-ngx/src/app/shared/models/rule-node.models.ts b/ui-ngx/src/app/shared/models/rule-node.models.ts index 8225fbf18f..786fd72448 100644 --- a/ui-ngx/src/app/shared/models/rule-node.models.ts +++ b/ui-ngx/src/app/shared/models/rule-node.models.ts @@ -476,7 +476,13 @@ const ruleNodeClazzHelpLinkMap = { 'org.thingsboard.rule.engine.metadata.TbGetTenantDetailsNode': 'ruleNodeTenantDetails', 'org.thingsboard.rule.engine.metadata.CalculateDeltaNode': 'ruleNodeCalculateDelta', 'org.thingsboard.rule.engine.transform.TbChangeOriginatorNode': 'ruleNodeChangeOriginator', + 'org.thingsboard.rule.engine.transform.TbCopyKeysNode': 'ruleNodeCopyKeyValuePairs', + 'org.thingsboard.rule.engine.deduplication.TbMsgDeduplicationNode': 'ruleNodeDeduplication', + 'org.thingsboard.rule.engine.transform.TbDeleteKeysNode': 'ruleNodeDeleteKeyValuePairs', + 'org.thingsboard.rule.engine.transform.TbJsonPathNode': 'ruleNodeJsonPath', + 'org.thingsboard.rule.engine.transform.TbRenameKeysNode': 'ruleNodeRenameKeys', 'org.thingsboard.rule.engine.transform.TbTransformMsgNode': 'ruleNodeTransformMsg', + 'org.thingsboard.rule.engine.transform.TbSplitArrayMsgNode': 'ruleNodeSplitArrayMsg', 'org.thingsboard.rule.engine.mail.TbMsgToEmailNode': 'ruleNodeMsgToEmail', 'org.thingsboard.rule.engine.action.TbAssignToCustomerNode': 'ruleNodeAssignToCustomer', 'org.thingsboard.rule.engine.action.TbUnassignFromCustomerNode': 'ruleNodeUnassignFromCustomer', @@ -505,6 +511,7 @@ const ruleNodeClazzHelpLinkMap = { 'org.thingsboard.rule.engine.kafka.TbKafkaNode': 'ruleNodeKafka', 'org.thingsboard.rule.engine.mqtt.TbMqttNode': 'ruleNodeMqtt', 'org.thingsboard.rule.engine.mqtt.azure.TbAzureIotHubNode': 'ruleNodeAzureIotHub', + 'org.thingsboard.rule.engine.gcp.pubsub.TbPubSubNode': 'ruleNodeGcpPubSub', 'org.thingsboard.rule.engine.rabbitmq.TbRabbitMqNode': 'ruleNodeRabbitMq', 'org.thingsboard.rule.engine.rest.TbRestApiCallNode': 'ruleNodeRestApiCall', 'org.thingsboard.rule.engine.mail.TbSendEmailNode': 'ruleNodeSendEmail', From e5c595ee3935344bccfe3774ecebe381480d74bd Mon Sep 17 00:00:00 2001 From: Dmytro Skarzhynets Date: Mon, 6 Oct 2025 20:21:25 +0300 Subject: [PATCH 071/102] Fix links for "send to slack" and "aws sqs" nodes --- ui-ngx/src/app/shared/models/constants.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/ui-ngx/src/app/shared/models/constants.ts b/ui-ngx/src/app/shared/models/constants.ts index 34deb3a7d5..6047384ec3 100644 --- a/ui-ngx/src/app/shared/models/constants.ts +++ b/ui-ngx/src/app/shared/models/constants.ts @@ -152,7 +152,7 @@ export const HelpLinks = { ruleNodeAiRequest: `${helpBaseUrl}/docs/user-guide/rule-engine-2-0/nodes/external/ai-request/`, ruleNodeAwsLambda: `${helpBaseUrl}/docs/user-guide/rule-engine-2-0/nodes/external/aws-lambda/`, ruleNodeAwsSns: `${helpBaseUrl}/docs/user-guide/rule-engine-2-0/nodes/external/aws-sns/`, - ruleNodeAwsSqs: `${helpBaseUrl}docs/user-guide/rule-engine-2-0/nodes/external/aws-sqs/`, + ruleNodeAwsSqs: `${helpBaseUrl}/docs/user-guide/rule-engine-2-0/nodes/external/aws-sqs/`, ruleNodeKafka: `${helpBaseUrl}/docs/user-guide/rule-engine-2-0/nodes/external/kafka/`, ruleNodeMqtt: `${helpBaseUrl}/docs/user-guide/rule-engine-2-0/nodes/external/mqtt/`, ruleNodeAzureIotHub: `${helpBaseUrl}/docs/user-guide/rule-engine-2-0/nodes/external/azure-iot-hub/`, @@ -170,7 +170,7 @@ export const HelpLinks = { ruleNodeAcknowledge: `${helpBaseUrl}/docs/user-guide/rule-engine-2-0/nodes/flow/acknowledge/`, ruleNodeCheckpoint: `${helpBaseUrl}/docs/user-guide/rule-engine-2-0/nodes/flow/checkpoint/`, ruleNodeSendNotification: `${helpBaseUrl}/docs/user-guide/rule-engine-2-0/nodes/external/send-notification/`, - ruleNodeSendSlack: `${helpBaseUrl}docs/user-guide/rule-engine-2-0/nodes/external/send-to-slack/`, + ruleNodeSendSlack: `${helpBaseUrl}/docs/user-guide/rule-engine-2-0/nodes/external/send-to-slack/`, tenants: `${helpBaseUrl}/docs${docPlatformPrefix}/user-guide/ui/tenants`, tenantProfiles: `${helpBaseUrl}/docs${docPlatformPrefix}/user-guide/ui/tenant-profiles`, customers: `${helpBaseUrl}/docs${docPlatformPrefix}/user-guide/ui/customers`, From b3147e82192a9ddb03b4c32264aa744e151e5142 Mon Sep 17 00:00:00 2001 From: dashevchenko Date: Tue, 7 Oct 2025 11:30:14 +0300 Subject: [PATCH 072/102] name conflict strategy: initial implementation --- .../server/controller/AssetController.java | 9 ++++-- .../server/controller/CustomerController.java | 7 ++++- .../server/controller/DeviceController.java | 18 ++++++++--- .../controller/EntityViewController.java | 9 ++++-- .../server/controller/Lwm2mController.java | 3 +- .../device/DeviceBulkImportService.java | 3 +- .../entitiy/asset/DefaultTbAssetService.java | 5 ++-- .../service/entitiy/asset/TbAssetService.java | 3 +- .../device/DefaultTbDeviceService.java | 9 +++--- .../entitiy/device/TbDeviceService.java | 5 ++-- .../DefaultTbEntityViewService.java | 5 ++-- .../entityview/TbEntityViewService.java | 3 +- .../server/dao/asset/AssetService.java | 6 ++++ .../server/dao/device/DeviceService.java | 5 ++++ .../dao/entityview/EntityViewService.java | 3 ++ .../common/data/NameConflictStrategy.java | 23 ++++++++++++++ .../java/org/thingsboard/server/dao/Dao.java | 3 ++ .../server/dao/asset/AssetDao.java | 2 ++ .../server/dao/asset/BaseAssetService.java | 13 ++++++++ .../server/dao/device/DeviceDao.java | 2 ++ .../server/dao/device/DeviceServiceImpl.java | 28 +++++++++++++++-- .../dao/entity/AbstractEntityService.java | 30 +++++++++++++++++++ .../dao/entityview/EntityViewServiceImpl.java | 13 ++++++++ .../server/dao/sql/asset/AssetRepository.java | 5 ++++ .../server/dao/sql/asset/JpaAssetDao.java | 7 +++++ .../dao/sql/customer/CustomerRepository.java | 5 ++++ .../dao/sql/device/DeviceRepository.java | 5 ++++ .../server/dao/sql/device/JpaDeviceDao.java | 6 ++++ .../sql/entityview/EntityViewRepository.java | 5 ++++ 29 files changed, 214 insertions(+), 26 deletions(-) create mode 100644 common/data/src/main/java/org/thingsboard/server/common/data/NameConflictStrategy.java diff --git a/application/src/main/java/org/thingsboard/server/controller/AssetController.java b/application/src/main/java/org/thingsboard/server/controller/AssetController.java index 2a4cce143a..9cade90860 100644 --- a/application/src/main/java/org/thingsboard/server/controller/AssetController.java +++ b/application/src/main/java/org/thingsboard/server/controller/AssetController.java @@ -34,6 +34,7 @@ import org.springframework.web.bind.annotation.ResponseStatus; import org.springframework.web.bind.annotation.RestController; import org.thingsboard.server.common.data.Customer; import org.thingsboard.server.common.data.EntitySubtype; +import org.thingsboard.server.common.data.NameConflictStrategy; import org.thingsboard.server.common.data.asset.Asset; import org.thingsboard.server.common.data.asset.AssetInfo; import org.thingsboard.server.common.data.asset.AssetSearchQuery; @@ -137,10 +138,14 @@ public class AssetController extends BaseController { @PreAuthorize("hasAnyAuthority('TENANT_ADMIN', 'CUSTOMER_USER')") @RequestMapping(value = "/asset", method = RequestMethod.POST) @ResponseBody - public Asset saveAsset(@io.swagger.v3.oas.annotations.parameters.RequestBody(description = "A JSON value representing the asset.") @RequestBody Asset asset) throws Exception { + public Asset saveAsset(@io.swagger.v3.oas.annotations.parameters.RequestBody(description = "A JSON value representing the asset.") @RequestBody Asset asset, + @Parameter(description = "Optional value of name conflict strategy. Possible values: FAIL or UNIQUIFY. " + + "If omitted, FAIL strategy is applied. FAIL strategy implies exception will be thrown if an entity with the same name already exists. " + + "UNIQUIFY strategy appends a numerical suffix to the entity name, if a name conflict occurs.") + @RequestParam(name = "nameConflictStrategy", defaultValue = "FAIL") NameConflictStrategy nameConflictStrategy) throws Exception { asset.setTenantId(getTenantId()); checkEntity(asset.getId(), asset, Resource.ASSET); - return tbAssetService.save(asset, getCurrentUser()); + return tbAssetService.save(asset, nameConflictStrategy, getCurrentUser()); } @ApiOperation(value = "Delete asset (deleteAsset)", diff --git a/application/src/main/java/org/thingsboard/server/controller/CustomerController.java b/application/src/main/java/org/thingsboard/server/controller/CustomerController.java index 03ac9c60fa..5c4acb6dbc 100644 --- a/application/src/main/java/org/thingsboard/server/controller/CustomerController.java +++ b/application/src/main/java/org/thingsboard/server/controller/CustomerController.java @@ -32,6 +32,7 @@ import org.springframework.web.bind.annotation.ResponseStatus; import org.springframework.web.bind.annotation.RestController; import org.thingsboard.common.util.JacksonUtil; import org.thingsboard.server.common.data.Customer; +import org.thingsboard.server.common.data.NameConflictStrategy; import org.thingsboard.server.common.data.exception.ThingsboardException; import org.thingsboard.server.common.data.id.CustomerId; import org.thingsboard.server.common.data.id.TenantId; @@ -128,7 +129,11 @@ public class CustomerController extends BaseController { @PreAuthorize("hasAuthority('TENANT_ADMIN')") @RequestMapping(value = "/customer", method = RequestMethod.POST) @ResponseBody - public Customer saveCustomer(@io.swagger.v3.oas.annotations.parameters.RequestBody(description = "A JSON value representing the customer.") @RequestBody Customer customer) throws Exception { + public Customer saveCustomer(@io.swagger.v3.oas.annotations.parameters.RequestBody(description = "A JSON value representing the customer.") @RequestBody Customer customer, + @Parameter(description = "Optional value of name conflict strategy. Possible values: FAIL or UNIQUIFY. " + + "If omitted, FAIL strategy is applied. FAIL strategy implies exception will be thrown if an entity with the same name already exists. " + + "UNIQUIFY strategy appends a numerical suffix to the entity name, if a name conflict occurs.") + @RequestParam(name = "nameConflictStrategy", defaultValue = "FAIL") NameConflictStrategy nameConflictStrategy) throws Exception { customer.setTenantId(getTenantId()); checkEntity(customer.getId(), customer, Resource.CUSTOMER); return tbCustomerService.save(customer, getCurrentUser()); diff --git a/application/src/main/java/org/thingsboard/server/controller/DeviceController.java b/application/src/main/java/org/thingsboard/server/controller/DeviceController.java index 61864dbf63..724e3a430c 100644 --- a/application/src/main/java/org/thingsboard/server/controller/DeviceController.java +++ b/application/src/main/java/org/thingsboard/server/controller/DeviceController.java @@ -46,6 +46,7 @@ import org.thingsboard.server.common.data.Device; import org.thingsboard.server.common.data.DeviceInfo; import org.thingsboard.server.common.data.DeviceInfoFilter; import org.thingsboard.server.common.data.EntitySubtype; +import org.thingsboard.server.common.data.NameConflictStrategy; import org.thingsboard.server.common.data.SaveDeviceWithCredentialsRequest; import org.thingsboard.server.common.data.Tenant; import org.thingsboard.server.common.data.device.DeviceSearchQuery; @@ -177,14 +178,19 @@ public class DeviceController extends BaseController { @ResponseBody public Device saveDevice(@io.swagger.v3.oas.annotations.parameters.RequestBody(description = "A JSON value representing the device.") @RequestBody Device device, @Parameter(description = "Optional value of the device credentials to be used during device creation. " + - "If omitted, access token will be auto-generated.") @RequestParam(name = "accessToken", required = false) String accessToken) throws Exception { + "If omitted, access token will be auto-generated.") + @RequestParam(name = "accessToken", required = false) String accessToken, + @Parameter(description = "Optional value of name conflict strategy. Possible values: FAIL or UNIQUIFY. " + + "If omitted, FAIL strategy is applied. FAIL strategy implies exception will be thrown if an entity with the same name already exists. " + + "UNIQUIFY strategy appends a numerical suffix to the entity name, if a name conflict occurs.") + @RequestParam(name = "nameConflictStrategy", defaultValue = "FAIL") NameConflictStrategy nameConflictStrategy) throws Exception { device.setTenantId(getCurrentUser().getTenantId()); if (device.getId() != null) { checkDeviceId(device.getId(), Operation.WRITE); } else { checkEntity(null, device, Resource.DEVICE); } - return tbDeviceService.save(device, accessToken, getCurrentUser()); + return tbDeviceService.save(device, accessToken, nameConflictStrategy, getCurrentUser()); } @ApiOperation(value = "Create Device (saveDevice) with credentials ", @@ -209,12 +215,16 @@ public class DeviceController extends BaseController { @RequestMapping(value = "/device-with-credentials", method = RequestMethod.POST) @ResponseBody public Device saveDeviceWithCredentials(@Parameter(description = "The JSON object with device and credentials. See method description above for example.") - @Valid @RequestBody SaveDeviceWithCredentialsRequest deviceAndCredentials) throws ThingsboardException { + @Valid @RequestBody SaveDeviceWithCredentialsRequest deviceAndCredentials, + @Parameter(description = "Optional value of name conflict strategy. Possible values: FAIL or UNIQUIFY. " + + "If omitted, FAIL strategy is applied. FAIL strategy implies exception will be thrown if an entity with the same name already exists. " + + "UNIQUIFY strategy appends a numerical suffix to the entity name, if a name conflict occurs.") + @RequestParam(name = "nameConflictStrategy", defaultValue = "FAIL") NameConflictStrategy nameConflictStrategy) throws ThingsboardException { Device device = deviceAndCredentials.getDevice(); DeviceCredentials credentials = deviceAndCredentials.getCredentials(); device.setTenantId(getCurrentUser().getTenantId()); checkEntity(device.getId(), device, Resource.DEVICE); - return tbDeviceService.saveDeviceWithCredentials(device, credentials, getCurrentUser()); + return tbDeviceService.saveDeviceWithCredentials(device, credentials, nameConflictStrategy, getCurrentUser()); } @ApiOperation(value = "Delete device (deleteDevice)", 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 b1b6b6b1e3..147fccda78 100644 --- a/application/src/main/java/org/thingsboard/server/controller/EntityViewController.java +++ b/application/src/main/java/org/thingsboard/server/controller/EntityViewController.java @@ -34,6 +34,7 @@ import org.thingsboard.server.common.data.Customer; import org.thingsboard.server.common.data.EntitySubtype; import org.thingsboard.server.common.data.EntityView; import org.thingsboard.server.common.data.EntityViewInfo; +import org.thingsboard.server.common.data.NameConflictStrategy; import org.thingsboard.server.common.data.edge.Edge; import org.thingsboard.server.common.data.entityview.EntityViewSearchQuery; import org.thingsboard.server.common.data.exception.ThingsboardException; @@ -128,7 +129,11 @@ public class EntityViewController extends BaseController { @ResponseBody public EntityView saveEntityView( @Parameter(description = "A JSON object representing the entity view.") - @RequestBody EntityView entityView) throws Exception { + @RequestBody EntityView entityView, + @Parameter(description = "Optional value of name conflict strategy. Possible values: FAIL or UNIQUIFY. " + + "If omitted, FAIL strategy is applied. FAIL strategy implies exception will be thrown if an entity with the same name already exists. " + + "UNIQUIFY strategy appends a numerical suffix to the entity name, if a name conflict occurs.") + @RequestParam(name = "nameConflictStrategy", defaultValue = "FAIL") NameConflictStrategy nameConflictStrategy) throws Exception { entityView.setTenantId(getCurrentUser().getTenantId()); EntityView existingEntityView = null; if (entityView.getId() == null) { @@ -137,7 +142,7 @@ public class EntityViewController extends BaseController { } else { existingEntityView = checkEntityViewId(entityView.getId(), Operation.WRITE); } - return tbEntityViewService.save(entityView, existingEntityView, getCurrentUser()); + return tbEntityViewService.save(entityView, existingEntityView, nameConflictStrategy, getCurrentUser()); } @ApiOperation(value = "Delete entity view (deleteEntityView)", diff --git a/application/src/main/java/org/thingsboard/server/controller/Lwm2mController.java b/application/src/main/java/org/thingsboard/server/controller/Lwm2mController.java index f0ec727896..1febbb9bfd 100644 --- a/application/src/main/java/org/thingsboard/server/controller/Lwm2mController.java +++ b/application/src/main/java/org/thingsboard/server/controller/Lwm2mController.java @@ -27,6 +27,7 @@ import org.springframework.web.bind.annotation.ResponseBody; import org.springframework.web.bind.annotation.RestController; import org.thingsboard.common.util.JacksonUtil; import org.thingsboard.server.common.data.Device; +import org.thingsboard.server.common.data.NameConflictStrategy; import org.thingsboard.server.common.data.SaveDeviceWithCredentialsRequest; import org.thingsboard.server.common.data.device.profile.lwm2m.bootstrap.LwM2MServerSecurityConfigDefault; import org.thingsboard.server.common.data.exception.ThingsboardException; @@ -73,6 +74,6 @@ public class Lwm2mController extends BaseController { public Device saveDeviceWithCredentials(@RequestBody Map, Object> deviceWithDeviceCredentials) throws ThingsboardException { Device device = checkNotNull(JacksonUtil.convertValue(deviceWithDeviceCredentials.get(Device.class), Device.class)); DeviceCredentials credentials = checkNotNull(JacksonUtil.convertValue(deviceWithDeviceCredentials.get(DeviceCredentials.class), DeviceCredentials.class)); - return deviceController.saveDeviceWithCredentials(new SaveDeviceWithCredentialsRequest(device, credentials)); + return deviceController.saveDeviceWithCredentials(new SaveDeviceWithCredentialsRequest(device, credentials), NameConflictStrategy.FAIL); } } diff --git a/application/src/main/java/org/thingsboard/server/service/device/DeviceBulkImportService.java b/application/src/main/java/org/thingsboard/server/service/device/DeviceBulkImportService.java index d042fb2657..316069280a 100644 --- a/application/src/main/java/org/thingsboard/server/service/device/DeviceBulkImportService.java +++ b/application/src/main/java/org/thingsboard/server/service/device/DeviceBulkImportService.java @@ -29,6 +29,7 @@ import org.thingsboard.server.common.data.DeviceProfileProvisionType; import org.thingsboard.server.common.data.DeviceProfileType; import org.thingsboard.server.common.data.DeviceTransportType; import org.thingsboard.server.common.data.EntityType; +import org.thingsboard.server.common.data.NameConflictStrategy; import org.thingsboard.server.common.data.StringUtils; import org.thingsboard.server.common.data.device.credentials.BasicMqttCredentials; import org.thingsboard.server.common.data.device.credentials.lwm2m.LwM2MClientCredential; @@ -128,7 +129,7 @@ public class DeviceBulkImportService extends AbstractBulkImportService { } device.setDeviceProfileId(deviceProfile.getId()); - return tbDeviceService.saveDeviceWithCredentials(device, deviceCredentials, user); + return tbDeviceService.saveDeviceWithCredentials(device, deviceCredentials, NameConflictStrategy.FAIL, user); } @Override diff --git a/application/src/main/java/org/thingsboard/server/service/entitiy/asset/DefaultTbAssetService.java b/application/src/main/java/org/thingsboard/server/service/entitiy/asset/DefaultTbAssetService.java index 3f69d00276..f55635d1d7 100644 --- a/application/src/main/java/org/thingsboard/server/service/entitiy/asset/DefaultTbAssetService.java +++ b/application/src/main/java/org/thingsboard/server/service/entitiy/asset/DefaultTbAssetService.java @@ -20,6 +20,7 @@ import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import org.thingsboard.server.common.data.Customer; import org.thingsboard.server.common.data.EntityType; +import org.thingsboard.server.common.data.NameConflictStrategy; import org.thingsboard.server.common.data.User; import org.thingsboard.server.common.data.asset.Asset; import org.thingsboard.server.common.data.audit.ActionType; @@ -39,11 +40,11 @@ public class DefaultTbAssetService extends AbstractTbEntityService implements Tb private final AssetService assetService; @Override - public Asset save(Asset asset, User user) throws Exception { + public Asset save(Asset asset, NameConflictStrategy nameConflictStrategy, User user) throws Exception { ActionType actionType = asset.getId() == null ? ActionType.ADDED : ActionType.UPDATED; TenantId tenantId = asset.getTenantId(); try { - Asset savedAsset = checkNotNull(assetService.saveAsset(asset)); + Asset savedAsset = checkNotNull(assetService.saveAsset(asset, nameConflictStrategy)); autoCommit(user, savedAsset.getId()); logEntityActionService.logEntityAction(tenantId, savedAsset.getId(), savedAsset, asset.getCustomerId(), actionType, user); diff --git a/application/src/main/java/org/thingsboard/server/service/entitiy/asset/TbAssetService.java b/application/src/main/java/org/thingsboard/server/service/entitiy/asset/TbAssetService.java index a2af8ffcdc..52b20fc52b 100644 --- a/application/src/main/java/org/thingsboard/server/service/entitiy/asset/TbAssetService.java +++ b/application/src/main/java/org/thingsboard/server/service/entitiy/asset/TbAssetService.java @@ -16,6 +16,7 @@ package org.thingsboard.server.service.entitiy.asset; import org.thingsboard.server.common.data.Customer; +import org.thingsboard.server.common.data.NameConflictStrategy; import org.thingsboard.server.common.data.User; import org.thingsboard.server.common.data.asset.Asset; import org.thingsboard.server.common.data.edge.Edge; @@ -25,7 +26,7 @@ import org.thingsboard.server.common.data.id.TenantId; public interface TbAssetService { - Asset save(Asset asset, User user) throws Exception; + Asset save(Asset asset, NameConflictStrategy nameConflictStrategy, User user) throws Exception; void delete(Asset asset, User user); diff --git a/application/src/main/java/org/thingsboard/server/service/entitiy/device/DefaultTbDeviceService.java b/application/src/main/java/org/thingsboard/server/service/entitiy/device/DefaultTbDeviceService.java index f182894239..85d5de5379 100644 --- a/application/src/main/java/org/thingsboard/server/service/entitiy/device/DefaultTbDeviceService.java +++ b/application/src/main/java/org/thingsboard/server/service/entitiy/device/DefaultTbDeviceService.java @@ -25,6 +25,7 @@ import org.springframework.transaction.annotation.Transactional; import org.thingsboard.server.common.data.Customer; import org.thingsboard.server.common.data.Device; import org.thingsboard.server.common.data.EntityType; +import org.thingsboard.server.common.data.NameConflictStrategy; import org.thingsboard.server.common.data.Tenant; import org.thingsboard.server.common.data.User; import org.thingsboard.server.common.data.audit.ActionType; @@ -55,11 +56,11 @@ public class DefaultTbDeviceService extends AbstractTbEntityService implements T private final ClaimDevicesService claimDevicesService; @Override - public Device save(Device device, String accessToken, User user) throws Exception { + public Device save(Device device, String accessToken, NameConflictStrategy nameConflictStrategy, User user) throws Exception { ActionType actionType = device.getId() == null ? ActionType.ADDED : ActionType.UPDATED; TenantId tenantId = device.getTenantId(); try { - Device savedDevice = checkNotNull(deviceService.saveDeviceWithAccessToken(device, accessToken)); + Device savedDevice = checkNotNull(deviceService.saveDeviceWithAccessToken(device, accessToken, nameConflictStrategy)); autoCommit(user, savedDevice.getId()); logEntityActionService.logEntityAction(tenantId, savedDevice.getId(), savedDevice, savedDevice.getCustomerId(), actionType, user); @@ -72,11 +73,11 @@ public class DefaultTbDeviceService extends AbstractTbEntityService implements T } @Override - public Device saveDeviceWithCredentials(Device device, DeviceCredentials credentials, User user) throws ThingsboardException { + public Device saveDeviceWithCredentials(Device device, DeviceCredentials credentials, NameConflictStrategy nameConflictStrategy, User user) throws ThingsboardException { ActionType actionType = device.getId() == null ? ActionType.ADDED : ActionType.UPDATED; TenantId tenantId = device.getTenantId(); try { - Device savedDevice = checkNotNull(deviceService.saveDeviceWithCredentials(device, credentials)); + Device savedDevice = checkNotNull(deviceService.saveDeviceWithCredentials(device, credentials, nameConflictStrategy)); logEntityActionService.logEntityAction(tenantId, savedDevice.getId(), savedDevice, savedDevice.getCustomerId(), actionType, user); diff --git a/application/src/main/java/org/thingsboard/server/service/entitiy/device/TbDeviceService.java b/application/src/main/java/org/thingsboard/server/service/entitiy/device/TbDeviceService.java index c234b3b597..26be8c5db4 100644 --- a/application/src/main/java/org/thingsboard/server/service/entitiy/device/TbDeviceService.java +++ b/application/src/main/java/org/thingsboard/server/service/entitiy/device/TbDeviceService.java @@ -18,6 +18,7 @@ package org.thingsboard.server.service.entitiy.device; import com.google.common.util.concurrent.ListenableFuture; import org.thingsboard.server.common.data.Customer; import org.thingsboard.server.common.data.Device; +import org.thingsboard.server.common.data.NameConflictStrategy; import org.thingsboard.server.common.data.Tenant; import org.thingsboard.server.common.data.User; import org.thingsboard.server.common.data.edge.Edge; @@ -31,9 +32,9 @@ import org.thingsboard.server.dao.device.claim.ReclaimResult; public interface TbDeviceService { - Device save(Device device, String accessToken, User user) throws Exception; + Device save(Device device, String accessToken, NameConflictStrategy nameConflictStrategy, User user) throws Exception; - Device saveDeviceWithCredentials(Device device, DeviceCredentials deviceCredentials, User user) throws ThingsboardException; + Device saveDeviceWithCredentials(Device device, DeviceCredentials deviceCredentials, NameConflictStrategy nameConflictStrategy, User user) throws ThingsboardException; void delete(Device device, User user); diff --git a/application/src/main/java/org/thingsboard/server/service/entitiy/entityview/DefaultTbEntityViewService.java b/application/src/main/java/org/thingsboard/server/service/entitiy/entityview/DefaultTbEntityViewService.java index 8fb99bfb0e..4daf54afdd 100644 --- a/application/src/main/java/org/thingsboard/server/service/entitiy/entityview/DefaultTbEntityViewService.java +++ b/application/src/main/java/org/thingsboard/server/service/entitiy/entityview/DefaultTbEntityViewService.java @@ -33,6 +33,7 @@ import org.thingsboard.server.common.data.AttributeScope; import org.thingsboard.server.common.data.Customer; import org.thingsboard.server.common.data.EntityType; import org.thingsboard.server.common.data.EntityView; +import org.thingsboard.server.common.data.NameConflictStrategy; import org.thingsboard.server.common.data.User; import org.thingsboard.server.common.data.audit.ActionType; import org.thingsboard.server.common.data.edge.Edge; @@ -79,11 +80,11 @@ public class DefaultTbEntityViewService extends AbstractTbEntityService implemen final Map>> localCache = new ConcurrentHashMap<>(); @Override - public EntityView save(EntityView entityView, EntityView existingEntityView, User user) throws Exception { + public EntityView save(EntityView entityView, EntityView existingEntityView, NameConflictStrategy nameConflictStrategy, User user) throws Exception { ActionType actionType = entityView.getId() == null ? ActionType.ADDED : ActionType.UPDATED; TenantId tenantId = entityView.getTenantId(); try { - EntityView savedEntityView = checkNotNull(entityViewService.saveEntityView(entityView)); + EntityView savedEntityView = checkNotNull(entityViewService.saveEntityView(entityView, nameConflictStrategy)); this.updateEntityViewAttributes(tenantId, savedEntityView, existingEntityView, user); autoCommit(user, savedEntityView.getId()); logEntityActionService.logEntityAction(savedEntityView.getTenantId(), savedEntityView.getId(), savedEntityView, diff --git a/application/src/main/java/org/thingsboard/server/service/entitiy/entityview/TbEntityViewService.java b/application/src/main/java/org/thingsboard/server/service/entitiy/entityview/TbEntityViewService.java index 3aec924f75..f34b4ed335 100644 --- a/application/src/main/java/org/thingsboard/server/service/entitiy/entityview/TbEntityViewService.java +++ b/application/src/main/java/org/thingsboard/server/service/entitiy/entityview/TbEntityViewService.java @@ -18,6 +18,7 @@ package org.thingsboard.server.service.entitiy.entityview; import com.google.common.util.concurrent.ListenableFuture; import org.thingsboard.server.common.data.Customer; import org.thingsboard.server.common.data.EntityView; +import org.thingsboard.server.common.data.NameConflictStrategy; import org.thingsboard.server.common.data.User; import org.thingsboard.server.common.data.edge.Edge; import org.thingsboard.server.common.data.exception.ThingsboardException; @@ -31,7 +32,7 @@ import java.util.List; public interface TbEntityViewService extends ComponentLifecycleListener { - EntityView save(EntityView entityView, EntityView existingEntityView, User user) throws Exception; + EntityView save(EntityView entityView, EntityView existingEntityView, NameConflictStrategy nameConflictStrategy, User user) throws Exception; void updateEntityViewAttributes(TenantId tenantId, EntityView savedEntityView, EntityView oldEntityView, User user) throws ThingsboardException; diff --git a/common/dao-api/src/main/java/org/thingsboard/server/dao/asset/AssetService.java b/common/dao-api/src/main/java/org/thingsboard/server/dao/asset/AssetService.java index c22c9c4140..a930c86ac9 100644 --- a/common/dao-api/src/main/java/org/thingsboard/server/dao/asset/AssetService.java +++ b/common/dao-api/src/main/java/org/thingsboard/server/dao/asset/AssetService.java @@ -17,6 +17,7 @@ package org.thingsboard.server.dao.asset; import com.google.common.util.concurrent.ListenableFuture; import org.thingsboard.server.common.data.EntitySubtype; +import org.thingsboard.server.common.data.NameConflictStrategy; import org.thingsboard.server.common.data.ProfileEntityIdInfo; import org.thingsboard.server.common.data.asset.Asset; import org.thingsboard.server.common.data.asset.AssetInfo; @@ -25,12 +26,15 @@ import org.thingsboard.server.common.data.id.AssetId; import org.thingsboard.server.common.data.id.AssetProfileId; import org.thingsboard.server.common.data.id.CustomerId; import org.thingsboard.server.common.data.id.EdgeId; +import org.thingsboard.server.common.data.id.EntityId; +import org.thingsboard.server.common.data.id.HasId; import org.thingsboard.server.common.data.id.TenantId; import org.thingsboard.server.common.data.page.PageData; import org.thingsboard.server.common.data.page.PageLink; import org.thingsboard.server.dao.entity.EntityDaoService; import java.util.List; +import java.util.Optional; public interface AssetService extends EntityDaoService { @@ -48,6 +52,8 @@ public interface AssetService extends EntityDaoService { Asset saveAsset(Asset asset); + Asset saveAsset(Asset asset, NameConflictStrategy nameConflictStrategy); + Asset assignAssetToCustomer(TenantId tenantId, AssetId assetId, CustomerId customerId); Asset unassignAssetFromCustomer(TenantId tenantId, AssetId assetId); diff --git a/common/dao-api/src/main/java/org/thingsboard/server/dao/device/DeviceService.java b/common/dao-api/src/main/java/org/thingsboard/server/dao/device/DeviceService.java index 9eb258f182..759ab3a708 100644 --- a/common/dao-api/src/main/java/org/thingsboard/server/dao/device/DeviceService.java +++ b/common/dao-api/src/main/java/org/thingsboard/server/dao/device/DeviceService.java @@ -23,6 +23,7 @@ import org.thingsboard.server.common.data.DeviceInfoFilter; import org.thingsboard.server.common.data.DeviceProfile; import org.thingsboard.server.common.data.DeviceTransportType; import org.thingsboard.server.common.data.EntitySubtype; +import org.thingsboard.server.common.data.NameConflictStrategy; import org.thingsboard.server.common.data.ProfileEntityIdInfo; import org.thingsboard.server.common.data.device.DeviceSearchQuery; import org.thingsboard.server.common.data.id.CustomerId; @@ -58,8 +59,12 @@ public interface DeviceService extends EntityDaoService { Device saveDeviceWithAccessToken(Device device, String accessToken); + Device saveDeviceWithAccessToken(Device device, String accessToken, NameConflictStrategy nameConflictStrategy); + Device saveDeviceWithCredentials(Device device, DeviceCredentials deviceCredentials); + Device saveDeviceWithCredentials(Device device, DeviceCredentials deviceCredentials, NameConflictStrategy nameConflictStrategy); + Device saveDevice(ProvisionRequest provisionRequest, DeviceProfile profile); Device assignDeviceToCustomer(TenantId tenantId, DeviceId deviceId, CustomerId customerId); diff --git a/common/dao-api/src/main/java/org/thingsboard/server/dao/entityview/EntityViewService.java b/common/dao-api/src/main/java/org/thingsboard/server/dao/entityview/EntityViewService.java index 6e557106f8..b039dbafd1 100644 --- a/common/dao-api/src/main/java/org/thingsboard/server/dao/entityview/EntityViewService.java +++ b/common/dao-api/src/main/java/org/thingsboard/server/dao/entityview/EntityViewService.java @@ -19,6 +19,7 @@ import com.google.common.util.concurrent.ListenableFuture; import org.thingsboard.server.common.data.EntitySubtype; import org.thingsboard.server.common.data.EntityView; import org.thingsboard.server.common.data.EntityViewInfo; +import org.thingsboard.server.common.data.NameConflictStrategy; import org.thingsboard.server.common.data.entityview.EntityViewSearchQuery; import org.thingsboard.server.common.data.id.CustomerId; import org.thingsboard.server.common.data.id.EdgeId; @@ -38,6 +39,8 @@ public interface EntityViewService extends EntityDaoService { EntityView saveEntityView(EntityView entityView); + EntityView saveEntityView(EntityView entityView, NameConflictStrategy nameConflictStrategy); + EntityView saveEntityView(EntityView entityView, boolean doValidate); EntityView assignEntityViewToCustomer(TenantId tenantId, EntityViewId entityViewId, CustomerId customerId); diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/NameConflictStrategy.java b/common/data/src/main/java/org/thingsboard/server/common/data/NameConflictStrategy.java new file mode 100644 index 0000000000..b21ddce883 --- /dev/null +++ b/common/data/src/main/java/org/thingsboard/server/common/data/NameConflictStrategy.java @@ -0,0 +1,23 @@ +/** + * 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.common.data; + +public enum NameConflictStrategy { + + FAIL, + UNIQUIFY; + +} diff --git a/dao/src/main/java/org/thingsboard/server/dao/Dao.java b/dao/src/main/java/org/thingsboard/server/dao/Dao.java index 72883c55ef..450b030e08 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/Dao.java +++ b/dao/src/main/java/org/thingsboard/server/dao/Dao.java @@ -16,6 +16,7 @@ package org.thingsboard.server.dao; import com.google.common.util.concurrent.ListenableFuture; +import org.thingsboard.server.common.data.EntityInfo; import org.thingsboard.server.common.data.EntityType; import org.thingsboard.server.common.data.edqs.fields.EntityFields; import org.thingsboard.server.common.data.id.TenantId; @@ -32,6 +33,8 @@ public interface Dao { ListenableFuture findByIdAsync(TenantId tenantId, UUID id); + EntityInfo findEntityInfoByName(TenantId tenantId, String name); + boolean existsById(TenantId tenantId, UUID id); ListenableFuture existsByIdAsync(TenantId tenantId, UUID id); diff --git a/dao/src/main/java/org/thingsboard/server/dao/asset/AssetDao.java b/dao/src/main/java/org/thingsboard/server/dao/asset/AssetDao.java index 098dc4e83d..4b24e464bc 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/asset/AssetDao.java +++ b/dao/src/main/java/org/thingsboard/server/dao/asset/AssetDao.java @@ -16,6 +16,7 @@ package org.thingsboard.server.dao.asset; import com.google.common.util.concurrent.ListenableFuture; +import org.thingsboard.server.common.data.EntityInfo; import org.thingsboard.server.common.data.EntitySubtype; import org.thingsboard.server.common.data.ProfileEntityIdInfo; import org.thingsboard.server.common.data.asset.Asset; @@ -242,4 +243,5 @@ public interface AssetDao extends Dao, TenantEntityDao, Exportable PageData findProfileEntityIdInfosByTenantId(UUID tenantId, PageLink pageLink); + EntityInfo findEntityInfoByName(TenantId tenantId, String name); } diff --git a/dao/src/main/java/org/thingsboard/server/dao/asset/BaseAssetService.java b/dao/src/main/java/org/thingsboard/server/dao/asset/BaseAssetService.java index fed201d403..0414a0f4a7 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/asset/BaseAssetService.java +++ b/dao/src/main/java/org/thingsboard/server/dao/asset/BaseAssetService.java @@ -26,6 +26,7 @@ import org.springframework.transaction.annotation.Transactional; import org.springframework.transaction.event.TransactionalEventListener; import org.thingsboard.server.common.data.EntitySubtype; import org.thingsboard.server.common.data.EntityType; +import org.thingsboard.server.common.data.NameConflictStrategy; import org.thingsboard.server.common.data.ProfileEntityIdInfo; import org.thingsboard.server.common.data.StringUtils; import org.thingsboard.server.common.data.asset.Asset; @@ -146,8 +147,17 @@ public class BaseAssetService extends AbstractCachedEntityService, TenantEntityDao, Exporta PageData findDeviceInfosByFilter(DeviceInfoFilter filter, PageLink pageLink); + EntityInfo findEntityInfoByName(TenantId tenantId, String name); } diff --git a/dao/src/main/java/org/thingsboard/server/dao/device/DeviceServiceImpl.java b/dao/src/main/java/org/thingsboard/server/dao/device/DeviceServiceImpl.java index 6d993f3e3d..adce656bef 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/device/DeviceServiceImpl.java +++ b/dao/src/main/java/org/thingsboard/server/dao/device/DeviceServiceImpl.java @@ -36,9 +36,11 @@ import org.thingsboard.server.common.data.DeviceInfoFilter; import org.thingsboard.server.common.data.DeviceProfile; import org.thingsboard.server.common.data.DeviceProfileType; import org.thingsboard.server.common.data.DeviceTransportType; +import org.thingsboard.server.common.data.EntityInfo; import org.thingsboard.server.common.data.EntitySubtype; import org.thingsboard.server.common.data.EntityType; import org.thingsboard.server.common.data.EntityView; +import org.thingsboard.server.common.data.NameConflictStrategy; import org.thingsboard.server.common.data.ProfileEntityIdInfo; import org.thingsboard.server.common.data.StringUtils; import org.thingsboard.server.common.data.Tenant; @@ -88,6 +90,7 @@ import java.util.ArrayList; import java.util.List; import java.util.Optional; import java.util.UUID; +import java.util.function.Consumer; import static org.thingsboard.server.dao.DaoUtil.toUUIDs; import static org.thingsboard.server.dao.service.Validator.validateId; @@ -167,6 +170,12 @@ public class DeviceServiceImpl extends CachedVersionedEntityService & HasTenantId & HasName> void uniquifyEntityName(E entity, E oldEntity, Consumer setName, EntityType entityType) { + Dao dao = entityDaoRegistry.getDao(entityType); + EntityInfo existingEntity = dao.findEntityInfoByName(entity.getTenantId(), entity.getName()); + if (existingEntity != null && (oldEntity == null || !existingEntity.getId().equals(oldEntity.getId()))) { + int suffix = 1; + while (true) { + String newName = entity.getName() + "-" + suffix; + if (dao.findEntityInfoByName(entity.getTenantId(), newName) == null) { + setName.accept(newName); + break; + } + suffix++; + } + } + } + } diff --git a/dao/src/main/java/org/thingsboard/server/dao/entityview/EntityViewServiceImpl.java b/dao/src/main/java/org/thingsboard/server/dao/entityview/EntityViewServiceImpl.java index 0e742e20db..cd01473d69 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/entityview/EntityViewServiceImpl.java +++ b/dao/src/main/java/org/thingsboard/server/dao/entityview/EntityViewServiceImpl.java @@ -29,6 +29,7 @@ import org.thingsboard.server.common.data.EntitySubtype; import org.thingsboard.server.common.data.EntityType; import org.thingsboard.server.common.data.EntityView; import org.thingsboard.server.common.data.EntityViewInfo; +import org.thingsboard.server.common.data.NameConflictStrategy; import org.thingsboard.server.common.data.StringUtils; import org.thingsboard.server.common.data.audit.ActionType; import org.thingsboard.server.common.data.edge.Edge; @@ -110,8 +111,17 @@ public class EntityViewServiceImpl extends CachedVersionedEntityService, Expor AssetEntity findByTenantIdAndName(UUID tenantId, String name); + @Query("SELECT new org.thingsboard.server.common.data.EntityInfo(a.id, 'ASSET', a.name) " + + "FROM AssetEntity a WHERE a.tenantId = :tenantId AND a.name = :name") + EntityInfo findEntityInfoByName(UUID tenantId, String name); + @Query("SELECT a FROM AssetEntity a WHERE a.tenantId = :tenantId " + "AND a.type = :type " + "AND (:textSearch IS NULL OR ilike(a.name, CONCAT('%', :textSearch, '%')) = true " + diff --git a/dao/src/main/java/org/thingsboard/server/dao/sql/asset/JpaAssetDao.java b/dao/src/main/java/org/thingsboard/server/dao/sql/asset/JpaAssetDao.java index 4b55884792..123cd17a69 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/sql/asset/JpaAssetDao.java +++ b/dao/src/main/java/org/thingsboard/server/dao/sql/asset/JpaAssetDao.java @@ -21,6 +21,7 @@ import org.springframework.beans.factory.annotation.Autowired; import org.springframework.data.domain.Limit; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.stereotype.Component; +import org.thingsboard.server.common.data.EntityInfo; import org.thingsboard.server.common.data.EntitySubtype; import org.thingsboard.server.common.data.EntityType; import org.thingsboard.server.common.data.ProfileEntityIdInfo; @@ -267,6 +268,12 @@ public class JpaAssetDao extends JpaAbstractDao implements A return nativeAssetRepository.findProfileEntityIdInfosByTenantId(tenantId, DaoUtil.toPageable(pageLink)); } + @Override + public EntityInfo findEntityInfoByName(TenantId tenantId, String name) { + log.debug("Find asset entity info by name [{}]", name); + return assetRepository.findEntityInfoByName(tenantId.getId(), name); + } + @Override public Long countByTenantId(TenantId tenantId) { return assetRepository.countByTenantId(tenantId.getId()); diff --git a/dao/src/main/java/org/thingsboard/server/dao/sql/customer/CustomerRepository.java b/dao/src/main/java/org/thingsboard/server/dao/sql/customer/CustomerRepository.java index 8ad7311423..ea6276f37d 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/sql/customer/CustomerRepository.java +++ b/dao/src/main/java/org/thingsboard/server/dao/sql/customer/CustomerRepository.java @@ -21,6 +21,7 @@ import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; +import org.thingsboard.server.common.data.EntityInfo; import org.thingsboard.server.common.data.edqs.fields.CustomerFields; import org.thingsboard.server.dao.ExportableEntityRepository; import org.thingsboard.server.dao.model.sql.CustomerEntity; @@ -41,6 +42,10 @@ public interface CustomerRepository extends JpaRepository, CustomerEntity findByTenantIdAndTitle(UUID tenantId, String title); + @Query("SELECT new org.thingsboard.server.common.data.EntityInfo(a.id, 'CUSTOMER', a.title) " + + "FROM CustomerEntity a WHERE a.tenantId = :tenantId AND a.title = :name") + EntityInfo findEntityInfoByName(UUID tenantId, String name); + @Query(value = "SELECT * FROM customer c WHERE c.tenant_id = :tenantId " + "AND c.is_public IS TRUE ORDER BY c.id ASC LIMIT 1", nativeQuery = true) CustomerEntity findPublicCustomerByTenantId(@Param("tenantId") UUID tenantId); diff --git a/dao/src/main/java/org/thingsboard/server/dao/sql/device/DeviceRepository.java b/dao/src/main/java/org/thingsboard/server/dao/sql/device/DeviceRepository.java index f4c2fed9fa..14e66826ba 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/sql/device/DeviceRepository.java +++ b/dao/src/main/java/org/thingsboard/server/dao/sql/device/DeviceRepository.java @@ -22,6 +22,7 @@ import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; import org.thingsboard.server.common.data.DeviceTransportType; +import org.thingsboard.server.common.data.EntityInfo; import org.thingsboard.server.common.data.edqs.fields.DeviceFields; import org.thingsboard.server.dao.ExportableEntityRepository; import org.thingsboard.server.dao.model.sql.DeviceEntity; @@ -151,6 +152,10 @@ public interface DeviceRepository extends JpaRepository, Exp DeviceEntity findByTenantIdAndName(UUID tenantId, String name); + @Query("SELECT new org.thingsboard.server.common.data.EntityInfo(a.id, 'DEVICE', a.name) " + + "FROM DeviceEntity a WHERE a.tenantId = :tenantId AND a.name = :name") + EntityInfo findEntityInfoByName(UUID tenantId, String name); + List findDevicesByTenantIdAndCustomerIdAndIdIn(UUID tenantId, UUID customerId, List deviceIds); List findDevicesByTenantIdAndIdIn(UUID tenantId, List deviceIds); diff --git a/dao/src/main/java/org/thingsboard/server/dao/sql/device/JpaDeviceDao.java b/dao/src/main/java/org/thingsboard/server/dao/sql/device/JpaDeviceDao.java index 9c79637e39..5d70ee53e5 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/sql/device/JpaDeviceDao.java +++ b/dao/src/main/java/org/thingsboard/server/dao/sql/device/JpaDeviceDao.java @@ -28,6 +28,7 @@ import org.thingsboard.server.common.data.DeviceIdInfo; import org.thingsboard.server.common.data.DeviceInfo; import org.thingsboard.server.common.data.DeviceInfoFilter; import org.thingsboard.server.common.data.DeviceTransportType; +import org.thingsboard.server.common.data.EntityInfo; import org.thingsboard.server.common.data.EntitySubtype; import org.thingsboard.server.common.data.EntityType; import org.thingsboard.server.common.data.ProfileEntityIdInfo; @@ -114,6 +115,11 @@ public class JpaDeviceDao extends JpaAbstractDao implement DaoUtil.toPageable(pageLink))); } + @Override + public EntityInfo findEntityInfoByName(TenantId tenantId, String name) { + return deviceRepository.findEntityInfoByName(tenantId.getId(), name); + } + @Override public ListenableFuture> findDevicesByTenantIdAndIdsAsync(UUID tenantId, List deviceIds) { return service.submit(() -> DaoUtil.convertDataList(deviceRepository.findDevicesByTenantIdAndIdIn(tenantId, deviceIds))); diff --git a/dao/src/main/java/org/thingsboard/server/dao/sql/entityview/EntityViewRepository.java b/dao/src/main/java/org/thingsboard/server/dao/sql/entityview/EntityViewRepository.java index 6094e9b171..0e66850be8 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/sql/entityview/EntityViewRepository.java +++ b/dao/src/main/java/org/thingsboard/server/dao/sql/entityview/EntityViewRepository.java @@ -21,6 +21,7 @@ import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; +import org.thingsboard.server.common.data.EntityInfo; import org.thingsboard.server.common.data.edqs.fields.EntityViewFields; import org.thingsboard.server.dao.ExportableEntityRepository; import org.thingsboard.server.dao.model.sql.EntityViewEntity; @@ -118,6 +119,10 @@ public interface EntityViewRepository extends JpaRepository findAllByTenantIdAndEntityId(UUID tenantId, UUID entityId); boolean existsByTenantIdAndEntityId(UUID tenantId, UUID entityId); From 6cfa3f1b0b6b4a699f1df94f4528322b8199d107 Mon Sep 17 00:00:00 2001 From: dashevchenko Date: Tue, 7 Oct 2025 12:22:57 +0300 Subject: [PATCH 073/102] fixed device session closing: should close every device session if message id wrong --- .../AbstractGatewaySessionHandler.java | 20 +++++++++++-------- 1 file changed, 12 insertions(+), 8 deletions(-) diff --git a/common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/session/AbstractGatewaySessionHandler.java b/common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/session/AbstractGatewaySessionHandler.java index 168fef6c2e..fee2f04e24 100644 --- a/common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/session/AbstractGatewaySessionHandler.java +++ b/common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/session/AbstractGatewaySessionHandler.java @@ -845,27 +845,31 @@ public abstract class AbstractGatewaySessionHandler Date: Tue, 7 Oct 2025 17:00:01 +0300 Subject: [PATCH 074/102] Support of displayName entity field --- .../controller/EntityQueryControllerTest.java | 155 +++++++++++++++++- .../dao/sql/query/EntityKeyMapping.java | 27 ++- 2 files changed, 173 insertions(+), 9 deletions(-) diff --git a/application/src/test/java/org/thingsboard/server/controller/EntityQueryControllerTest.java b/application/src/test/java/org/thingsboard/server/controller/EntityQueryControllerTest.java index 93d67ccd4b..0c40c2f868 100644 --- a/application/src/test/java/org/thingsboard/server/controller/EntityQueryControllerTest.java +++ b/application/src/test/java/org/thingsboard/server/controller/EntityQueryControllerTest.java @@ -77,6 +77,7 @@ import java.util.Arrays; import java.util.Collections; import java.util.List; import java.util.concurrent.TimeUnit; +import java.util.function.BiConsumer; import java.util.stream.Collectors; import static org.assertj.core.api.Assertions.assertThat; @@ -1068,6 +1069,119 @@ public class EntityQueryControllerTest extends AbstractControllerTest { countByQueryAndCheck(customerEntitiesQuery, 0); } + @Test + public void testFindDevicesByDisplayName() throws Exception { + loginTenantAdmin(); + int numOfDevices = 3; + + for (int i = 0; i < numOfDevices; i++) { + Device device = new Device(); + String name = "Device" + i; + device.setName(name); + device.setLabel("Device Label " + i); + device.setType("testFindDevicesByDisplayName"); + + Device savedDevice = doPost("/api/device?accessToken=" + name, device, Device.class); + } + + DeviceTypeFilter filter = new DeviceTypeFilter(); + filter.setDeviceTypes(List.of("testFindDevicesByDisplayName")); + filter.setDeviceNameFilter(""); + + KeyFilter displayNameFilter = getEntityFieldEqualFilter("displayName", "Device Label " + 0); + + EntityDataSortOrder sortOrder = new EntityDataSortOrder( + new EntityKey(EntityKeyType.ENTITY_FIELD, "displayName"), EntityDataSortOrder.Direction.ASC + ); + EntityDataPageLink pageLink = new EntityDataPageLink(10, 0, null, sortOrder); + List entityFields = List.of(new EntityKey(EntityKeyType.ENTITY_FIELD, "name"), new EntityKey(EntityKeyType.ENTITY_FIELD, "displayName")); + + // all devices with ownerName = TEST TENANT + EntityDataQuery query = new EntityDataQuery(filter, pageLink, entityFields, Collections.emptyList(), Collections.emptyList()); + checkEntitiesByQuery(query, numOfDevices, (i, entity) -> { + String name = entity.getLatest().get(EntityKeyType.ENTITY_FIELD).getOrDefault("name", new TsValue(0, "Invalid")).getValue(); + String displayName = entity.getLatest().get(EntityKeyType.ENTITY_FIELD).getOrDefault("displayName", new TsValue(0, "Invalid")).getValue(); + Assert.assertEquals("Device" + i, name); + Assert.assertEquals("Device Label " + i, displayName); + }); + + // all devices with ownerName = TEST TENANT + EntityDataQuery displayNameFilterQuery = new EntityDataQuery(filter, pageLink, entityFields, Collections.emptyList(), List.of(displayNameFilter)); + checkEntitiesByQuery(displayNameFilterQuery, 1, (i, entity) -> { + String name = entity.getLatest().get(EntityKeyType.ENTITY_FIELD).getOrDefault("name", new TsValue(0, "Invalid")).getValue(); + String displayName = entity.getLatest().get(EntityKeyType.ENTITY_FIELD).getOrDefault("displayName", new TsValue(0, "Invalid")).getValue(); + Assert.assertEquals("Device" + i, name); + Assert.assertEquals("Device Label " + i, displayName); + }); + } + + @Test + public void testFindUsersByDisplayName() throws Exception { + loginTenantAdmin(); + + User userA = new User(); + userA.setAuthority(Authority.TENANT_ADMIN); + userA.setFirstName("John"); + userA.setLastName("Doe"); + userA.setEmail("john.doe@tb.org"); + userA = doPost("/api/user", userA, User.class); + var aId = userA.getId(); + + User userB = new User(); + userB.setAuthority(Authority.TENANT_ADMIN); + userB.setFirstName("John"); + userB.setEmail("john@tb.org"); + userB = doPost("/api/user", userB, User.class); + var bId = userB.getId(); + + User userC = new User(); + userC.setAuthority(Authority.TENANT_ADMIN); + userC.setLastName("Doe"); + userC.setEmail("doe@tb.org"); + userC = doPost("/api/user", userC, User.class); + var cId = userC.getId(); + + User userD = new User(); + userD.setAuthority(Authority.TENANT_ADMIN); + userD.setEmail("noname@tb.org"); + userD = doPost("/api/user", userD, User.class); + var dId = userD.getId(); + + EntityTypeFilter filter = new EntityTypeFilter(); + filter.setEntityType(EntityType.USER); + + EntityDataSortOrder sortOrder = new EntityDataSortOrder( + new EntityKey(EntityKeyType.ENTITY_FIELD, "displayName"), EntityDataSortOrder.Direction.ASC + ); + EntityDataPageLink pageLink = new EntityDataPageLink(10, 0, null, sortOrder); + List entityFields = List.of(new EntityKey(EntityKeyType.ENTITY_FIELD, "displayName")); + + EntityDataQuery query = new EntityDataQuery(filter, pageLink, entityFields, Collections.emptyList(), List.of(getEntityFieldEqualFilter("displayName", "John Doe"))); + checkEntitiesByQuery(query, 1, (i, entity) -> { + Assert.assertEquals(aId, entity.getEntityId()); + String displayName = entity.getLatest().get(EntityKeyType.ENTITY_FIELD).getOrDefault("displayName", new TsValue(0, "Invalid")).getValue(); + Assert.assertEquals("John Doe", displayName); + }); + query = new EntityDataQuery(filter, pageLink, entityFields, Collections.emptyList(), List.of(getEntityFieldEqualFilter("displayName", "John"))); + checkEntitiesByQuery(query, 1, (i, entity) -> { + Assert.assertEquals(bId, entity.getEntityId()); + String displayName = entity.getLatest().get(EntityKeyType.ENTITY_FIELD).getOrDefault("displayName", new TsValue(0, "Invalid")).getValue(); + Assert.assertEquals("John", displayName); + }); + query = new EntityDataQuery(filter, pageLink, entityFields, Collections.emptyList(), List.of(getEntityFieldEqualFilter("displayName", "Doe"))); + checkEntitiesByQuery(query, 1, (i, entity) -> { + Assert.assertEquals(cId, entity.getEntityId()); + String displayName = entity.getLatest().get(EntityKeyType.ENTITY_FIELD).getOrDefault("displayName", new TsValue(0, "Invalid")).getValue(); + Assert.assertEquals("Doe", displayName); + }); + query = new EntityDataQuery(filter, pageLink, entityFields, Collections.emptyList(), List.of(getEntityFieldEqualFilter("displayName", "noname@tb.org"))); + checkEntitiesByQuery(query, 1, (i, entity) -> { + Assert.assertEquals(dId, entity.getEntityId()); + String displayName = entity.getLatest().get(EntityKeyType.ENTITY_FIELD).getOrDefault("displayName", new TsValue(0, "Invalid")).getValue(); + Assert.assertEquals("noname@tb.org", displayName); + }); + } + @Test public void testFindDevicesByOwnerNameAndOwnerType() throws Exception { loginTenantAdmin(); @@ -1105,19 +1219,30 @@ public class EntityQueryControllerTest extends AbstractControllerTest { // all devices with ownerName = TEST TENANT EntityDataQuery query = new EntityDataQuery(filter, pageLink, entityFields, latestValues, List.of(activeAlarmTimeFilter, tenantOwnerNameFilter)); - checkEntitiesByQuery(query, numOfDevices, TEST_TENANT_NAME, "TENANT"); + BiConsumer checkFunction = (i, entity) -> { + String name = entity.getLatest().get(EntityKeyType.ENTITY_FIELD).getOrDefault("name", new TsValue(0, "Invalid")).getValue(); + String ownerName = entity.getLatest().get(EntityKeyType.ENTITY_FIELD).getOrDefault("ownerName", new TsValue(0, "Invalid")).getValue(); + String ownerType = entity.getLatest().get(EntityKeyType.ENTITY_FIELD).getOrDefault("ownerType", new TsValue(0, "Invalid")).getValue(); + String alarmActiveTime = entity.getLatest().get(EntityKeyType.ATTRIBUTE).getOrDefault("alarmActiveTime", new TsValue(0, "-1")).getValue(); + + Assert.assertEquals("Device" + i, name); + Assert.assertEquals(TEST_TENANT_NAME, ownerName); + Assert.assertEquals("TENANT", ownerType); + Assert.assertEquals("1" + i, alarmActiveTime); + }; + checkEntitiesByQuery(query, numOfDevices, checkFunction); // all devices with wrong ownerName EntityDataQuery wrongTenantNameQuery = new EntityDataQuery(filter, pageLink, entityFields, latestValues, List.of(activeAlarmTimeFilter, wrongOwnerNameFilter)); - checkEntitiesByQuery(wrongTenantNameQuery, 0, null, null); + checkEntitiesByQuery(wrongTenantNameQuery, 0, null); // all devices with owner type = TENANT EntityDataQuery tenantEntitiesQuery = new EntityDataQuery(filter, pageLink, entityFields, latestValues, List.of(activeAlarmTimeFilter, tenantOwnerTypeFilter)); - checkEntitiesByQuery(tenantEntitiesQuery, numOfDevices, TEST_TENANT_NAME, "TENANT"); + checkEntitiesByQuery(tenantEntitiesQuery, numOfDevices, checkFunction); // all devices with owner type = CUSTOMER EntityDataQuery customerEntitiesQuery = new EntityDataQuery(filter, pageLink, entityFields, latestValues, List.of(activeAlarmTimeFilter, customerOwnerTypeFilter)); - checkEntitiesByQuery(customerEntitiesQuery, 0, null, null); + checkEntitiesByQuery(customerEntitiesQuery, 0, null); } @Test @@ -1163,6 +1288,28 @@ public class EntityQueryControllerTest extends AbstractControllerTest { findByQueryAndCheck(query, 0); } + private void checkEntitiesByQuery(EntityDataQuery query, int expectedNumOfDevices, BiConsumer checkFunction) throws Exception { + await() + .alias("data by query") + .atMost(30, TimeUnit.SECONDS) + .until(() -> { + var data = findByQuery(query); + var loadedEntities = new ArrayList<>(data.getData()); + return loadedEntities.size() == expectedNumOfDevices; + }); + if (expectedNumOfDevices == 0) { + return; + } + var data = findByQuery(query); + var loadedEntities = new ArrayList<>(data.getData()); + + Assert.assertEquals(expectedNumOfDevices, loadedEntities.size()); + + for (int i = 0; i < expectedNumOfDevices; i++) { + checkFunction.accept(i, loadedEntities.get(i)); + } + } + private void checkEntitiesByQuery(EntityDataQuery query, int expectedNumOfDevices, String expectedOwnerName, String expectedOwnerType) throws Exception { await() .alias("data by query") diff --git a/dao/src/main/java/org/thingsboard/server/dao/sql/query/EntityKeyMapping.java b/dao/src/main/java/org/thingsboard/server/dao/sql/query/EntityKeyMapping.java index 9755201fe9..dda2cf127d 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/sql/query/EntityKeyMapping.java +++ b/dao/src/main/java/org/thingsboard/server/dao/sql/query/EntityKeyMapping.java @@ -65,6 +65,7 @@ public class EntityKeyMapping { public static final String NAME = "name"; public static final String TYPE = "type"; public static final String LABEL = "label"; + public static final String DISPLAY_NAME = "displayName"; public static final String FIRST_NAME = "firstName"; public static final String LAST_NAME = "lastName"; public static final String EMAIL = "email"; @@ -83,6 +84,8 @@ public class EntityKeyMapping { public static final String SERVICE_ID = "serviceId"; public static final String OWNER_NAME = "ownerName"; public static final String OWNER_TYPE = "ownerType"; + public static final String LABELED_ENTITY_DISPLAY_NAME_SELECT_QUERY = "COALESCE (e." + LABEL + ", e." + NAME+")"; + public static final String USER_DISPLAY_NAME_SELECT_QUERY = "COALESCE(NULLIF(TRIM(CONCAT_WS(' ', e.first_name, e.last_name)), ''), e.email)"; public static final String OWNER_NAME_SELECT_QUERY = "case when e.customer_id = '" + NULL_UUID + "' " + "then (select title from tenant where id = e.tenant_id) " + "else (select title from customer where id = e.customer_id) end"; @@ -94,6 +97,16 @@ public class EntityKeyMapping { OWNER_NAME, OWNER_NAME_SELECT_QUERY, OWNER_TYPE, OWNER_TYPE_SELECT_QUERY ); + public static final Map labeledPropertiesFunctions = Map.of( + OWNER_NAME, OWNER_NAME_SELECT_QUERY, + OWNER_TYPE, OWNER_TYPE_SELECT_QUERY, + DISPLAY_NAME, LABELED_ENTITY_DISPLAY_NAME_SELECT_QUERY + ); + public static final Map userPropertiesFunctions = Map.of( + OWNER_NAME, OWNER_NAME_SELECT_QUERY, + OWNER_TYPE, OWNER_TYPE_SELECT_QUERY, + DISPLAY_NAME, USER_DISPLAY_NAME_SELECT_QUERY + ); public static final Map queueStatsPropertiesFunctions = Map.of(NAME, QUEUE_STATS_NAME_QUERY); public static final List typedEntityFields = Arrays.asList(CREATED_TIME, ENTITY_TYPE, NAME, TYPE, ADDITIONAL_INFO); @@ -153,20 +166,24 @@ public class EntityKeyMapping { Map contactBasedAliases = new HashMap<>(); contactBasedAliases.put(NAME, TITLE); contactBasedAliases.put(LABEL, TITLE); + contactBasedAliases.put(DISPLAY_NAME, TITLE); aliases.put(EntityType.TENANT, contactBasedAliases); aliases.put(EntityType.CUSTOMER, contactBasedAliases); aliases.put(EntityType.DASHBOARD, contactBasedAliases); + Map deviceAndAssetAliases = new HashMap<>(); + deviceAndAssetAliases.put(TITLE, NAME); + aliases.put(EntityType.DEVICE, deviceAndAssetAliases); + aliases.put(EntityType.ASSET, deviceAndAssetAliases); Map commonEntityAliases = new HashMap<>(); commonEntityAliases.put(TITLE, NAME); - aliases.put(EntityType.DEVICE, commonEntityAliases); - aliases.put(EntityType.ASSET, commonEntityAliases); + commonEntityAliases.put(DISPLAY_NAME, NAME); aliases.put(EntityType.ENTITY_VIEW, commonEntityAliases); aliases.put(EntityType.WIDGETS_BUNDLE, commonEntityAliases); - propertiesFunctions.put(EntityType.DEVICE, ownerPropertiesFunctions); - propertiesFunctions.put(EntityType.ASSET, ownerPropertiesFunctions); + propertiesFunctions.put(EntityType.DEVICE, labeledPropertiesFunctions); + propertiesFunctions.put(EntityType.ASSET, labeledPropertiesFunctions); propertiesFunctions.put(EntityType.ENTITY_VIEW, ownerPropertiesFunctions); - propertiesFunctions.put(EntityType.USER, ownerPropertiesFunctions); + propertiesFunctions.put(EntityType.USER, userPropertiesFunctions); propertiesFunctions.put(EntityType.DASHBOARD, ownerPropertiesFunctions); propertiesFunctions.put(EntityType.QUEUE_STATS, queueStatsPropertiesFunctions); From 720eab396c76dc836eeddc4c0cadd6a7da366114 Mon Sep 17 00:00:00 2001 From: dashevchenko Date: Tue, 7 Oct 2025 17:20:11 +0300 Subject: [PATCH 075/102] extended name conflict strategy to include name suffix separator --- .../main/data/upgrade/basic/schema_update.sql | 3 ++ .../server/controller/AssetController.java | 15 ++++++---- .../server/controller/CustomerController.java | 15 ++++++---- .../server/controller/DeviceController.java | 29 ++++++++++++------- .../controller/EntityViewController.java | 15 ++++++---- .../server/controller/Lwm2mController.java | 4 ++- .../device/DeviceBulkImportService.java | 3 +- .../entitiy/SimpleTbEntityService.java | 5 ++++ .../entitiy/asset/DefaultTbAssetService.java | 5 ++++ .../service/entitiy/asset/TbAssetService.java | 2 ++ .../customer/DefaultTbCustomerService.java | 8 ++++- .../controller/AssetControllerTest.java | 18 ++++++++++++ .../controller/CustomerControllerTest.java | 15 ++++++++++ .../controller/DeviceControllerTest.java | 18 ++++++++++++ .../controller/EntityViewControllerTest.java | 19 ++++++++++++ .../server/dao/asset/AssetService.java | 3 -- .../server/dao/customer/CustomerService.java | 3 ++ .../common/data/NameConflictPolicy.java | 23 +++++++++++++++ .../common/data/NameConflictStrategy.java | 8 +++-- .../java/org/thingsboard/server/dao/Dao.java | 4 ++- .../server/dao/asset/AssetDao.java | 1 - .../server/dao/asset/BaseAssetService.java | 7 +++-- .../dao/customer/CustomerServiceImpl.java | 26 ++++++++++++----- .../server/dao/device/DeviceDao.java | 1 - .../server/dao/device/DeviceServiceImpl.java | 11 ++++--- .../dao/entity/AbstractEntityService.java | 12 ++++---- .../dao/entityview/EntityViewServiceImpl.java | 10 ++++--- .../validator/EntityViewDataValidator.java | 8 ----- .../dao/sql/customer/JpaCustomerDao.java | 6 ++++ .../dao/sql/entityview/JpaEntityViewDao.java | 6 ++++ .../main/resources/sql/schema-entities.sql | 1 + 31 files changed, 232 insertions(+), 72 deletions(-) create mode 100644 common/data/src/main/java/org/thingsboard/server/common/data/NameConflictPolicy.java diff --git a/application/src/main/data/upgrade/basic/schema_update.sql b/application/src/main/data/upgrade/basic/schema_update.sql index 0add4c0545..3986a3c222 100644 --- a/application/src/main/data/upgrade/basic/schema_update.sql +++ b/application/src/main/data/upgrade/basic/schema_update.sql @@ -46,3 +46,6 @@ WHERE NOT ( ); -- UPDATE TENANT PROFILE CONFIGURATION END + +ALTER TABLE entity_view ADD CONSTRAINT entity_view_name_unq_key UNIQUE (tenant_id, name); + diff --git a/application/src/main/java/org/thingsboard/server/controller/AssetController.java b/application/src/main/java/org/thingsboard/server/controller/AssetController.java index 9cade90860..15fcfa84ff 100644 --- a/application/src/main/java/org/thingsboard/server/controller/AssetController.java +++ b/application/src/main/java/org/thingsboard/server/controller/AssetController.java @@ -34,6 +34,7 @@ import org.springframework.web.bind.annotation.ResponseStatus; import org.springframework.web.bind.annotation.RestController; import org.thingsboard.server.common.data.Customer; import org.thingsboard.server.common.data.EntitySubtype; +import org.thingsboard.server.common.data.NameConflictPolicy; import org.thingsboard.server.common.data.NameConflictStrategy; import org.thingsboard.server.common.data.asset.Asset; import org.thingsboard.server.common.data.asset.AssetInfo; @@ -139,13 +140,17 @@ public class AssetController extends BaseController { @RequestMapping(value = "/asset", method = RequestMethod.POST) @ResponseBody public Asset saveAsset(@io.swagger.v3.oas.annotations.parameters.RequestBody(description = "A JSON value representing the asset.") @RequestBody Asset asset, - @Parameter(description = "Optional value of name conflict strategy. Possible values: FAIL or UNIQUIFY. " + - "If omitted, FAIL strategy is applied. FAIL strategy implies exception will be thrown if an entity with the same name already exists. " + - "UNIQUIFY strategy appends a numerical suffix to the entity name, if a name conflict occurs.") - @RequestParam(name = "nameConflictStrategy", defaultValue = "FAIL") NameConflictStrategy nameConflictStrategy) throws Exception { + @Parameter(description = "Optional value of name conflict policy. Possible values: FAIL or UNIQUIFY. " + + "If omitted, FAIL policy is applied. FAIL policy implies exception will be thrown if an entity with the same name already exists. " + + "UNIQUIFY policy appends a suffix to the entity name, if a name conflict occurs.") + @RequestParam(name = "policy", defaultValue = "FAIL") NameConflictPolicy policy, + @Parameter(description = "Optional value of name suffix separator used by UNIQUIFY policy. By default, underscore separator is used. " + + "For example, strategy is UNIQUIFY, separator is '-'; if a name conflict occurs for customer asset 'Office A', " + + "created asset will have name like 'Office A-7fsh4f'.") + @RequestParam(name = "separator", defaultValue = "_") String separator) throws Exception { asset.setTenantId(getTenantId()); checkEntity(asset.getId(), asset, Resource.ASSET); - return tbAssetService.save(asset, nameConflictStrategy, getCurrentUser()); + return tbAssetService.save(asset, new NameConflictStrategy(policy, separator), getCurrentUser()); } @ApiOperation(value = "Delete asset (deleteAsset)", diff --git a/application/src/main/java/org/thingsboard/server/controller/CustomerController.java b/application/src/main/java/org/thingsboard/server/controller/CustomerController.java index 5c4acb6dbc..8f37a55e60 100644 --- a/application/src/main/java/org/thingsboard/server/controller/CustomerController.java +++ b/application/src/main/java/org/thingsboard/server/controller/CustomerController.java @@ -33,6 +33,7 @@ import org.springframework.web.bind.annotation.RestController; import org.thingsboard.common.util.JacksonUtil; import org.thingsboard.server.common.data.Customer; import org.thingsboard.server.common.data.NameConflictStrategy; +import org.thingsboard.server.common.data.NameConflictPolicy; import org.thingsboard.server.common.data.exception.ThingsboardException; import org.thingsboard.server.common.data.id.CustomerId; import org.thingsboard.server.common.data.id.TenantId; @@ -130,13 +131,17 @@ public class CustomerController extends BaseController { @RequestMapping(value = "/customer", method = RequestMethod.POST) @ResponseBody public Customer saveCustomer(@io.swagger.v3.oas.annotations.parameters.RequestBody(description = "A JSON value representing the customer.") @RequestBody Customer customer, - @Parameter(description = "Optional value of name conflict strategy. Possible values: FAIL or UNIQUIFY. " + - "If omitted, FAIL strategy is applied. FAIL strategy implies exception will be thrown if an entity with the same name already exists. " + - "UNIQUIFY strategy appends a numerical suffix to the entity name, if a name conflict occurs.") - @RequestParam(name = "nameConflictStrategy", defaultValue = "FAIL") NameConflictStrategy nameConflictStrategy) throws Exception { + @Parameter(description = "Optional value of name conflict policy. Possible values: FAIL or UNIQUIFY. " + + "If omitted, FAIL policy is applied. FAIL policy implies exception will be thrown if an entity with the same name already exists. " + + "UNIQUIFY policy appends a suffix to the entity name, if a name conflict occurs.") + @RequestParam(name = "policy", defaultValue = "FAIL") NameConflictPolicy policy, + @Parameter(description = "Optional value of name suffix separator used by UNIQUIFY policy. By default, underscore separator is used. " + + "For example, strategy is UNIQUIFY, separator is '-'; if a name conflict occurs for customer name 'Customer A', " + + "created customer will have name like 'Customer A-7fsh4f'.") + @RequestParam(name = "separator", defaultValue = "_") String separator) throws Exception { customer.setTenantId(getTenantId()); checkEntity(customer.getId(), customer, Resource.CUSTOMER); - return tbCustomerService.save(customer, getCurrentUser()); + return tbCustomerService.save(customer, new NameConflictStrategy(policy, separator), getCurrentUser()); } @ApiOperation(value = "Delete Customer (deleteCustomer)", diff --git a/application/src/main/java/org/thingsboard/server/controller/DeviceController.java b/application/src/main/java/org/thingsboard/server/controller/DeviceController.java index 724e3a430c..73d615ffd3 100644 --- a/application/src/main/java/org/thingsboard/server/controller/DeviceController.java +++ b/application/src/main/java/org/thingsboard/server/controller/DeviceController.java @@ -46,6 +46,7 @@ import org.thingsboard.server.common.data.Device; import org.thingsboard.server.common.data.DeviceInfo; import org.thingsboard.server.common.data.DeviceInfoFilter; import org.thingsboard.server.common.data.EntitySubtype; +import org.thingsboard.server.common.data.NameConflictPolicy; import org.thingsboard.server.common.data.NameConflictStrategy; import org.thingsboard.server.common.data.SaveDeviceWithCredentialsRequest; import org.thingsboard.server.common.data.Tenant; @@ -180,17 +181,21 @@ public class DeviceController extends BaseController { @Parameter(description = "Optional value of the device credentials to be used during device creation. " + "If omitted, access token will be auto-generated.") @RequestParam(name = "accessToken", required = false) String accessToken, - @Parameter(description = "Optional value of name conflict strategy. Possible values: FAIL or UNIQUIFY. " + - "If omitted, FAIL strategy is applied. FAIL strategy implies exception will be thrown if an entity with the same name already exists. " + - "UNIQUIFY strategy appends a numerical suffix to the entity name, if a name conflict occurs.") - @RequestParam(name = "nameConflictStrategy", defaultValue = "FAIL") NameConflictStrategy nameConflictStrategy) throws Exception { + @Parameter(description = "Optional value of name conflict policy. Possible values: FAIL or UNIQUIFY. " + + "If omitted, FAIL policy is applied. FAIL policy implies exception will be thrown if an entity with the same name already exists. " + + "UNIQUIFY policy appends a suffix to the entity name, if a name conflict occurs.") + @RequestParam(name = "policy", defaultValue = "FAIL") NameConflictPolicy policy, + @Parameter(description = "Optional value of name suffix separator used by UNIQUIFY policy. By default, underscore separator is used. " + + "For example, strategy is UNIQUIFY, separator is '-'; if a name conflict occurs for device name 'thermostat', " + + "created device will have name like 'thermostat-7fsh4f'.") + @RequestParam(name = "separator", defaultValue = "_") String separator) throws Exception { device.setTenantId(getCurrentUser().getTenantId()); if (device.getId() != null) { checkDeviceId(device.getId(), Operation.WRITE); } else { checkEntity(null, device, Resource.DEVICE); } - return tbDeviceService.save(device, accessToken, nameConflictStrategy, getCurrentUser()); + return tbDeviceService.save(device, accessToken, new NameConflictStrategy(policy, separator), getCurrentUser()); } @ApiOperation(value = "Create Device (saveDevice) with credentials ", @@ -216,15 +221,19 @@ public class DeviceController extends BaseController { @ResponseBody public Device saveDeviceWithCredentials(@Parameter(description = "The JSON object with device and credentials. See method description above for example.") @Valid @RequestBody SaveDeviceWithCredentialsRequest deviceAndCredentials, - @Parameter(description = "Optional value of name conflict strategy. Possible values: FAIL or UNIQUIFY. " + - "If omitted, FAIL strategy is applied. FAIL strategy implies exception will be thrown if an entity with the same name already exists. " + - "UNIQUIFY strategy appends a numerical suffix to the entity name, if a name conflict occurs.") - @RequestParam(name = "nameConflictStrategy", defaultValue = "FAIL") NameConflictStrategy nameConflictStrategy) throws ThingsboardException { + @Parameter(description = "Optional value of name conflict policy. Possible values: FAIL or UNIQUIFY. " + + "If omitted, FAIL policy is applied. FAIL policy implies exception will be thrown if an entity with the same name already exists. " + + "UNIQUIFY policy appends a suffix to the entity name, if a name conflict occurs.") + @RequestParam(name = "policy", defaultValue = "FAIL") NameConflictPolicy policy, + @Parameter(description = "Optional value of name suffix separator used by UNIQUIFY policy. By default, underscore separator is used. " + + "For example, strategy is UNIQUIFY, separator is '-'; if a name conflict occurs for device name 'thermostat', " + + "created device will have name like 'thermostat-7fsh4f'.") + @RequestParam(name = "separator", defaultValue = "_") String separator) throws ThingsboardException { Device device = deviceAndCredentials.getDevice(); DeviceCredentials credentials = deviceAndCredentials.getCredentials(); device.setTenantId(getCurrentUser().getTenantId()); checkEntity(device.getId(), device, Resource.DEVICE); - return tbDeviceService.saveDeviceWithCredentials(device, credentials, nameConflictStrategy, getCurrentUser()); + return tbDeviceService.saveDeviceWithCredentials(device, credentials, new NameConflictStrategy(policy, separator), getCurrentUser()); } @ApiOperation(value = "Delete device (deleteDevice)", 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 147fccda78..37cc7c7797 100644 --- a/application/src/main/java/org/thingsboard/server/controller/EntityViewController.java +++ b/application/src/main/java/org/thingsboard/server/controller/EntityViewController.java @@ -34,6 +34,7 @@ import org.thingsboard.server.common.data.Customer; import org.thingsboard.server.common.data.EntitySubtype; import org.thingsboard.server.common.data.EntityView; import org.thingsboard.server.common.data.EntityViewInfo; +import org.thingsboard.server.common.data.NameConflictPolicy; import org.thingsboard.server.common.data.NameConflictStrategy; import org.thingsboard.server.common.data.edge.Edge; import org.thingsboard.server.common.data.entityview.EntityViewSearchQuery; @@ -130,10 +131,14 @@ public class EntityViewController extends BaseController { public EntityView saveEntityView( @Parameter(description = "A JSON object representing the entity view.") @RequestBody EntityView entityView, - @Parameter(description = "Optional value of name conflict strategy. Possible values: FAIL or UNIQUIFY. " + - "If omitted, FAIL strategy is applied. FAIL strategy implies exception will be thrown if an entity with the same name already exists. " + - "UNIQUIFY strategy appends a numerical suffix to the entity name, if a name conflict occurs.") - @RequestParam(name = "nameConflictStrategy", defaultValue = "FAIL") NameConflictStrategy nameConflictStrategy) throws Exception { + @Parameter(description = "Optional value of name conflict policy. Possible values: FAIL or UNIQUIFY. " + + "If omitted, FAIL policy is applied. FAIL policy implies exception will be thrown if an entity with the same name already exists. " + + "UNIQUIFY policy appends a suffix to the entity name, if a name conflict occurs.") + @RequestParam(name = "policy", defaultValue = "FAIL") NameConflictPolicy policy, + @Parameter(description = "Optional value of name suffix separator used by UNIQUIFY policy. By default, underscore separator is used. " + + "For example, strategy is UNIQUIFY, separator is '-'; if a name conflict occurs for entity view name 'Device A', " + + "created customer will have name like 'Device A-7fsh4f'.") + @RequestParam(name = "separator", defaultValue = "_") String separator) throws Exception { entityView.setTenantId(getCurrentUser().getTenantId()); EntityView existingEntityView = null; if (entityView.getId() == null) { @@ -142,7 +147,7 @@ public class EntityViewController extends BaseController { } else { existingEntityView = checkEntityViewId(entityView.getId(), Operation.WRITE); } - return tbEntityViewService.save(entityView, existingEntityView, nameConflictStrategy, getCurrentUser()); + return tbEntityViewService.save(entityView, existingEntityView, new NameConflictStrategy(policy, separator), getCurrentUser()); } @ApiOperation(value = "Delete entity view (deleteEntityView)", diff --git a/application/src/main/java/org/thingsboard/server/controller/Lwm2mController.java b/application/src/main/java/org/thingsboard/server/controller/Lwm2mController.java index 1febbb9bfd..cdab0805a1 100644 --- a/application/src/main/java/org/thingsboard/server/controller/Lwm2mController.java +++ b/application/src/main/java/org/thingsboard/server/controller/Lwm2mController.java @@ -27,6 +27,7 @@ import org.springframework.web.bind.annotation.ResponseBody; import org.springframework.web.bind.annotation.RestController; import org.thingsboard.common.util.JacksonUtil; import org.thingsboard.server.common.data.Device; +import org.thingsboard.server.common.data.NameConflictPolicy; import org.thingsboard.server.common.data.NameConflictStrategy; import org.thingsboard.server.common.data.SaveDeviceWithCredentialsRequest; import org.thingsboard.server.common.data.device.profile.lwm2m.bootstrap.LwM2MServerSecurityConfigDefault; @@ -38,6 +39,7 @@ import org.thingsboard.server.service.lwm2m.LwM2MService; import java.util.Map; +import static org.thingsboard.server.common.data.NameConflictStrategy.DEFAULT; import static org.thingsboard.server.controller.ControllerConstants.IS_BOOTSTRAP_SERVER_PARAM_DESCRIPTION; import static org.thingsboard.server.controller.ControllerConstants.TENANT_OR_CUSTOMER_AUTHORITY_PARAGRAPH; @@ -74,6 +76,6 @@ public class Lwm2mController extends BaseController { public Device saveDeviceWithCredentials(@RequestBody Map, Object> deviceWithDeviceCredentials) throws ThingsboardException { Device device = checkNotNull(JacksonUtil.convertValue(deviceWithDeviceCredentials.get(Device.class), Device.class)); DeviceCredentials credentials = checkNotNull(JacksonUtil.convertValue(deviceWithDeviceCredentials.get(DeviceCredentials.class), DeviceCredentials.class)); - return deviceController.saveDeviceWithCredentials(new SaveDeviceWithCredentialsRequest(device, credentials), NameConflictStrategy.FAIL); + return deviceController.saveDeviceWithCredentials(new SaveDeviceWithCredentialsRequest(device, credentials), DEFAULT.policy(), DEFAULT.separator()); } } diff --git a/application/src/main/java/org/thingsboard/server/service/device/DeviceBulkImportService.java b/application/src/main/java/org/thingsboard/server/service/device/DeviceBulkImportService.java index 316069280a..952756210c 100644 --- a/application/src/main/java/org/thingsboard/server/service/device/DeviceBulkImportService.java +++ b/application/src/main/java/org/thingsboard/server/service/device/DeviceBulkImportService.java @@ -29,6 +29,7 @@ import org.thingsboard.server.common.data.DeviceProfileProvisionType; import org.thingsboard.server.common.data.DeviceProfileType; import org.thingsboard.server.common.data.DeviceTransportType; import org.thingsboard.server.common.data.EntityType; +import org.thingsboard.server.common.data.NameConflictPolicy; import org.thingsboard.server.common.data.NameConflictStrategy; import org.thingsboard.server.common.data.StringUtils; import org.thingsboard.server.common.data.device.credentials.BasicMqttCredentials; @@ -129,7 +130,7 @@ public class DeviceBulkImportService extends AbstractBulkImportService { } device.setDeviceProfileId(deviceProfile.getId()); - return tbDeviceService.saveDeviceWithCredentials(device, deviceCredentials, NameConflictStrategy.FAIL, user); + return tbDeviceService.saveDeviceWithCredentials(device, deviceCredentials, NameConflictStrategy.DEFAULT, user); } @Override diff --git a/application/src/main/java/org/thingsboard/server/service/entitiy/SimpleTbEntityService.java b/application/src/main/java/org/thingsboard/server/service/entitiy/SimpleTbEntityService.java index 61d52f00bb..84d606251c 100644 --- a/application/src/main/java/org/thingsboard/server/service/entitiy/SimpleTbEntityService.java +++ b/application/src/main/java/org/thingsboard/server/service/entitiy/SimpleTbEntityService.java @@ -15,6 +15,7 @@ */ package org.thingsboard.server.service.entitiy; +import org.thingsboard.server.common.data.NameConflictStrategy; import org.thingsboard.server.common.data.User; import org.thingsboard.server.service.security.model.SecurityUser; @@ -26,6 +27,10 @@ public interface SimpleTbEntityService { T save(T entity, SecurityUser user) throws Exception; + default T save(T entity, NameConflictStrategy nameConflictPolicy, SecurityUser user) throws Exception { + return save(entity, null); + } + void delete(T entity, User user); } diff --git a/application/src/main/java/org/thingsboard/server/service/entitiy/asset/DefaultTbAssetService.java b/application/src/main/java/org/thingsboard/server/service/entitiy/asset/DefaultTbAssetService.java index f55635d1d7..ef630c2df4 100644 --- a/application/src/main/java/org/thingsboard/server/service/entitiy/asset/DefaultTbAssetService.java +++ b/application/src/main/java/org/thingsboard/server/service/entitiy/asset/DefaultTbAssetService.java @@ -39,6 +39,11 @@ public class DefaultTbAssetService extends AbstractTbEntityService implements Tb private final AssetService assetService; + @Override + public Asset save(Asset asset, User user) throws Exception { + return save(asset, NameConflictStrategy.DEFAULT, user); + } + @Override public Asset save(Asset asset, NameConflictStrategy nameConflictStrategy, User user) throws Exception { ActionType actionType = asset.getId() == null ? ActionType.ADDED : ActionType.UPDATED; diff --git a/application/src/main/java/org/thingsboard/server/service/entitiy/asset/TbAssetService.java b/application/src/main/java/org/thingsboard/server/service/entitiy/asset/TbAssetService.java index 52b20fc52b..42fce5213e 100644 --- a/application/src/main/java/org/thingsboard/server/service/entitiy/asset/TbAssetService.java +++ b/application/src/main/java/org/thingsboard/server/service/entitiy/asset/TbAssetService.java @@ -26,6 +26,8 @@ import org.thingsboard.server.common.data.id.TenantId; public interface TbAssetService { + Asset save(Asset asset, User user) throws Exception; + Asset save(Asset asset, NameConflictStrategy nameConflictStrategy, User user) throws Exception; void delete(Asset asset, User user); diff --git a/application/src/main/java/org/thingsboard/server/service/entitiy/customer/DefaultTbCustomerService.java b/application/src/main/java/org/thingsboard/server/service/entitiy/customer/DefaultTbCustomerService.java index c070aea087..a2a0e6846d 100644 --- a/application/src/main/java/org/thingsboard/server/service/entitiy/customer/DefaultTbCustomerService.java +++ b/application/src/main/java/org/thingsboard/server/service/entitiy/customer/DefaultTbCustomerService.java @@ -19,6 +19,7 @@ import lombok.AllArgsConstructor; import org.springframework.stereotype.Service; import org.thingsboard.server.common.data.Customer; import org.thingsboard.server.common.data.EntityType; +import org.thingsboard.server.common.data.NameConflictStrategy; import org.thingsboard.server.common.data.User; import org.thingsboard.server.common.data.audit.ActionType; import org.thingsboard.server.common.data.id.CustomerId; @@ -32,10 +33,15 @@ public class DefaultTbCustomerService extends AbstractTbEntityService implements @Override public Customer save(Customer customer, SecurityUser user) throws Exception { + return save(customer, NameConflictStrategy.DEFAULT, user); + } + + @Override + public Customer save(Customer customer, NameConflictStrategy nameConflictStrategy, SecurityUser user) throws Exception { ActionType actionType = customer.getId() == null ? ActionType.ADDED : ActionType.UPDATED; TenantId tenantId = customer.getTenantId(); try { - Customer savedCustomer = checkNotNull(customerService.saveCustomer(customer)); + Customer savedCustomer = checkNotNull(customerService.saveCustomer(customer, nameConflictStrategy)); autoCommit(user, savedCustomer.getId()); logEntityActionService.logEntityAction(tenantId, savedCustomer.getId(), savedCustomer, null, actionType, user); return savedCustomer; diff --git a/application/src/test/java/org/thingsboard/server/controller/AssetControllerTest.java b/application/src/test/java/org/thingsboard/server/controller/AssetControllerTest.java index 90b5cbeef2..bb0d90e45a 100644 --- a/application/src/test/java/org/thingsboard/server/controller/AssetControllerTest.java +++ b/application/src/test/java/org/thingsboard/server/controller/AssetControllerTest.java @@ -1080,6 +1080,24 @@ public class AssetControllerTest extends AbstractControllerTest { testEntityDaoWithRelationsTransactionalException(assetDao, savedTenant.getId(), assetId, "/api/asset/" + assetId); } + @Test + public void testSaveAssetWithUniquifyStrategy() throws Exception { + Asset asset = new Asset(); + asset.setName("My asset"); + asset.setType("default"); + doPost("/api/asset", asset, Asset.class); + + doPost("/api/asset", asset).andExpect(status().isBadRequest()); + + doPost("/api/asset?policy=FAIL", asset).andExpect(status().isBadRequest()); + + Asset secondAsset = doPost("/api/asset?policy=UNIQUIFY", asset, Asset.class); + assertThat(secondAsset.getName()).startsWith("My asset_"); + + Asset thirdAsset = doPost("/api/asset?policy=UNIQUIFY&separator=-", asset, Asset.class); + assertThat(thirdAsset.getName()).startsWith("My asset-"); + } + private Asset createAsset(String name) { Asset asset = new Asset(); asset.setName(name); diff --git a/application/src/test/java/org/thingsboard/server/controller/CustomerControllerTest.java b/application/src/test/java/org/thingsboard/server/controller/CustomerControllerTest.java index f312e49f71..08eecf3f10 100644 --- a/application/src/test/java/org/thingsboard/server/controller/CustomerControllerTest.java +++ b/application/src/test/java/org/thingsboard/server/controller/CustomerControllerTest.java @@ -462,6 +462,21 @@ public class CustomerControllerTest extends AbstractControllerTest { testEntityDaoWithRelationsTransactionalException(customerDao, savedTenant.getId(), customerId, "/api/customer/" + customerId); } + @Test + public void testSaveCustomerWithUniquifyStrategy() throws Exception { + Customer customer = new Customer(); + customer.setTitle("My customer"); + Customer savedCustomer = doPost("/api/customer", customer, Customer.class); + + doPost("/api/customer?policy=FAIL", customer).andExpect(status().isBadRequest()); + + Customer secondCustomer = doPost("/api/customer?policy=UNIQUIFY", customer, Customer.class); + assertThat(secondCustomer.getName()).startsWith("My customer_"); + + Customer thirdCustomer = doPost("/api/customer?policy=UNIQUIFY&separator=-", customer, Customer.class); + assertThat(thirdCustomer.getName()).startsWith("My customer-"); + } + private Customer createCustomer(String title) { Customer customer = new Customer(); customer.setTitle(title); diff --git a/application/src/test/java/org/thingsboard/server/controller/DeviceControllerTest.java b/application/src/test/java/org/thingsboard/server/controller/DeviceControllerTest.java index 36cede9e84..118e0f1137 100644 --- a/application/src/test/java/org/thingsboard/server/controller/DeviceControllerTest.java +++ b/application/src/test/java/org/thingsboard/server/controller/DeviceControllerTest.java @@ -1608,6 +1608,24 @@ public class DeviceControllerTest extends AbstractControllerTest { assertThat(device.getVersion()).isEqualTo(3); } + @Test + public void testSaveDeviceWithUniquifyStrategy() throws Exception { + Device device = new Device(); + device.setName("My device"); + device.setType("default"); + Device savedDevice = doPost("/api/device", device, Device.class); + + doPost("/api/device", device).andExpect(status().isBadRequest()); + + doPost("/api/device?policy=FAIL", device).andExpect(status().isBadRequest()); + + Device secondDevice = doPost("/api/device?policy=UNIQUIFY", device, Device.class); + assertThat(secondDevice.getName()).startsWith("My device_"); + + Device thirdDevice = doPost("/api/device?policy=UNIQUIFY&separator=-", device, Device.class); + assertThat(thirdDevice.getName()).startsWith("My device-"); + } + private Device createDevice(String name) { Device device = new Device(); device.setName(name); diff --git a/application/src/test/java/org/thingsboard/server/controller/EntityViewControllerTest.java b/application/src/test/java/org/thingsboard/server/controller/EntityViewControllerTest.java index 10b49dfe1d..0549e17e43 100644 --- a/application/src/test/java/org/thingsboard/server/controller/EntityViewControllerTest.java +++ b/application/src/test/java/org/thingsboard/server/controller/EntityViewControllerTest.java @@ -853,4 +853,23 @@ public class EntityViewControllerTest extends AbstractControllerTest { EntityViewId entityViewId = getNewSavedEntityView("EntityView for Test WithRelations Transactional Exception").getId(); testEntityDaoWithRelationsTransactionalException(entityViewDao, tenantId, entityViewId, "/api/entityView/" + entityViewId); } + + @Test + public void testSaveEntityViewWithUniquifyStrategy() throws Exception { + EntityView view = new EntityView(); + view.setEntityId(testDevice.getId()); + view.setTenantId(tenantId); + view.setType("default"); + view.setName("Test device view"); + + EntityView savedView = doPost("/api/entityView", view, EntityView.class); + + doPost("/api/entityView?policy=FAIL", view).andExpect(status().isBadRequest()); + + EntityView secondView = doPost("/api/entityView?policy=UNIQUIFY", view, EntityView.class); + assertThat(secondView.getName()).startsWith("Test device view_"); + + EntityView thirdView = doPost("/api/entityView?policy=UNIQUIFY&separator=-", view, EntityView.class); + assertThat(thirdView.getName()).startsWith("Test device view-"); + } } diff --git a/common/dao-api/src/main/java/org/thingsboard/server/dao/asset/AssetService.java b/common/dao-api/src/main/java/org/thingsboard/server/dao/asset/AssetService.java index a930c86ac9..59c17fb4bd 100644 --- a/common/dao-api/src/main/java/org/thingsboard/server/dao/asset/AssetService.java +++ b/common/dao-api/src/main/java/org/thingsboard/server/dao/asset/AssetService.java @@ -26,15 +26,12 @@ import org.thingsboard.server.common.data.id.AssetId; import org.thingsboard.server.common.data.id.AssetProfileId; import org.thingsboard.server.common.data.id.CustomerId; import org.thingsboard.server.common.data.id.EdgeId; -import org.thingsboard.server.common.data.id.EntityId; -import org.thingsboard.server.common.data.id.HasId; import org.thingsboard.server.common.data.id.TenantId; import org.thingsboard.server.common.data.page.PageData; import org.thingsboard.server.common.data.page.PageLink; import org.thingsboard.server.dao.entity.EntityDaoService; import java.util.List; -import java.util.Optional; public interface AssetService extends EntityDaoService { diff --git a/common/dao-api/src/main/java/org/thingsboard/server/dao/customer/CustomerService.java b/common/dao-api/src/main/java/org/thingsboard/server/dao/customer/CustomerService.java index d478e2099a..1cd5ff9ff1 100644 --- a/common/dao-api/src/main/java/org/thingsboard/server/dao/customer/CustomerService.java +++ b/common/dao-api/src/main/java/org/thingsboard/server/dao/customer/CustomerService.java @@ -17,6 +17,7 @@ package org.thingsboard.server.dao.customer; import com.google.common.util.concurrent.ListenableFuture; import org.thingsboard.server.common.data.Customer; +import org.thingsboard.server.common.data.NameConflictStrategy; import org.thingsboard.server.common.data.id.CustomerId; import org.thingsboard.server.common.data.id.TenantId; import org.thingsboard.server.common.data.page.PageData; @@ -37,6 +38,8 @@ public interface CustomerService extends EntityDaoService { Customer saveCustomer(Customer customer); + Customer saveCustomer(Customer customer, NameConflictStrategy nameConflictStrategy); + void deleteCustomer(TenantId tenantId, CustomerId customerId); Customer findOrCreatePublicCustomer(TenantId tenantId); diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/NameConflictPolicy.java b/common/data/src/main/java/org/thingsboard/server/common/data/NameConflictPolicy.java new file mode 100644 index 0000000000..1685dbc933 --- /dev/null +++ b/common/data/src/main/java/org/thingsboard/server/common/data/NameConflictPolicy.java @@ -0,0 +1,23 @@ +/** + * 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.common.data; + +public enum NameConflictPolicy { + + FAIL, + UNIQUIFY; + +} diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/NameConflictStrategy.java b/common/data/src/main/java/org/thingsboard/server/common/data/NameConflictStrategy.java index b21ddce883..00b72f7223 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/NameConflictStrategy.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/NameConflictStrategy.java @@ -15,9 +15,11 @@ */ package org.thingsboard.server.common.data; -public enum NameConflictStrategy { +import io.swagger.v3.oas.annotations.media.Schema; - FAIL, - UNIQUIFY; +@Schema +public record NameConflictStrategy(NameConflictPolicy policy, String separator) { + + public static final NameConflictStrategy DEFAULT = new NameConflictStrategy(NameConflictPolicy.FAIL, null); } diff --git a/dao/src/main/java/org/thingsboard/server/dao/Dao.java b/dao/src/main/java/org/thingsboard/server/dao/Dao.java index 450b030e08..6c1ab74764 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/Dao.java +++ b/dao/src/main/java/org/thingsboard/server/dao/Dao.java @@ -33,7 +33,9 @@ public interface Dao { ListenableFuture findByIdAsync(TenantId tenantId, UUID id); - EntityInfo findEntityInfoByName(TenantId tenantId, String name); + default EntityInfo findEntityInfoByName(TenantId tenantId, String name) { + throw new UnsupportedOperationException(); + } boolean existsById(TenantId tenantId, UUID id); diff --git a/dao/src/main/java/org/thingsboard/server/dao/asset/AssetDao.java b/dao/src/main/java/org/thingsboard/server/dao/asset/AssetDao.java index 4b24e464bc..7735e718c8 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/asset/AssetDao.java +++ b/dao/src/main/java/org/thingsboard/server/dao/asset/AssetDao.java @@ -243,5 +243,4 @@ public interface AssetDao extends Dao, TenantEntityDao, Exportable PageData findProfileEntityIdInfosByTenantId(UUID tenantId, PageLink pageLink); - EntityInfo findEntityInfoByName(TenantId tenantId, String name); } diff --git a/dao/src/main/java/org/thingsboard/server/dao/asset/BaseAssetService.java b/dao/src/main/java/org/thingsboard/server/dao/asset/BaseAssetService.java index 0414a0f4a7..7df76d9153 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/asset/BaseAssetService.java +++ b/dao/src/main/java/org/thingsboard/server/dao/asset/BaseAssetService.java @@ -26,6 +26,7 @@ import org.springframework.transaction.annotation.Transactional; import org.springframework.transaction.event.TransactionalEventListener; import org.thingsboard.server.common.data.EntitySubtype; import org.thingsboard.server.common.data.EntityType; +import org.thingsboard.server.common.data.NameConflictPolicy; import org.thingsboard.server.common.data.NameConflictStrategy; import org.thingsboard.server.common.data.ProfileEntityIdInfo; import org.thingsboard.server.common.data.StringUtils; @@ -154,7 +155,7 @@ public class BaseAssetService extends AbstractCachedEntityService, TenantEntityDao, Exporta PageData findDeviceInfosByFilter(DeviceInfoFilter filter, PageLink pageLink); - EntityInfo findEntityInfoByName(TenantId tenantId, String name); } diff --git a/dao/src/main/java/org/thingsboard/server/dao/device/DeviceServiceImpl.java b/dao/src/main/java/org/thingsboard/server/dao/device/DeviceServiceImpl.java index adce656bef..6a14805ca7 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/device/DeviceServiceImpl.java +++ b/dao/src/main/java/org/thingsboard/server/dao/device/DeviceServiceImpl.java @@ -36,10 +36,10 @@ import org.thingsboard.server.common.data.DeviceInfoFilter; import org.thingsboard.server.common.data.DeviceProfile; import org.thingsboard.server.common.data.DeviceProfileType; import org.thingsboard.server.common.data.DeviceTransportType; -import org.thingsboard.server.common.data.EntityInfo; import org.thingsboard.server.common.data.EntitySubtype; import org.thingsboard.server.common.data.EntityType; import org.thingsboard.server.common.data.EntityView; +import org.thingsboard.server.common.data.NameConflictPolicy; import org.thingsboard.server.common.data.NameConflictStrategy; import org.thingsboard.server.common.data.ProfileEntityIdInfo; import org.thingsboard.server.common.data.StringUtils; @@ -90,7 +90,6 @@ import java.util.ArrayList; import java.util.List; import java.util.Optional; import java.util.UUID; -import java.util.function.Consumer; import static org.thingsboard.server.dao.DaoUtil.toUUIDs; import static org.thingsboard.server.dao.service.Validator.validateId; @@ -190,7 +189,7 @@ public class DeviceServiceImpl extends CachedVersionedEntityService & HasTenantId & HasName> void uniquifyEntityName(E entity, E oldEntity, Consumer setName, EntityType entityType) { + protected & HasTenantId & HasName> void uniquifyEntityName(E entity, E oldEntity, Consumer setName, EntityType entityType, NameConflictStrategy nameConflictStrategy) { Dao dao = entityDaoRegistry.getDao(entityType); EntityInfo existingEntity = dao.findEntityInfoByName(entity.getTenantId(), entity.getName()); if (existingEntity != null && (oldEntity == null || !existingEntity.getId().equals(oldEntity.getId()))) { - int suffix = 1; + String suffix = StringUtils.randomAlphanumeric(6); while (true) { - String newName = entity.getName() + "-" + suffix; + String newName = entity.getName() + nameConflictStrategy.separator() + suffix; if (dao.findEntityInfoByName(entity.getTenantId(), newName) == null) { setName.accept(newName); break; } - suffix++; + suffix = StringUtils.randomAlphanumeric(6); } } } diff --git a/dao/src/main/java/org/thingsboard/server/dao/entityview/EntityViewServiceImpl.java b/dao/src/main/java/org/thingsboard/server/dao/entityview/EntityViewServiceImpl.java index cd01473d69..9b344a8848 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/entityview/EntityViewServiceImpl.java +++ b/dao/src/main/java/org/thingsboard/server/dao/entityview/EntityViewServiceImpl.java @@ -29,6 +29,7 @@ import org.thingsboard.server.common.data.EntitySubtype; import org.thingsboard.server.common.data.EntityType; import org.thingsboard.server.common.data.EntityView; import org.thingsboard.server.common.data.EntityViewInfo; +import org.thingsboard.server.common.data.NameConflictPolicy; import org.thingsboard.server.common.data.NameConflictStrategy; import org.thingsboard.server.common.data.StringUtils; import org.thingsboard.server.common.data.audit.ActionType; @@ -118,7 +119,7 @@ public class EntityViewServiceImpl extends CachedVersionedEntityService { private final TenantService tenantService; private final CustomerDao customerDao; - @Override - protected void validateCreate(TenantId tenantId, EntityView entityView) { - entityViewDao.findEntityViewByTenantIdAndName(entityView.getTenantId().getId(), entityView.getName()) - .ifPresent(e -> { - throw new DataValidationException("Entity view with such name already exists!"); - }); - } - @Override protected EntityView validateUpdate(TenantId tenantId, EntityView entityView) { var opt = entityViewDao.findEntityViewByTenantIdAndName(entityView.getTenantId().getId(), entityView.getName()); diff --git a/dao/src/main/java/org/thingsboard/server/dao/sql/customer/JpaCustomerDao.java b/dao/src/main/java/org/thingsboard/server/dao/sql/customer/JpaCustomerDao.java index 75e7179391..b6827383b2 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/sql/customer/JpaCustomerDao.java +++ b/dao/src/main/java/org/thingsboard/server/dao/sql/customer/JpaCustomerDao.java @@ -20,6 +20,7 @@ import org.springframework.data.domain.Limit; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.stereotype.Component; import org.thingsboard.server.common.data.Customer; +import org.thingsboard.server.common.data.EntityInfo; import org.thingsboard.server.common.data.EntityType; import org.thingsboard.server.common.data.edqs.fields.CustomerFields; import org.thingsboard.server.common.data.id.CustomerId; @@ -117,6 +118,11 @@ public class JpaCustomerDao extends JpaAbstractDao imp return customerRepository.findNextBatch(id, Limit.of(batchSize)); } + @Override + public EntityInfo findEntityInfoByName(TenantId tenantId, String name) { + return customerRepository.findEntityInfoByName(tenantId.getId(), name); + } + @Override public EntityType getEntityType() { return EntityType.CUSTOMER; diff --git a/dao/src/main/java/org/thingsboard/server/dao/sql/entityview/JpaEntityViewDao.java b/dao/src/main/java/org/thingsboard/server/dao/sql/entityview/JpaEntityViewDao.java index 44d8a09ff4..bd4f39162f 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/sql/entityview/JpaEntityViewDao.java +++ b/dao/src/main/java/org/thingsboard/server/dao/sql/entityview/JpaEntityViewDao.java @@ -21,6 +21,7 @@ import org.springframework.beans.factory.annotation.Autowired; import org.springframework.data.domain.Limit; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.stereotype.Component; +import org.thingsboard.server.common.data.EntityInfo; import org.thingsboard.server.common.data.EntitySubtype; import org.thingsboard.server.common.data.EntityType; import org.thingsboard.server.common.data.EntityView; @@ -230,6 +231,11 @@ public class JpaEntityViewDao extends JpaAbstractDao Date: Tue, 7 Oct 2025 17:29:08 +0300 Subject: [PATCH 076/102] Add support of displayName in EDQS --- .../server/edqs/data/BaseEntityData.java | 22 +++++++++++++++++++ .../server/edqs/data/DeviceData.java | 1 + 2 files changed, 23 insertions(+) diff --git a/common/edqs/src/main/java/org/thingsboard/server/edqs/data/BaseEntityData.java b/common/edqs/src/main/java/org/thingsboard/server/edqs/data/BaseEntityData.java index 21b9ff0c4f..487c534778 100644 --- a/common/edqs/src/main/java/org/thingsboard/server/edqs/data/BaseEntityData.java +++ b/common/edqs/src/main/java/org/thingsboard/server/edqs/data/BaseEntityData.java @@ -20,6 +20,7 @@ import lombok.Setter; import lombok.ToString; import org.thingsboard.server.common.data.AttributeScope; import org.thingsboard.server.common.data.EntityType; +import org.thingsboard.server.common.data.StringUtils; import org.thingsboard.server.common.data.edqs.fields.EntityFields; import org.thingsboard.server.common.data.id.CustomerId; import org.thingsboard.server.common.data.permission.QueryContext; @@ -139,11 +140,32 @@ public abstract class BaseEntityData implements EntityDa case "name" -> getEntityName(); case "ownerName" -> getOwnerName(); case "ownerType" -> getOwnerType(); + case "displayName" -> getDisplayName(); case "entityType" -> Optional.ofNullable(getEntityType()).map(EntityType::name).orElse(""); default -> fields.getAsString(name); }; } + public String getDisplayName(){ + return switch (getEntityType()) { + case DEVICE, ASSET -> StringUtils.isNotEmpty(fields.getLabel()) ? fields.getLabel() : fields.getName(); + case USER -> { + boolean firstNameSet = StringUtils.isNotEmpty(fields.getFirstName()); + boolean lastNameSet = StringUtils.isNotEmpty(fields.getLastName()); + if(firstNameSet && lastNameSet) { + yield fields.getFirstName() + " " + fields.getLastName(); + } else if(firstNameSet) { + yield fields.getFirstName(); + } else if (lastNameSet) { + yield fields.getLastName(); + } else { + yield fields.getEmail(); + } + } + default -> fields.getName(); + }; + } + public String getEntityName() { return getFields().getName(); } diff --git a/common/edqs/src/main/java/org/thingsboard/server/edqs/data/DeviceData.java b/common/edqs/src/main/java/org/thingsboard/server/edqs/data/DeviceData.java index 3a3e5c5792..f47bfcb28a 100644 --- a/common/edqs/src/main/java/org/thingsboard/server/edqs/data/DeviceData.java +++ b/common/edqs/src/main/java/org/thingsboard/server/edqs/data/DeviceData.java @@ -18,6 +18,7 @@ package org.thingsboard.server.edqs.data; import lombok.ToString; import org.thingsboard.server.common.data.AttributeScope; import org.thingsboard.server.common.data.EntityType; +import org.thingsboard.server.common.data.StringUtils; import org.thingsboard.server.common.data.edqs.fields.DeviceFields; import org.thingsboard.server.common.data.query.EntityKeyType; import org.thingsboard.server.common.data.edqs.DataPoint; From 2d4b8a85f9a135088324dad654b5f8eb31685c36 Mon Sep 17 00:00:00 2001 From: dashevchenko Date: Tue, 7 Oct 2025 18:09:21 +0300 Subject: [PATCH 077/102] refactoring --- .../thingsboard/server/controller/AssetController.java | 2 +- .../server/service/device/DeviceBulkImportService.java | 2 +- .../server/service/entitiy/SimpleTbEntityService.java | 5 ----- .../service/entitiy/customer/TbCustomerService.java | 4 ++++ .../service/entitiy/device/DefaultTbDeviceService.java | 10 ++++++++++ .../server/service/entitiy/device/TbDeviceService.java | 4 ++++ .../entitiy/entityview/DefaultTbEntityViewService.java | 5 +++++ .../entitiy/entityview/TbEntityViewService.java | 2 ++ .../org/thingsboard/server/dao/asset/AssetDao.java | 1 - .../org/thingsboard/server/dao/device/DeviceDao.java | 1 - 10 files changed, 27 insertions(+), 9 deletions(-) diff --git a/application/src/main/java/org/thingsboard/server/controller/AssetController.java b/application/src/main/java/org/thingsboard/server/controller/AssetController.java index 15fcfa84ff..8de614d577 100644 --- a/application/src/main/java/org/thingsboard/server/controller/AssetController.java +++ b/application/src/main/java/org/thingsboard/server/controller/AssetController.java @@ -145,7 +145,7 @@ public class AssetController extends BaseController { "UNIQUIFY policy appends a suffix to the entity name, if a name conflict occurs.") @RequestParam(name = "policy", defaultValue = "FAIL") NameConflictPolicy policy, @Parameter(description = "Optional value of name suffix separator used by UNIQUIFY policy. By default, underscore separator is used. " + - "For example, strategy is UNIQUIFY, separator is '-'; if a name conflict occurs for customer asset 'Office A', " + + "For example, strategy is UNIQUIFY, separator is '-'; if a name conflict occurs for asset 'Office A', " + "created asset will have name like 'Office A-7fsh4f'.") @RequestParam(name = "separator", defaultValue = "_") String separator) throws Exception { asset.setTenantId(getTenantId()); diff --git a/application/src/main/java/org/thingsboard/server/service/device/DeviceBulkImportService.java b/application/src/main/java/org/thingsboard/server/service/device/DeviceBulkImportService.java index 952756210c..7bd0dc06ea 100644 --- a/application/src/main/java/org/thingsboard/server/service/device/DeviceBulkImportService.java +++ b/application/src/main/java/org/thingsboard/server/service/device/DeviceBulkImportService.java @@ -130,7 +130,7 @@ public class DeviceBulkImportService extends AbstractBulkImportService { } device.setDeviceProfileId(deviceProfile.getId()); - return tbDeviceService.saveDeviceWithCredentials(device, deviceCredentials, NameConflictStrategy.DEFAULT, user); + return tbDeviceService.saveDeviceWithCredentials(device, deviceCredentials, user); } @Override diff --git a/application/src/main/java/org/thingsboard/server/service/entitiy/SimpleTbEntityService.java b/application/src/main/java/org/thingsboard/server/service/entitiy/SimpleTbEntityService.java index 84d606251c..61d52f00bb 100644 --- a/application/src/main/java/org/thingsboard/server/service/entitiy/SimpleTbEntityService.java +++ b/application/src/main/java/org/thingsboard/server/service/entitiy/SimpleTbEntityService.java @@ -15,7 +15,6 @@ */ package org.thingsboard.server.service.entitiy; -import org.thingsboard.server.common.data.NameConflictStrategy; import org.thingsboard.server.common.data.User; import org.thingsboard.server.service.security.model.SecurityUser; @@ -27,10 +26,6 @@ public interface SimpleTbEntityService { T save(T entity, SecurityUser user) throws Exception; - default T save(T entity, NameConflictStrategy nameConflictPolicy, SecurityUser user) throws Exception { - return save(entity, null); - } - void delete(T entity, User user); } diff --git a/application/src/main/java/org/thingsboard/server/service/entitiy/customer/TbCustomerService.java b/application/src/main/java/org/thingsboard/server/service/entitiy/customer/TbCustomerService.java index f77e990620..5884ae2e48 100644 --- a/application/src/main/java/org/thingsboard/server/service/entitiy/customer/TbCustomerService.java +++ b/application/src/main/java/org/thingsboard/server/service/entitiy/customer/TbCustomerService.java @@ -16,8 +16,12 @@ package org.thingsboard.server.service.entitiy.customer; import org.thingsboard.server.common.data.Customer; +import org.thingsboard.server.common.data.NameConflictStrategy; import org.thingsboard.server.service.entitiy.SimpleTbEntityService; +import org.thingsboard.server.service.security.model.SecurityUser; public interface TbCustomerService extends SimpleTbEntityService { + Customer save(Customer customer, NameConflictStrategy nameConflictStrategy, SecurityUser user) throws Exception; + } diff --git a/application/src/main/java/org/thingsboard/server/service/entitiy/device/DefaultTbDeviceService.java b/application/src/main/java/org/thingsboard/server/service/entitiy/device/DefaultTbDeviceService.java index 85d5de5379..e982adbdf1 100644 --- a/application/src/main/java/org/thingsboard/server/service/entitiy/device/DefaultTbDeviceService.java +++ b/application/src/main/java/org/thingsboard/server/service/entitiy/device/DefaultTbDeviceService.java @@ -55,6 +55,11 @@ public class DefaultTbDeviceService extends AbstractTbEntityService implements T private final DeviceCredentialsService deviceCredentialsService; private final ClaimDevicesService claimDevicesService; + @Override + public Device save(Device device, String accessToken, User user) throws Exception { + return save(device, accessToken, NameConflictStrategy.DEFAULT, user); + } + @Override public Device save(Device device, String accessToken, NameConflictStrategy nameConflictStrategy, User user) throws Exception { ActionType actionType = device.getId() == null ? ActionType.ADDED : ActionType.UPDATED; @@ -72,6 +77,11 @@ public class DefaultTbDeviceService extends AbstractTbEntityService implements T } } + @Override + public Device saveDeviceWithCredentials(Device device, DeviceCredentials credentials, User user) throws ThingsboardException { + return saveDeviceWithCredentials(device, credentials, NameConflictStrategy.DEFAULT, user); + } + @Override public Device saveDeviceWithCredentials(Device device, DeviceCredentials credentials, NameConflictStrategy nameConflictStrategy, User user) throws ThingsboardException { ActionType actionType = device.getId() == null ? ActionType.ADDED : ActionType.UPDATED; diff --git a/application/src/main/java/org/thingsboard/server/service/entitiy/device/TbDeviceService.java b/application/src/main/java/org/thingsboard/server/service/entitiy/device/TbDeviceService.java index 26be8c5db4..e217d8de7b 100644 --- a/application/src/main/java/org/thingsboard/server/service/entitiy/device/TbDeviceService.java +++ b/application/src/main/java/org/thingsboard/server/service/entitiy/device/TbDeviceService.java @@ -32,8 +32,12 @@ import org.thingsboard.server.dao.device.claim.ReclaimResult; public interface TbDeviceService { + Device save(Device device, String accessToken, User user) throws Exception; + Device save(Device device, String accessToken, NameConflictStrategy nameConflictStrategy, User user) throws Exception; + Device saveDeviceWithCredentials(Device device, DeviceCredentials deviceCredentials, User user) throws ThingsboardException; + Device saveDeviceWithCredentials(Device device, DeviceCredentials deviceCredentials, NameConflictStrategy nameConflictStrategy, User user) throws ThingsboardException; void delete(Device device, User user); diff --git a/application/src/main/java/org/thingsboard/server/service/entitiy/entityview/DefaultTbEntityViewService.java b/application/src/main/java/org/thingsboard/server/service/entitiy/entityview/DefaultTbEntityViewService.java index 4daf54afdd..54699e0995 100644 --- a/application/src/main/java/org/thingsboard/server/service/entitiy/entityview/DefaultTbEntityViewService.java +++ b/application/src/main/java/org/thingsboard/server/service/entitiy/entityview/DefaultTbEntityViewService.java @@ -79,6 +79,11 @@ public class DefaultTbEntityViewService extends AbstractTbEntityService implemen final Map>> localCache = new ConcurrentHashMap<>(); + @Override + public EntityView save(EntityView entityView, EntityView existingEntityView, User user) throws Exception { + return save(entityView, existingEntityView, NameConflictStrategy.DEFAULT, user); + } + @Override public EntityView save(EntityView entityView, EntityView existingEntityView, NameConflictStrategy nameConflictStrategy, User user) throws Exception { ActionType actionType = entityView.getId() == null ? ActionType.ADDED : ActionType.UPDATED; diff --git a/application/src/main/java/org/thingsboard/server/service/entitiy/entityview/TbEntityViewService.java b/application/src/main/java/org/thingsboard/server/service/entitiy/entityview/TbEntityViewService.java index f34b4ed335..bb4bcf9cad 100644 --- a/application/src/main/java/org/thingsboard/server/service/entitiy/entityview/TbEntityViewService.java +++ b/application/src/main/java/org/thingsboard/server/service/entitiy/entityview/TbEntityViewService.java @@ -32,6 +32,8 @@ import java.util.List; public interface TbEntityViewService extends ComponentLifecycleListener { + EntityView save(EntityView entityView, EntityView existingEntityView, User user) throws Exception; + EntityView save(EntityView entityView, EntityView existingEntityView, NameConflictStrategy nameConflictStrategy, User user) throws Exception; void updateEntityViewAttributes(TenantId tenantId, EntityView savedEntityView, EntityView oldEntityView, User user) throws ThingsboardException; diff --git a/dao/src/main/java/org/thingsboard/server/dao/asset/AssetDao.java b/dao/src/main/java/org/thingsboard/server/dao/asset/AssetDao.java index 7735e718c8..098dc4e83d 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/asset/AssetDao.java +++ b/dao/src/main/java/org/thingsboard/server/dao/asset/AssetDao.java @@ -16,7 +16,6 @@ package org.thingsboard.server.dao.asset; import com.google.common.util.concurrent.ListenableFuture; -import org.thingsboard.server.common.data.EntityInfo; import org.thingsboard.server.common.data.EntitySubtype; import org.thingsboard.server.common.data.ProfileEntityIdInfo; import org.thingsboard.server.common.data.asset.Asset; diff --git a/dao/src/main/java/org/thingsboard/server/dao/device/DeviceDao.java b/dao/src/main/java/org/thingsboard/server/dao/device/DeviceDao.java index feeac3e336..6bc8903d20 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/device/DeviceDao.java +++ b/dao/src/main/java/org/thingsboard/server/dao/device/DeviceDao.java @@ -21,7 +21,6 @@ import org.thingsboard.server.common.data.DeviceIdInfo; import org.thingsboard.server.common.data.DeviceInfo; import org.thingsboard.server.common.data.DeviceInfoFilter; import org.thingsboard.server.common.data.DeviceTransportType; -import org.thingsboard.server.common.data.EntityInfo; import org.thingsboard.server.common.data.EntitySubtype; import org.thingsboard.server.common.data.ProfileEntityIdInfo; import org.thingsboard.server.common.data.id.DeviceId; From cda9cc47827522e0c04afcd7a89cb07331eb6d79 Mon Sep 17 00:00:00 2001 From: dashevchenko Date: Wed, 8 Oct 2025 11:20:42 +0300 Subject: [PATCH 078/102] refactoring --- .../thingsboard/server/controller/AssetController.java | 10 ++++------ .../server/controller/ControllerConstants.java | 8 ++++++++ .../server/controller/CustomerController.java | 10 ++++------ .../server/controller/DeviceController.java | 10 ++++------ .../server/controller/EntityViewController.java | 10 ++++------ .../thingsboard/server/dao/asset/BaseAssetService.java | 6 +++--- .../server/dao/customer/CustomerServiceImpl.java | 6 +++--- .../server/dao/device/DeviceServiceImpl.java | 6 +++--- .../server/dao/entityview/EntityViewServiceImpl.java | 6 +++--- 9 files changed, 36 insertions(+), 36 deletions(-) diff --git a/application/src/main/java/org/thingsboard/server/controller/AssetController.java b/application/src/main/java/org/thingsboard/server/controller/AssetController.java index 8de614d577..d00725f1c1 100644 --- a/application/src/main/java/org/thingsboard/server/controller/AssetController.java +++ b/application/src/main/java/org/thingsboard/server/controller/AssetController.java @@ -78,6 +78,8 @@ import static org.thingsboard.server.controller.ControllerConstants.EDGE_ASSIGN_ import static org.thingsboard.server.controller.ControllerConstants.EDGE_ID_PARAM_DESCRIPTION; import static org.thingsboard.server.controller.ControllerConstants.EDGE_UNASSIGN_ASYNC_FIRST_STEP_DESCRIPTION; import static org.thingsboard.server.controller.ControllerConstants.EDGE_UNASSIGN_RECEIVE_STEP_DESCRIPTION; +import static org.thingsboard.server.controller.ControllerConstants.NAME_CONFLICT_POLICY_DESC; +import static org.thingsboard.server.controller.ControllerConstants.NAME_CONFLICT_SEPARATOR_DESC; import static org.thingsboard.server.controller.ControllerConstants.PAGE_DATA_PARAMETERS; import static org.thingsboard.server.controller.ControllerConstants.PAGE_NUMBER_DESCRIPTION; import static org.thingsboard.server.controller.ControllerConstants.PAGE_SIZE_DESCRIPTION; @@ -140,13 +142,9 @@ public class AssetController extends BaseController { @RequestMapping(value = "/asset", method = RequestMethod.POST) @ResponseBody public Asset saveAsset(@io.swagger.v3.oas.annotations.parameters.RequestBody(description = "A JSON value representing the asset.") @RequestBody Asset asset, - @Parameter(description = "Optional value of name conflict policy. Possible values: FAIL or UNIQUIFY. " + - "If omitted, FAIL policy is applied. FAIL policy implies exception will be thrown if an entity with the same name already exists. " + - "UNIQUIFY policy appends a suffix to the entity name, if a name conflict occurs.") + @Parameter(description = NAME_CONFLICT_POLICY_DESC) @RequestParam(name = "policy", defaultValue = "FAIL") NameConflictPolicy policy, - @Parameter(description = "Optional value of name suffix separator used by UNIQUIFY policy. By default, underscore separator is used. " + - "For example, strategy is UNIQUIFY, separator is '-'; if a name conflict occurs for asset 'Office A', " + - "created asset will have name like 'Office A-7fsh4f'.") + @Parameter(description = NAME_CONFLICT_SEPARATOR_DESC) @RequestParam(name = "separator", defaultValue = "_") String separator) throws Exception { asset.setTenantId(getTenantId()); checkEntity(asset.getId(), asset, Resource.ASSET); diff --git a/application/src/main/java/org/thingsboard/server/controller/ControllerConstants.java b/application/src/main/java/org/thingsboard/server/controller/ControllerConstants.java index a87864726b..7b5bf8b165 100644 --- a/application/src/main/java/org/thingsboard/server/controller/ControllerConstants.java +++ b/application/src/main/java/org/thingsboard/server/controller/ControllerConstants.java @@ -1744,4 +1744,12 @@ public class ControllerConstants { MARKDOWN_CODE_BLOCK_END ; protected static final String SECURITY_WRITE_CHECK = " Security check is performed to verify that the user has 'WRITE' permission for the entity (entities)."; + + public static final String NAME_CONFLICT_POLICY_DESC = "Optional value of name conflict policy. Possible values: FAIL or UNIQUIFY. " + + " If omitted, FAIL policy is applied. FAIL policy implies exception will be thrown if an entity with the same name already exists. " + + " UNIQUIFY policy appends a suffix to the entity name, if a name conflict occurs."; + + public static final String NAME_CONFLICT_SEPARATOR_DESC = "Optional value of name suffix separator used by UNIQUIFY policy. By default, underscore separator is used. " + + "For example, strategy is UNIQUIFY, separator is '-'; if a name conflict occurs for entity name 'test-name', " + + "created entity will have name like 'test-name-7fsh4f'."; } diff --git a/application/src/main/java/org/thingsboard/server/controller/CustomerController.java b/application/src/main/java/org/thingsboard/server/controller/CustomerController.java index 8f37a55e60..839bcc984f 100644 --- a/application/src/main/java/org/thingsboard/server/controller/CustomerController.java +++ b/application/src/main/java/org/thingsboard/server/controller/CustomerController.java @@ -49,6 +49,8 @@ import static org.thingsboard.server.controller.ControllerConstants.CUSTOMER_ID; import static org.thingsboard.server.controller.ControllerConstants.CUSTOMER_ID_PARAM_DESCRIPTION; import static org.thingsboard.server.controller.ControllerConstants.CUSTOMER_TEXT_SEARCH_DESCRIPTION; import static org.thingsboard.server.controller.ControllerConstants.HOME_DASHBOARD; +import static org.thingsboard.server.controller.ControllerConstants.NAME_CONFLICT_POLICY_DESC; +import static org.thingsboard.server.controller.ControllerConstants.NAME_CONFLICT_SEPARATOR_DESC; import static org.thingsboard.server.controller.ControllerConstants.PAGE_DATA_PARAMETERS; import static org.thingsboard.server.controller.ControllerConstants.PAGE_NUMBER_DESCRIPTION; import static org.thingsboard.server.controller.ControllerConstants.PAGE_SIZE_DESCRIPTION; @@ -131,13 +133,9 @@ public class CustomerController extends BaseController { @RequestMapping(value = "/customer", method = RequestMethod.POST) @ResponseBody public Customer saveCustomer(@io.swagger.v3.oas.annotations.parameters.RequestBody(description = "A JSON value representing the customer.") @RequestBody Customer customer, - @Parameter(description = "Optional value of name conflict policy. Possible values: FAIL or UNIQUIFY. " + - "If omitted, FAIL policy is applied. FAIL policy implies exception will be thrown if an entity with the same name already exists. " + - "UNIQUIFY policy appends a suffix to the entity name, if a name conflict occurs.") + @Parameter(description = NAME_CONFLICT_POLICY_DESC) @RequestParam(name = "policy", defaultValue = "FAIL") NameConflictPolicy policy, - @Parameter(description = "Optional value of name suffix separator used by UNIQUIFY policy. By default, underscore separator is used. " + - "For example, strategy is UNIQUIFY, separator is '-'; if a name conflict occurs for customer name 'Customer A', " + - "created customer will have name like 'Customer A-7fsh4f'.") + @Parameter(description = NAME_CONFLICT_SEPARATOR_DESC) @RequestParam(name = "separator", defaultValue = "_") String separator) throws Exception { customer.setTenantId(getTenantId()); checkEntity(customer.getId(), customer, Resource.CUSTOMER); diff --git a/application/src/main/java/org/thingsboard/server/controller/DeviceController.java b/application/src/main/java/org/thingsboard/server/controller/DeviceController.java index 73d615ffd3..4941c0383f 100644 --- a/application/src/main/java/org/thingsboard/server/controller/DeviceController.java +++ b/application/src/main/java/org/thingsboard/server/controller/DeviceController.java @@ -110,6 +110,8 @@ import static org.thingsboard.server.controller.ControllerConstants.EDGE_ASSIGN_ import static org.thingsboard.server.controller.ControllerConstants.EDGE_ID_PARAM_DESCRIPTION; import static org.thingsboard.server.controller.ControllerConstants.EDGE_UNASSIGN_ASYNC_FIRST_STEP_DESCRIPTION; import static org.thingsboard.server.controller.ControllerConstants.EDGE_UNASSIGN_RECEIVE_STEP_DESCRIPTION; +import static org.thingsboard.server.controller.ControllerConstants.NAME_CONFLICT_POLICY_DESC; +import static org.thingsboard.server.controller.ControllerConstants.NAME_CONFLICT_SEPARATOR_DESC; import static org.thingsboard.server.controller.ControllerConstants.PAGE_DATA_PARAMETERS; import static org.thingsboard.server.controller.ControllerConstants.PAGE_NUMBER_DESCRIPTION; import static org.thingsboard.server.controller.ControllerConstants.PAGE_SIZE_DESCRIPTION; @@ -181,13 +183,9 @@ public class DeviceController extends BaseController { @Parameter(description = "Optional value of the device credentials to be used during device creation. " + "If omitted, access token will be auto-generated.") @RequestParam(name = "accessToken", required = false) String accessToken, - @Parameter(description = "Optional value of name conflict policy. Possible values: FAIL or UNIQUIFY. " + - "If omitted, FAIL policy is applied. FAIL policy implies exception will be thrown if an entity with the same name already exists. " + - "UNIQUIFY policy appends a suffix to the entity name, if a name conflict occurs.") + @Parameter(description = NAME_CONFLICT_POLICY_DESC) @RequestParam(name = "policy", defaultValue = "FAIL") NameConflictPolicy policy, - @Parameter(description = "Optional value of name suffix separator used by UNIQUIFY policy. By default, underscore separator is used. " + - "For example, strategy is UNIQUIFY, separator is '-'; if a name conflict occurs for device name 'thermostat', " + - "created device will have name like 'thermostat-7fsh4f'.") + @Parameter(description = NAME_CONFLICT_SEPARATOR_DESC) @RequestParam(name = "separator", defaultValue = "_") String separator) throws Exception { device.setTenantId(getCurrentUser().getTenantId()); if (device.getId() != null) { 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 37cc7c7797..4cf9a51227 100644 --- a/application/src/main/java/org/thingsboard/server/controller/EntityViewController.java +++ b/application/src/main/java/org/thingsboard/server/controller/EntityViewController.java @@ -71,6 +71,8 @@ import static org.thingsboard.server.controller.ControllerConstants.ENTITY_VIEW_ import static org.thingsboard.server.controller.ControllerConstants.ENTITY_VIEW_TEXT_SEARCH_DESCRIPTION; import static org.thingsboard.server.controller.ControllerConstants.ENTITY_VIEW_TYPE; import static org.thingsboard.server.controller.ControllerConstants.MODEL_DESCRIPTION; +import static org.thingsboard.server.controller.ControllerConstants.NAME_CONFLICT_POLICY_DESC; +import static org.thingsboard.server.controller.ControllerConstants.NAME_CONFLICT_SEPARATOR_DESC; import static org.thingsboard.server.controller.ControllerConstants.PAGE_DATA_PARAMETERS; import static org.thingsboard.server.controller.ControllerConstants.PAGE_NUMBER_DESCRIPTION; import static org.thingsboard.server.controller.ControllerConstants.PAGE_SIZE_DESCRIPTION; @@ -131,13 +133,9 @@ public class EntityViewController extends BaseController { public EntityView saveEntityView( @Parameter(description = "A JSON object representing the entity view.") @RequestBody EntityView entityView, - @Parameter(description = "Optional value of name conflict policy. Possible values: FAIL or UNIQUIFY. " + - "If omitted, FAIL policy is applied. FAIL policy implies exception will be thrown if an entity with the same name already exists. " + - "UNIQUIFY policy appends a suffix to the entity name, if a name conflict occurs.") + @Parameter(description = NAME_CONFLICT_POLICY_DESC) @RequestParam(name = "policy", defaultValue = "FAIL") NameConflictPolicy policy, - @Parameter(description = "Optional value of name suffix separator used by UNIQUIFY policy. By default, underscore separator is used. " + - "For example, strategy is UNIQUIFY, separator is '-'; if a name conflict occurs for entity view name 'Device A', " + - "created customer will have name like 'Device A-7fsh4f'.") + @Parameter(description = NAME_CONFLICT_SEPARATOR_DESC) @RequestParam(name = "separator", defaultValue = "_") String separator) throws Exception { entityView.setTenantId(getCurrentUser().getTenantId()); EntityView existingEntityView = null; diff --git a/dao/src/main/java/org/thingsboard/server/dao/asset/BaseAssetService.java b/dao/src/main/java/org/thingsboard/server/dao/asset/BaseAssetService.java index 7df76d9153..ad6adee1ee 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/asset/BaseAssetService.java +++ b/dao/src/main/java/org/thingsboard/server/dao/asset/BaseAssetService.java @@ -166,9 +166,6 @@ public class BaseAssetService extends AbstractCachedEntityService Date: Wed, 8 Oct 2025 11:30:40 +0300 Subject: [PATCH 079/102] minor refactoring --- .../thingsboard/server/controller/DeviceController.java | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/application/src/main/java/org/thingsboard/server/controller/DeviceController.java b/application/src/main/java/org/thingsboard/server/controller/DeviceController.java index 4941c0383f..a5efdc1ca4 100644 --- a/application/src/main/java/org/thingsboard/server/controller/DeviceController.java +++ b/application/src/main/java/org/thingsboard/server/controller/DeviceController.java @@ -219,13 +219,9 @@ public class DeviceController extends BaseController { @ResponseBody public Device saveDeviceWithCredentials(@Parameter(description = "The JSON object with device and credentials. See method description above for example.") @Valid @RequestBody SaveDeviceWithCredentialsRequest deviceAndCredentials, - @Parameter(description = "Optional value of name conflict policy. Possible values: FAIL or UNIQUIFY. " + - "If omitted, FAIL policy is applied. FAIL policy implies exception will be thrown if an entity with the same name already exists. " + - "UNIQUIFY policy appends a suffix to the entity name, if a name conflict occurs.") + @Parameter(description = NAME_CONFLICT_POLICY_DESC) @RequestParam(name = "policy", defaultValue = "FAIL") NameConflictPolicy policy, - @Parameter(description = "Optional value of name suffix separator used by UNIQUIFY policy. By default, underscore separator is used. " + - "For example, strategy is UNIQUIFY, separator is '-'; if a name conflict occurs for device name 'thermostat', " + - "created device will have name like 'thermostat-7fsh4f'.") + @Parameter(description = NAME_CONFLICT_SEPARATOR_DESC) @RequestParam(name = "separator", defaultValue = "_") String separator) throws ThingsboardException { Device device = deviceAndCredentials.getDevice(); DeviceCredentials credentials = deviceAndCredentials.getCredentials(); From 494fb7abeb51b1d9b3d92e122bc435d974d3cbae Mon Sep 17 00:00:00 2001 From: Vladyslav_Prykhodko Date: Wed, 8 Oct 2025 11:46:45 +0300 Subject: [PATCH 080/102] UI: Add new entity field - displayName --- ui-ngx/src/app/core/http/entity.service.ts | 2 ++ ui-ngx/src/app/shared/models/entity.models.ts | 5 +++++ 2 files changed, 7 insertions(+) diff --git a/ui-ngx/src/app/core/http/entity.service.ts b/ui-ngx/src/app/core/http/entity.service.ts index c652ba39ba..5cd4167afa 100644 --- a/ui-ngx/src/app/core/http/entity.service.ts +++ b/ui-ngx/src/app/core/http/entity.service.ts @@ -817,6 +817,7 @@ export class EntityService { switch (entityType) { case EntityType.USER: entityFieldKeys.push(entityFields.name.keyName); + entityFieldKeys.push(entityFields.displayName.keyName); entityFieldKeys.push(entityFields.email.keyName); entityFieldKeys.push(entityFields.firstName.keyName); entityFieldKeys.push(entityFields.lastName.keyName); @@ -846,6 +847,7 @@ export class EntityService { case EntityType.EDGE: case EntityType.ASSET: entityFieldKeys.push(entityFields.name.keyName); + entityFieldKeys.push(entityFields.displayName.keyName); entityFieldKeys.push(entityFields.type.keyName); entityFieldKeys.push(entityFields.label.keyName); entityFieldKeys.push(entityFields.ownerName.keyName); diff --git a/ui-ngx/src/app/shared/models/entity.models.ts b/ui-ngx/src/app/shared/models/entity.models.ts index 5aa526b583..ba171b05e5 100644 --- a/ui-ngx/src/app/shared/models/entity.models.ts +++ b/ui-ngx/src/app/shared/models/entity.models.ts @@ -163,6 +163,11 @@ export const entityFields: {[fieldName: string]: EntityField} = { name: 'entity-field.label', value: 'label' }, + displayName: { + keyName: 'displayName', + name: 'entity-field.name', + value: 'name' + }, queueName: { keyName: 'queueName', name: 'entity-field.queue-name', From a549340010362abe3bfd6ebc635f29bbe8d8edc4 Mon Sep 17 00:00:00 2001 From: ArtemDzhereleiko Date: Wed, 8 Oct 2025 15:07:21 +0300 Subject: [PATCH 081/102] UI: Fixed Genaral resources --- .../resources-library.component.html | 20 +++++++++---------- .../resources/resources-library.component.ts | 4 ++++ .../resources-library-table-config.resolve.ts | 2 +- 3 files changed, 14 insertions(+), 12 deletions(-) diff --git a/ui-ngx/src/app/modules/home/components/resources/resources-library.component.html b/ui-ngx/src/app/modules/home/components/resources/resources-library.component.html index 4737b75ef2..819753e105 100644 --- a/ui-ngx/src/app/modules/home/components/resources/resources-library.component.html +++ b/ui-ngx/src/app/modules/home/components/resources/resources-library.component.html @@ -30,7 +30,7 @@ @@ -48,16 +48,14 @@
    - @if (resourceTypes.length > 1) { - - resource.resource-type - - - {{ resourceTypesTranslationMap.get(resourceType) | translate }} - - - - } + + resource.resource-type + + + {{ resourceTypesTranslationMap.get(resourceType) | translate }} + + + resource.title diff --git a/ui-ngx/src/app/modules/home/components/resources/resources-library.component.ts b/ui-ngx/src/app/modules/home/components/resources/resources-library.component.ts index e3ad0e15f3..977eb6873a 100644 --- a/ui-ngx/src/app/modules/home/components/resources/resources-library.component.ts +++ b/ui-ngx/src/app/modules/home/components/resources/resources-library.component.ts @@ -112,6 +112,10 @@ export class ResourcesLibraryComponent extends EntityComponent impleme if (this.isEdit && this.entityForm && !this.isAdd) { this.entityForm.get('resourceType').disable({ emitEvent: false }); this.entityForm.get('fileName').disable({ emitEvent: false }); + this.entityForm.get('data').disable({ emitEvent: false }); + } + if (this.isAdd && this.resourceTypes.length === 1) { + this.entityForm.get('resourceType').disable({ emitEvent: false }); } } diff --git a/ui-ngx/src/app/modules/home/pages/admin/resource/resources-library-table-config.resolve.ts b/ui-ngx/src/app/modules/home/pages/admin/resource/resources-library-table-config.resolve.ts index d92355fbac..1bce24f984 100644 --- a/ui-ngx/src/app/modules/home/pages/admin/resource/resources-library-table-config.resolve.ts +++ b/ui-ngx/src/app/modules/home/pages/admin/resource/resources-library-table-config.resolve.ts @@ -173,7 +173,7 @@ export class ResourcesLibraryTableConfigResolver { case 'downloadResource': this.downloadResource(action.event, action.entity); return true; - case 'deleteLibrary': + case 'deleteResource': this.deleteResource(action.event, action.entity); } return false; From bae703030276c8cd6d539c520d0bee21a479a212 Mon Sep 17 00:00:00 2001 From: Vladyslav_Prykhodko Date: Wed, 8 Oct 2025 15:44:29 +0300 Subject: [PATCH 082/102] Fixed LABELED_ENTITY_DISPLAY_NAME_SELECT_QUERY in sql --- .../org/thingsboard/server/dao/sql/query/EntityKeyMapping.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dao/src/main/java/org/thingsboard/server/dao/sql/query/EntityKeyMapping.java b/dao/src/main/java/org/thingsboard/server/dao/sql/query/EntityKeyMapping.java index dda2cf127d..ea5c3e8f9d 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/sql/query/EntityKeyMapping.java +++ b/dao/src/main/java/org/thingsboard/server/dao/sql/query/EntityKeyMapping.java @@ -84,7 +84,7 @@ public class EntityKeyMapping { public static final String SERVICE_ID = "serviceId"; public static final String OWNER_NAME = "ownerName"; public static final String OWNER_TYPE = "ownerType"; - public static final String LABELED_ENTITY_DISPLAY_NAME_SELECT_QUERY = "COALESCE (e." + LABEL + ", e." + NAME+")"; + public static final String LABELED_ENTITY_DISPLAY_NAME_SELECT_QUERY = "COALESCE(NULLIF(e." + LABEL + ", ''), e." + NAME + ")"; public static final String USER_DISPLAY_NAME_SELECT_QUERY = "COALESCE(NULLIF(TRIM(CONCAT_WS(' ', e.first_name, e.last_name)), ''), e.email)"; public static final String OWNER_NAME_SELECT_QUERY = "case when e.customer_id = '" + NULL_UUID + "' " + "then (select title from tenant where id = e.tenant_id) " + From e3967e31fc83ed1cb6032d7ab64760bfa15c351d Mon Sep 17 00:00:00 2001 From: Vladyslav_Prykhodko Date: Wed, 8 Oct 2025 15:52:33 +0300 Subject: [PATCH 083/102] Sync edqs and sql logics to display entity name --- .../org/thingsboard/server/edqs/data/BaseEntityData.java | 6 +++--- .../thingsboard/server/dao/sql/query/EntityKeyMapping.java | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/common/edqs/src/main/java/org/thingsboard/server/edqs/data/BaseEntityData.java b/common/edqs/src/main/java/org/thingsboard/server/edqs/data/BaseEntityData.java index 487c534778..b75ce6a315 100644 --- a/common/edqs/src/main/java/org/thingsboard/server/edqs/data/BaseEntityData.java +++ b/common/edqs/src/main/java/org/thingsboard/server/edqs/data/BaseEntityData.java @@ -148,10 +148,10 @@ public abstract class BaseEntityData implements EntityDa public String getDisplayName(){ return switch (getEntityType()) { - case DEVICE, ASSET -> StringUtils.isNotEmpty(fields.getLabel()) ? fields.getLabel() : fields.getName(); + case DEVICE, ASSET -> StringUtils.isNotBlank(fields.getLabel()) ? fields.getLabel() : fields.getName(); case USER -> { - boolean firstNameSet = StringUtils.isNotEmpty(fields.getFirstName()); - boolean lastNameSet = StringUtils.isNotEmpty(fields.getLastName()); + boolean firstNameSet = StringUtils.isNotBlank(fields.getFirstName()); + boolean lastNameSet = StringUtils.isNotBlank(fields.getLastName()); if(firstNameSet && lastNameSet) { yield fields.getFirstName() + " " + fields.getLastName(); } else if(firstNameSet) { diff --git a/dao/src/main/java/org/thingsboard/server/dao/sql/query/EntityKeyMapping.java b/dao/src/main/java/org/thingsboard/server/dao/sql/query/EntityKeyMapping.java index ea5c3e8f9d..34bc719639 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/sql/query/EntityKeyMapping.java +++ b/dao/src/main/java/org/thingsboard/server/dao/sql/query/EntityKeyMapping.java @@ -84,7 +84,7 @@ public class EntityKeyMapping { public static final String SERVICE_ID = "serviceId"; public static final String OWNER_NAME = "ownerName"; public static final String OWNER_TYPE = "ownerType"; - public static final String LABELED_ENTITY_DISPLAY_NAME_SELECT_QUERY = "COALESCE(NULLIF(e." + LABEL + ", ''), e." + NAME + ")"; + public static final String LABELED_ENTITY_DISPLAY_NAME_SELECT_QUERY = "COALESCE(NULLIF(TRIM(e." + LABEL + "), ''), e." + NAME + ")"; public static final String USER_DISPLAY_NAME_SELECT_QUERY = "COALESCE(NULLIF(TRIM(CONCAT_WS(' ', e.first_name, e.last_name)), ''), e.email)"; public static final String OWNER_NAME_SELECT_QUERY = "case when e.customer_id = '" + NULL_UUID + "' " + "then (select title from tenant where id = e.tenant_id) " + From 7ee879a9c1d5001706c551ec34c12b69195c6cc9 Mon Sep 17 00:00:00 2001 From: Vladyslav_Prykhodko Date: Wed, 8 Oct 2025 16:30:40 +0300 Subject: [PATCH 084/102] UI: Add useEntityDisplayName input property in entity-autocomplete.component and entity-list.component --- .../entity/entity-autocomplete.component.html | 2 +- .../entity/entity-autocomplete.component.ts | 8 ++++++-- .../entity/entity-list-select.component.html | 1 + .../entity/entity-list-select.component.ts | 3 +++ .../components/entity/entity-list.component.html | 4 ++-- .../components/entity/entity-list.component.ts | 8 ++++++-- .../components/entity/entity-select.component.html | 1 + .../components/entity/entity-select.component.ts | 4 ++++ ui-ngx/src/app/shared/models/base-data.ts | 13 ++++++++++++- 9 files changed, 36 insertions(+), 8 deletions(-) diff --git a/ui-ngx/src/app/shared/components/entity/entity-autocomplete.component.html b/ui-ngx/src/app/shared/components/entity/entity-autocomplete.component.html index db7300530e..512ae42921 100644 --- a/ui-ngx/src/app/shared/components/entity/entity-autocomplete.component.html +++ b/ui-ngx/src/app/shared/components/entity/entity-autocomplete.component.html @@ -60,7 +60,7 @@ #entityAutocomplete="matAutocomplete" [displayWith]="displayEntityFn"> - +
    diff --git a/ui-ngx/src/app/shared/components/entity/entity-autocomplete.component.ts b/ui-ngx/src/app/shared/components/entity/entity-autocomplete.component.ts index 9137a3b2d7..6f8b39c171 100644 --- a/ui-ngx/src/app/shared/components/entity/entity-autocomplete.component.ts +++ b/ui-ngx/src/app/shared/components/entity/entity-autocomplete.component.ts @@ -22,7 +22,7 @@ import { catchError, debounceTime, map, share, switchMap, tap } from 'rxjs/opera import { Store } from '@ngrx/store'; import { AppState } from '@app/core/core.state'; import { AliasEntityType, EntityType } from '@shared/models/entity-type.models'; -import { BaseData } from '@shared/models/base-data'; +import { BaseData, getEntityDisplayName } from '@shared/models/base-data'; import { EntityId } from '@shared/models/id/entity-id'; import { EntityService } from '@core/http/entity.service'; import { getCurrentAuthUser } from '@core/auth/auth.selectors'; @@ -138,6 +138,10 @@ export class EntityAutocompleteComponent implements ControlValueAccessor, OnInit @coerceArray() additionalClasses: Array; + @Input() + @coerceBoolean() + useEntityDisplayName = false; + @Output() entityChanged = new EventEmitter>(); @@ -395,7 +399,7 @@ export class EntityAutocompleteComponent implements ControlValueAccessor, OnInit } displayEntityFn(entity?: BaseData): string | undefined { - return entity ? entity.name : undefined; + return entity ? (this.useEntityDisplayName ? getEntityDisplayName(entity) : entity.name) : undefined; } private fetchEntities(searchText?: string): Observable>> { diff --git a/ui-ngx/src/app/shared/components/entity/entity-list-select.component.html b/ui-ngx/src/app/shared/components/entity/entity-list-select.component.html index f3983111bf..0ae793a1ac 100644 --- a/ui-ngx/src/app/shared/components/entity/entity-list-select.component.html +++ b/ui-ngx/src/app/shared/components/entity/entity-list-select.component.html @@ -36,6 +36,7 @@ *ngIf="modelValue.entityType" [required]="required" [entityType]="modelValue.entityType" + [useEntityDisplayName]="useEntityDisplayName" formControlName="entityIds">
    diff --git a/ui-ngx/src/app/shared/components/entity/entity-list-select.component.ts b/ui-ngx/src/app/shared/components/entity/entity-list-select.component.ts index e4bef51d15..7c2c4e7f2a 100644 --- a/ui-ngx/src/app/shared/components/entity/entity-list-select.component.ts +++ b/ui-ngx/src/app/shared/components/entity/entity-list-select.component.ts @@ -68,6 +68,9 @@ export class EntityListSelectComponent implements ControlValueAccessor, OnInit { @Input() additionEntityTypes: {[key in string]: string} = {}; + @Input({transform: booleanAttribute}) + useEntityDisplayName = false; + displayEntityTypeSelect: boolean; private defaultEntityType: EntityType | AliasEntityType = null; diff --git a/ui-ngx/src/app/shared/components/entity/entity-list.component.html b/ui-ngx/src/app/shared/components/entity/entity-list.component.html index e0e7dfe3f7..33c684285e 100644 --- a/ui-ngx/src/app/shared/components/entity/entity-list.component.html +++ b/ui-ngx/src/app/shared/components/entity/entity-list.component.html @@ -28,7 +28,7 @@ class="tb-chip-row-ellipsis" [removable]="!disabled" (removed)="remove(entity)"> - {{entity.name}} + {{ displayEntityFn(entity) }} close - +
    diff --git a/ui-ngx/src/app/shared/components/entity/entity-list.component.ts b/ui-ngx/src/app/shared/components/entity/entity-list.component.ts index 9d1de180e9..f93172c9b0 100644 --- a/ui-ngx/src/app/shared/components/entity/entity-list.component.ts +++ b/ui-ngx/src/app/shared/components/entity/entity-list.component.ts @@ -39,7 +39,7 @@ import { Observable } from 'rxjs'; import { filter, map, mergeMap, share, tap } from 'rxjs/operators'; import { TranslateService } from '@ngx-translate/core'; import { EntityType } from '@shared/models/entity-type.models'; -import { BaseData } from '@shared/models/base-data'; +import { BaseData, getEntityDisplayName } from '@shared/models/base-data'; import { EntityId } from '@shared/models/id/entity-id'; import { EntityService } from '@core/http/entity.service'; import { MatAutocomplete } from '@angular/material/autocomplete'; @@ -125,6 +125,10 @@ export class EntityListComponent implements ControlValueAccessor, OnInit, OnChan @coerceBoolean() allowCreateNew: boolean; + @Input() + @coerceBoolean() + useEntityDisplayName = false; + @Output() createNew = new EventEmitter(); @@ -277,7 +281,7 @@ export class EntityListComponent implements ControlValueAccessor, OnInit, OnChan } public displayEntityFn(entity?: BaseData): string | undefined { - return entity ? entity.name : undefined; + return entity ? (this.useEntityDisplayName ? getEntityDisplayName(entity) : entity.name) : undefined; } private fetchEntities(searchText?: string): Observable>> { diff --git a/ui-ngx/src/app/shared/components/entity/entity-select.component.html b/ui-ngx/src/app/shared/components/entity/entity-select.component.html index 2b07af9e08..c0b4f1aafb 100644 --- a/ui-ngx/src/app/shared/components/entity/entity-select.component.html +++ b/ui-ngx/src/app/shared/components/entity/entity-select.component.html @@ -33,6 +33,7 @@ [appearance]="appearance" [required]="required" [entityType]="modelValue.entityType" + [useEntityDisplayName]="useEntityDisplayName" formControlName="entityId">
    diff --git a/ui-ngx/src/app/shared/components/entity/entity-select.component.ts b/ui-ngx/src/app/shared/components/entity/entity-select.component.ts index 01b1f03388..5e75426952 100644 --- a/ui-ngx/src/app/shared/components/entity/entity-select.component.ts +++ b/ui-ngx/src/app/shared/components/entity/entity-select.component.ts @@ -62,6 +62,10 @@ export class EntitySelectComponent implements ControlValueAccessor, OnInit, Afte @Input() appearance: MatFormFieldAppearance = 'fill'; + @Input() + @coerceBoolean() + useEntityDisplayName = false; + displayEntityTypeSelect: boolean; AliasEntityType = AliasEntityType; diff --git a/ui-ngx/src/app/shared/models/base-data.ts b/ui-ngx/src/app/shared/models/base-data.ts index 972fa0b5a0..d9dc4b070d 100644 --- a/ui-ngx/src/app/shared/models/base-data.ts +++ b/ui-ngx/src/app/shared/models/base-data.ts @@ -16,7 +16,9 @@ import { EntityId } from '@shared/models/id/entity-id'; import { HasUUID } from '@shared/models/id/has-uuid'; -import { isDefinedAndNotNull } from '@core/utils'; +import { isDefinedAndNotNull, isNotEmptyStr } from '@core/utils'; +import { EntityType } from '@shared/models/entity-type.models'; +import { User } from '@shared/models/user.model'; export declare type HasId = EntityId | HasUUID; @@ -49,3 +51,12 @@ export function hasIdEquals(id1: HasId, id2: HasId): boolean { return id1 === id2; } } + +export function getEntityDisplayName(entity: BaseData): string { + if (entity?.id?.entityType === EntityType.USER) { + const user = entity as User; + const userName = (user?.firstName ?? '') + " " + (user?.lastName ?? ''); + return isNotEmptyStr(userName) ? userName.trim() : entity?.name; + } + return isNotEmptyStr(entity?.label) ? entity.label : entity?.name; +} From 60f51a01b9acf7321542f09ee5d6087b3849f716 Mon Sep 17 00:00:00 2001 From: Vladyslav_Prykhodko Date: Wed, 8 Oct 2025 19:11:58 +0300 Subject: [PATCH 085/102] UI: Add new alarm field - originatorDisplayName --- .../data/json/system/widget_types/alarms_table.json | 13 ++++++------- .../json/system/widget_types/entities_table.json | 13 ++++++------- ui-ngx/src/app/shared/models/alarm.models.ts | 7 +++++++ 3 files changed, 19 insertions(+), 14 deletions(-) diff --git a/application/src/main/data/json/system/widget_types/alarms_table.json b/application/src/main/data/json/system/widget_types/alarms_table.json index 4f86957f1c..446129972b 100644 --- a/application/src/main/data/json/system/widget_types/alarms_table.json +++ b/application/src/main/data/json/system/widget_types/alarms_table.json @@ -12,18 +12,12 @@ "templateHtml": "\n", "templateCss": "", "controllerScript": "self.onInit = function() {\n}\n\nself.onDataUpdated = function() {\n self.ctx.$scope.alarmsTableWidget.onDataUpdated();\n}\n\nself.onEditModeChanged = function() {\n self.ctx.$scope.alarmsTableWidget.onEditModeChanged();\n}\n\nself.typeParameters = function() {\n return {\n supportsUnitConversion: true\n };\n};\n\nself.actionSources = function() {\n return {\n 'actionCellButton': {\n name: 'widget-action.action-cell-button',\n multiple: true,\n hasShowCondition: true\n },\n 'rowClick': {\n name: 'widget-action.row-click',\n multiple: false\n },\n 'cellClick': {\n name: 'widget-action.cell-click',\n multiple: true\n }\n };\n}\n\nself.onDestroy = function() {\n}\n", - "settingsSchema": "", - "dataKeySettingsSchema": "", "settingsDirective": "tb-alarms-table-widget-settings", "dataKeySettingsDirective": "tb-alarms-table-key-settings", "hasBasicMode": true, "basicModeDirective": "tb-alarms-table-basic-config", - "defaultConfig": "{\"timewindow\":{\"realtime\":{\"interval\":1000,\"timewindowMs\":86400000},\"aggregation\":{\"type\":\"NONE\",\"limit\":200}},\"showTitle\":true,\"backgroundColor\":\"rgb(255, 255, 255)\",\"color\":\"rgba(0, 0, 0, 0.87)\",\"padding\":\"4px\",\"settings\":{\"enableSelection\":true,\"enableSearch\":true,\"displayDetails\":true,\"allowAcknowledgment\":true,\"allowClear\":true,\"allowAssign\":true,\"displayActivity\":true,\"displayPagination\":true,\"defaultPageSize\":10,\"defaultSortOrder\":\"-createdTime\",\"enableSelectColumnDisplay\":true,\"enableStickyAction\":false,\"enableFilter\":true,\"entitiesTitle\":null,\"alarmsTitle\":\"Alarms\"},\"title\":\"Alarms table\",\"dropShadow\":true,\"enableFullscreen\":true,\"titleStyle\":{\"fontSize\":\"16px\",\"fontWeight\":400,\"padding\":\"5px 10px 5px 10px\"},\"useDashboardTimewindow\":false,\"showLegend\":false,\"alarmSource\":{\"type\":\"function\",\"dataKeys\":[{\"name\":\"createdTime\",\"type\":\"alarm\",\"label\":\"Created time\",\"color\":\"#2196f3\",\"settings\":{\"useCellStyleFunction\":false,\"cellStyleFunction\":\"\",\"useCellContentFunction\":false,\"cellContentFunction\":\"\"},\"_hash\":0.021092237451093787},{\"name\":\"originator\",\"type\":\"alarm\",\"label\":\"Originator\",\"color\":\"#4caf50\",\"settings\":{\"useCellStyleFunction\":false,\"cellStyleFunction\":\"\",\"useCellContentFunction\":false,\"cellContentFunction\":\"\"},\"_hash\":0.2780007688856758},{\"name\":\"type\",\"type\":\"alarm\",\"label\":\"Type\",\"color\":\"#f44336\",\"settings\":{\"useCellStyleFunction\":false,\"cellStyleFunction\":\"\",\"useCellContentFunction\":false,\"cellContentFunction\":\"\"},\"_hash\":0.7323586880398418},{\"name\":\"severity\",\"type\":\"alarm\",\"label\":\"Severity\",\"color\":\"#ffc107\",\"settings\":{\"useCellStyleFunction\":false,\"useCellContentFunction\":false},\"_hash\":0.09927019860088193},{\"name\":\"status\",\"type\":\"alarm\",\"label\":\"Status\",\"color\":\"#607d8b\",\"settings\":{\"useCellStyleFunction\":false,\"cellStyleFunction\":\"\",\"useCellContentFunction\":false,\"cellContentFunction\":\"\"},\"_hash\":0.6588418951443418},{\"name\":\"assignee\",\"type\":\"alarm\",\"label\":\"Assignee\",\"color\":\"#9c27b0\",\"settings\":{},\"_hash\":0.5008441077416634}],\"entityAliasId\":null,\"name\":\"alarms\"},\"alarmSearchStatus\":\"ANY\",\"alarmsPollingInterval\":5,\"showTitleIcon\":false,\"titleIcon\":\"warning\",\"iconColor\":\"rgba(0, 0, 0, 0.87)\",\"iconSize\":\"24px\",\"titleTooltip\":\"\",\"widgetStyle\":{},\"displayTimewindow\":true,\"actions\":{},\"alarmStatusList\":[],\"alarmSeverityList\":[],\"alarmTypeList\":[],\"searchPropagatedAlarms\":false,\"configMode\":\"basic\",\"alarmFilterConfig\":null}" + "defaultConfig": "{\"timewindow\":{\"realtime\":{\"interval\":1000,\"timewindowMs\":86400000},\"aggregation\":{\"type\":\"NONE\",\"limit\":200}},\"showTitle\":true,\"backgroundColor\":\"rgb(255, 255, 255)\",\"color\":\"rgba(0, 0, 0, 0.87)\",\"padding\":\"4px\",\"settings\":{\"enableSelection\":true,\"enableSearch\":true,\"displayDetails\":true,\"allowAcknowledgment\":true,\"allowClear\":true,\"allowAssign\":true,\"displayActivity\":true,\"displayPagination\":true,\"defaultPageSize\":10,\"defaultSortOrder\":\"-createdTime\",\"enableSelectColumnDisplay\":true,\"enableStickyAction\":false,\"enableFilter\":true,\"entitiesTitle\":null,\"alarmsTitle\":\"Alarms\"},\"title\":\"Alarms table\",\"dropShadow\":true,\"enableFullscreen\":true,\"titleStyle\":{\"fontSize\":\"16px\",\"fontWeight\":400,\"padding\":\"5px 10px 5px 10px\"},\"useDashboardTimewindow\":false,\"showLegend\":false,\"alarmSource\":{\"type\":\"function\",\"dataKeys\":[{\"name\":\"createdTime\",\"type\":\"alarm\",\"label\":\"Created time\",\"color\":\"#2196f3\",\"settings\":{\"useCellStyleFunction\":false,\"cellStyleFunction\":\"\",\"useCellContentFunction\":false,\"cellContentFunction\":\"\"},\"_hash\":0.021092237451093787},{\"name\":\"originatorDisplayName\",\"type\":\"alarm\",\"label\":\"Originator\",\"color\":\"#4caf50\",\"settings\":{\"useCellStyleFunction\":false,\"cellStyleFunction\":\"\",\"useCellContentFunction\":false,\"cellContentFunction\":\"\"},\"_hash\":0.2780007688856758},{\"name\":\"type\",\"type\":\"alarm\",\"label\":\"Type\",\"color\":\"#f44336\",\"settings\":{\"useCellStyleFunction\":false,\"cellStyleFunction\":\"\",\"useCellContentFunction\":false,\"cellContentFunction\":\"\"},\"_hash\":0.7323586880398418},{\"name\":\"severity\",\"type\":\"alarm\",\"label\":\"Severity\",\"color\":\"#ffc107\",\"settings\":{\"useCellStyleFunction\":false,\"useCellContentFunction\":false},\"_hash\":0.09927019860088193},{\"name\":\"status\",\"type\":\"alarm\",\"label\":\"Status\",\"color\":\"#607d8b\",\"settings\":{\"useCellStyleFunction\":false,\"cellStyleFunction\":\"\",\"useCellContentFunction\":false,\"cellContentFunction\":\"\"},\"_hash\":0.6588418951443418},{\"name\":\"assignee\",\"type\":\"alarm\",\"label\":\"Assignee\",\"color\":\"#9c27b0\",\"settings\":{},\"_hash\":0.5008441077416634}],\"entityAliasId\":null,\"name\":\"alarms\"},\"alarmSearchStatus\":\"ANY\",\"alarmsPollingInterval\":5,\"showTitleIcon\":false,\"titleIcon\":\"warning\",\"iconColor\":\"rgba(0, 0, 0, 0.87)\",\"iconSize\":\"24px\",\"titleTooltip\":\"\",\"widgetStyle\":{},\"displayTimewindow\":true,\"actions\":{},\"alarmStatusList\":[],\"alarmSeverityList\":[],\"alarmTypeList\":[],\"searchPropagatedAlarms\":false,\"configMode\":\"basic\",\"alarmFilterConfig\":null}" }, - "tags": [ - "alert", - "alerts" - ], "resources": [ { "link": "/api/images/system/alarms_table_system_widget_image.png", @@ -36,5 +30,10 @@ "data": "iVBORw0KGgoAAAANSUhEUgAAAMgAAACgCAIAAADGnbT+AAAABmJLR0QA/wD/AP+gvaeTAAAUUElEQVR42u2dh1sUVxeH/ctsUWMSSUETY/xSTDRKNNUYG4IgmhgVC7ZYA7FEERVUNAqoCIJdaeIConSwUAQEBO73zp5l3KAsEHcV3PN78vjcnXJ35s47554ZNr8zxBjz5MmTmpqakpKSOyrVCwiEAKmtrQ2ohkBVaWlpfX19e3u7UaleQCAESOAEVENAjA86KCpvCZyAagjhS2OVyrtxC6iGMDXqWKi8K6BSsFQKlkrBUilYCpZKwVIpWCoFS6V66WDxvisxMfGv3pSRkaEDquoHWJcuXZozZ87q1avX9qzly5ezTXV19aA47fT09JCQkObmZiXgVYJ15swZoPF8GfLz89nm9u3bg+K0+3JGqtcHrFWrVq34t3z0R8zBAhZ/zd2+ffuCBQsYGa+kwrt27Tp48CCNx48f+/T0BxZYe/bs4cy/+uqrKVOm7HLKz8H65ptvvvvuu3/++ee333575513Hjx48IIdXrx48fr163IPL1q0yL+mwiVLloSGhtox7Ny5c5LnMbi1tbXkRvv37//555+3bdsm2DHcZHgsYXlHR8drAxZn98YbbyQlJdHmvBiKW7du0T59+jQnGxYWVlxcfP/+fQaEf1nO6cfFxdE4f/48Z8dy2T4hIWHHjh1sf+XKFbbhI31+/PHH48eP52NMTMyhQ4fYjN7YpampyV/Amj17Ng3GheWVlZVDhw5lIUPz7rvv7t69m1XTpk1jLfn4hx9+yNPrc/ssKyvbu3evHf+6nVFFRQVr+W3aQGNrzZo1Y8eOXbduHT+ds0POm2++yelv3ryZ8+WYP/nkE1jp7OwMDAxkeW5u7ujRoxkHiAkICOAcV65cOWrUKLYHHQaWBKO8vHzu3LmzZs0qKiqKjY2dMGECu+/cuZOR9JeIdePGjZEjR9bV1b399tvciAIWlLBq06ZNP/744927d1kSHR3N4H7//fcQ9tw+2feXX37hxhW23M8IqhYvXsyXPnz4cKCBxfWW8DN8+HD4IG4tW7Zs6tSpnOyBAwc4cQacJGzevHkEpzFjxnBGUPjpp5+yQXx8PHsxgOxoz3oClvtUyFmzGbtD1d9//+0vYDGy3E+///47Nx9MuIPFHTZz5syCggKWcGdvduro0aM9dctrNthiAuUut89IqALHqqqqgUYVl5xZT+4Eh8MxbNiwq1evMjJffPHF5i5x/Fw+kKItNxXcEMPsDYhSgGWP57NgIeYEcgnw8uLbooEOFmJ0GFPYoi1gcTwcDHcY789aWlpgjjSfu5mbmwvgoWdhi1ucKYOjLSwsHLBUIX7dO2LEiMOHD8MWw8sgkCQRqMiNIKCxsZGMSrDjcYfpj2SA9okTJxgQ7j2GiA24i54LFrfiDz/8ICnp8ePH6ZyZ8TVP3hmIX3/91f7I5QcmxtQGa8aMGYw4M8K9e/dYyKpJkyZxw3GnkmF47pxHAdgKDg7maPkXiLl+AzZ/T01N5dQ4ZfIqbjDiNyQRXUY7Zb+OIQeXfEvS/KioKGIYeRVnx/8ww0d7PGnwkUZmZiYbMAK0SdhpSwrvRy9ICTOkpTKC9lT47GuI1tbWvncIWxztgI1V3URUfvaB0fOLGPDq9VmEHiCVBk8GgOXdFLNPYHGXcxlIBo/1LCYjtuEn9N4dU7oleScVlY/uOdYLwhoeHj4oqPK1iBqEt/Xr17+C91g8lDE9zfEoyV28/j6TNzTkp+43GY/KXvkWXj0rVaihocEXP2/Rn82ofCIFS6VgqRQslYKlYKkULJWCpVKwFCyVgqVSsFQKlkqlYKkULJVfg6VSeV0asVQ6FaoULJWCpWCpFCyVgqVSsFQqBUulYKkULJVKwVIpWCoFS6V6iWBhXYKfzgGPwoopKytLB1TVD7CuXbsmZqketHDhQnxBxGVVpfLTAgIq/wILg9DNbqI+j79fn+IY6z93Fe00t3c/f+OSOFOVrGA9R5g+Xr58GaM9HEdp4Pj7kodD7IcHEFipH5hTQ01Dl7FqbY71MW3i8zeuPGXuZypYPYpYJQbuorNnz+KliZO2WKURxniSoJgAPm84whHkWEsWyKqbN2/imrxlyxbW5uXlue754mIwjYyMFB9AHGDxQGcJH3EfxdOb3cUWli/Fk5JkkQHBxBCyjdOBcsOGDcZpa8iO+H9iBMeR4NkcERHByPgcrHPjTf5a18ebq6yPLrA6TflRkx1hHJtMW521oCzBVPxjNZ40W4EtK8zcjcWszjkKe0z5EZP7m3lcpWC5qKLmB3BwIcHFOMsFUIcMx1vcpCmvQAEPfFrfe+89Hl3xQsZRGM9q9mIJ2OGrieMt2F24cGHy5Mk4vQIoDTZ49OjRZ599BqMcM/1gFIhVLra5rMLzc+vWrfv27TNO41cqFcg0jX86a1nCV+NhyVEFBQWJ664PwboVZc4EmM4npr3VnHnb+ihgFe8y5z40VUnm0kyTFWItyV5s8lY4n7bmmotBpiLRnP/M3FxjLWEb9s1fbVruKViWKKBAiCJ44Br6wQcfCFhcURpgRLCRzT766CMYYoltD8x8CgR4Wc+fP/+yU3hWM80BFkzINlhOQiFm1BgwY+XNEkoQ5OTk0HguWOKvD5FvvfWW9Ekw87p7Z3ewyo+b9P+Z6rMWQ5lfWmFJwCIsNeRbISo7zKR98hSslofm1DBrFao+Z1LGms4OC6zCbToVPgXr22+/ZcaRXP6PP/5wByslJWXp0qWyGSGHijruYGGQz8wFQwQV+2kA710bLCIcnYMLJv0Eqr6Dhcc69ZLsPtndt2CVxlvZ+vX55trP1tRWEusCq/SQSR1v7uw12eEm3Q2s5gorD3tcaS2py7La7S1OsHYoWE/BooCHzQHTX69gkX1LKKJ+GIdEaSdmUrHJZ3fcp22wmPjwRhfn9OnTp0uahae+vOblAIRjSqp0A4vemHCZOqUT3xZKscA6bFrum+TRJmWMaa0zJftdYF2bY3KXWw0Hk+PHT8HqbLf2KolzrtpoMr5wTYUKljtY5EmUVqNneJK3D57BokFlLGY9smxZRQkGZjqq7jCrkjzZYIEUFFI+hLVQKFX8oIclJP5wA3Z8Lzt2A8s43bxJ1Jhk2ZEnBp+DZWE0z2SFOt8pdIFVddqijczpwlST9IaVpNs51v0LVlp25l2TGmjqchUsl7j83aoBEBXscmc0xI4bg3z7kKj/Ick7UyH7Un3OfXd2sSv9deuckENv9kJo41HR7l8qX5BUPbsj2zAn+qic4lO1N5qONmej1frP1WjsWttsxTCQetJgPSReX2DyVrlWEbdaalyPhNbJNLl2H1Bg8egENCdPnjzds6i6wTZMHK/w3Yl7juV34pVE0kgrwR9E77H4CyAPWXN6EyWTXm3JPwrdUrrCT8FiymtwDJzD6evPZphomnuTUan6C5ZKpWCpFCyVgqVSKVgqBUulYKlUCpZKwVIpWCqVgqVSsFSvJVgqlRYQUOlUqFKwVCoFS6VgqRQslUrBUilYKgVLpVKwVAqWSsHqSZ7/b1WsDXQ0Vf0DC0+OZcuW9fq/2GO76HOTDNXrBBb2m4PCFEQ1yMBSn/dXLOaBnqYCp8VS94376KPBvgkJxjemGwMLLDzWsEGbOHEi/mk06PAlX0EMI33r0NdfNTSY4GAzfLgZOdKEhJgukzCX2trMiBEmO9tqJyYacdrZssX0cdyYXoYONTU1/hKxutlxG6evn9g92rLt1/i3gdH/1x3b3vTvC8DHbk5unIu9O+ZH9nI8SHNzcz0cm4fv9YnCwsz06da1578pU0xUVPdYRY2Zzk6rMWYMPmZWg6co9wOrq3NtYMcz+3z9GSzc9PAF/emnn7DOlnoCWEUGBwdDAEagbEaDVeLUjfEacQ7LWiy4Mbe1e8Ni9MsvvxSHSKwi6YGI6HA4sIfE65H+cZcEFPxOca39+uuvMVd+rrmt7FhUVITXMo6SHBUGpz70bwIRAtXZs66P+M7HxroYmjbNDBuG6aBFBsMCc3xkeUaG2bTJzJ5tbeZwmIkTzdixZvx4I1URtm61Po4bZ4U09vVnsGgLEPQMHwIWlrU0KDnG1CnutAEBARweYIGIc4pogzb4wAUezmQbaMNBDrCEQvTnn3+KUxxssaXpzTWZsgPGaRUGXpQRkMOLlYvtC/EVXPhnK2UAELcN9RBssNwjlg3WjBnGWfrAYBjOsBDhVqywYCJiMYFevOjXYM2aNQuSfnRqDGPXHztuDNyBEgt42R34qCTg7vPOx9DQUEpREPz6bsddVVU1atQo6fPzzz9fsWKFr8DiknPhncfTHay0NKvhASwyB6Jdt+IGSUncQ2bqVCtpS0nxa7AoVZeWltbQpb6DFR4ezksQCghQxsLenbhlg8XpwJOY3lIWrxtY1FPZu3ev6aGAwPvvv09mJn36cCokHwoIMIcPuz5y2CEhfQULMeXJNIpFLwVguMpMlw6Ha5Wfg8XLM4IWxv9UFuHSmj7YcZM8sTHXnpQfY2NctfnIORLDOAUbLJgghrEK695x48bRm3EWwoiPj6dUzokTJ5hVeS1HqZVn7bg50+joaPxOWSLzsq+0Z48Fwf79BspJj3j06wmswEDDTcWDoQ0W0yWpWHKyCQqysn4GDbA4d/zradCVv4FFqRJK39gfk5OTmbCYccDLOIs0iUs2KVSiDLT1iL2F0CIFBKKioghX2fIQbkxBQQGe73ZMolCA3Tl27SynUAAwXSTnwII/O5snA3YhthG0WMtX2EWabIb4LtYyh8bExJDP+fbBkPkrNNRwCwlMxrKu56xcIY2J2Fl8ygpOvJjAdJ6GpH1gJ0EOOuU12MmT1jYs3L7d6o1oze6+ebZ9rV6Q+rUd92B88858ATT8ufDXnkWQIB+SWPKqRNRhptOLOmjAkqIPf3kUWZEU+FOpjP4eS6VgqRQslYKlYKkULJWCpVKwFCyVgqVSsFQKlg6ESsFSKVgqBUul8j5YKpX6vKt0KlQpWCqVgqVSsFQKlkqlYKkULJWCpVIpWCoFS6VgPVeY+5zuTWqZrOofWHl5eXP6oPnz5+McpGOqMv1ym8GAykNliqysLLXjVv0XsF6CjVF5efmLO4sUFhbmuNkrYpVTWlra307w4sJI0n0JnrZimMst9HKy0nvN9y5XX5X23YaSwroiaWfdyyl7VN7f3tLLM2qaa/wULGz1QsQN8QWEy97GjRvtjxEREdjz9bcTXN3O2nbFXVkmRpLG6cBGbmCc3rjuBnFeFzDNSw1ubGukvTM3OvLKGhodpmNRemjug7z+9naoIOFO/V0Fy7iHHGwagUMM1uPi4qTBBcYJUlJA27/PA1jYG8uObE/UMU7fZQDCUguGODvcuWGlxmmdSNiT2InjMruzHNNKAYvlrMUfEOPusLAw7P/YUfxRibj4PXnr2nR0doSkL7710NFhOhelhcLTo9ZH5Y8qbNpy7ueevHPqWvV1tuTjhYqLBXWFSXeSWzpaUkrOENVSSs9kVGayO2szKy9UNFY0tjWxqryxnM2uVl/rdK6it9OlZ9PK0h21BXkPbvoFWKdOnZoxYwZXMTIyUhxHMYMEC6431rTiJrpkyZLMzEx3sPCGLO8Sho4CFo6jmIvSwLUWo0dZQrcAivlxUFAQ3w5kM2fOZNXRo0fXrLEiBFvyUAJ5s2fPFrDWrl175MgRfFA5XywqAZEDYHvZGG9SL973W7K2Jt89TaQhXO3Iic6qyb5UeXnV5UhWJRafWHkpEiCWXljOvyxZeWnV4vPhO3NiHrU2zk1duPbqhuPFJ1gCMaxdduH3S5VXmF5ZteXG1mO3ExekBYMRI7n26voN1zcfLToGvn/n7/MLsIgKUioC87cJEybU1tZihMzl5+uIFvjesgoDd/fCE4A1efLkkC5hbusBLFmClSiOusZZb0JMv22wAgMDpQiKPRUKWDTAV+ITE6L4y1OOAJS9CNaJ4pPRubuIMfFFR86WpR4qiD9UGH/AcZBVJEwEnuL6O2ywMydawDpTmirTJfQU1lo5GaDE3orrBlZtSy1LgIm4VdpQxpKGVqvORawjzl/Aoq6OOGYjggqOyNiswxOTI5eTbyTFxuq4LzmWB7CIfLbH5OjRo93BEs48gwWOhE9GQEoceFH5D/IJSFuzthFamNqIW+uubrxcZbm3p5WnsQrg1l/bRAYmYDHf2WDdrr0trOzL398NrLoW663QHze2MpPmP7gVnO4a8wP+AxbTkLgdi996Y6OVW1DXBFNuikokJCTQOGw7oXsEi4AnsWe7U30Ei1lS3L8xOH0WLGZq2QurZqqqUFvPu2A1PWmal7qQ7Iq0iTkrPGMp8xdwsIoJ8VxZmhOdAzty/vzPYD14/JCkTR4zo3N2vbZgcevbsxi98ZBP8RKuJTDZl42Km8yDxlmhc/jw4ZVS9ao3sCiVQ+kbEjW84PsOFi7wkyZNWrlyJfGyG1h8ETO11ErhIWDYsGG+8PZdfWUtc5a0Y/J2w5Zk3MduHyclIj2CGLb5z2DROOg4TCq25sq68MyI1xMsnq1uuUmKeMnbV2ZAezPe79vzo4QTd0FbjZspPtjJ3wO44zlISCVREwJ4oJOiYvRmM+Fw1m6Q4gOyhN7I8zgYMZpnud0hT6MSBUmtBDuvi4jy8HGttOtb62uan7LLE2JlUyXPdDSsA2uuJm23Dsx0EoFa2q1iyqRTklHJlk/a21klT5FkaXRI41Gb9bBZ3VRD5DtSeOzlgcUzEdBgn3+jZ1G4hm388+/ZZHs8uqbZBv+DTRtvbCaNI1ELy4hwB9fnYJWVlS1YsKDXvxUyy/hnyXFedvi28ImPRRjjBRgv9CXgvTywjLMyUWlv6lZsUuXP0t9jqRQslYKlUrAULJWCpVKwVAqWgqVSsFQKlsqvweJvq/KbXZXKKwInoBrCX+/r6+t1OFTeEr/+AKohbW1t/KUPtjRuqV48VgESONEYwmd+jQlihC81kVa9iEAIkCRC/R9f3OEsEgi6eAAAAABJRU5ErkJggg==", "public": true } + ], + "scada": false, + "tags": [ + "alert", + "alerts" ] } \ No newline at end of file diff --git a/application/src/main/data/json/system/widget_types/entities_table.json b/application/src/main/data/json/system/widget_types/entities_table.json index 01034a1d1d..f58bd3f0ce 100644 --- a/application/src/main/data/json/system/widget_types/entities_table.json +++ b/application/src/main/data/json/system/widget_types/entities_table.json @@ -11,19 +11,13 @@ "resources": [], "templateHtml": "\n", "templateCss": "", - "controllerScript": "self.onInit = function() {\n}\n\nself.onDataUpdated = function() {\n self.ctx.$scope.entitiesTableWidget.onDataUpdated();\n}\n\nself.onEditModeChanged = function() {\n self.ctx.$scope.entitiesTableWidget.onEditModeChanged();\n}\n\nself.typeParameters = function() {\n return {\n maxDatasources: 1,\n hasDataPageLink: true,\n warnOnPageDataOverflow: false,\n dataKeysOptional: true,\n supportsUnitConversion: true,\n defaultDataKeysFunction: function() {\n return [{ name: 'name', type: 'entityField' }];\n }\n };\n}\n\nself.actionSources = function() {\n return {\n 'actionCellButton': {\n name: 'widget-action.action-cell-button',\n multiple: true,\n hasShowCondition: true\n },\n 'rowClick': {\n name: 'widget-action.row-click',\n multiple: false\n },\n 'rowDoubleClick': {\n name: 'widget-action.row-double-click',\n multiple: false\n },\n 'cellClick': {\n name: 'widget-action.cell-click',\n multiple: true\n }\n };\n}\n\nself.onDestroy = function() {\n}\n", - "settingsSchema": "", - "dataKeySettingsSchema": "", + "controllerScript": "self.onInit = function() {\n}\n\nself.onDataUpdated = function() {\n self.ctx.$scope.entitiesTableWidget.onDataUpdated();\n}\n\nself.onEditModeChanged = function() {\n self.ctx.$scope.entitiesTableWidget.onEditModeChanged();\n}\n\nself.typeParameters = function() {\n return {\n maxDatasources: 1,\n hasDataPageLink: true,\n warnOnPageDataOverflow: false,\n dataKeysOptional: true,\n supportsUnitConversion: true,\n defaultDataKeysFunction: function() {\n return [{ name: 'displayName', type: 'entityField' }];\n }\n };\n}\n\nself.actionSources = function() {\n return {\n 'actionCellButton': {\n name: 'widget-action.action-cell-button',\n multiple: true,\n hasShowCondition: true\n },\n 'rowClick': {\n name: 'widget-action.row-click',\n multiple: false\n },\n 'rowDoubleClick': {\n name: 'widget-action.row-double-click',\n multiple: false\n },\n 'cellClick': {\n name: 'widget-action.cell-click',\n multiple: true\n }\n };\n}\n\nself.onDestroy = function() {\n}\n", "settingsDirective": "tb-entities-table-widget-settings", "dataKeySettingsDirective": "tb-entities-table-key-settings", "hasBasicMode": true, "basicModeDirective": "tb-entities-table-basic-config", "defaultConfig": "{\"timewindow\":{\"realtime\":{\"interval\":1000,\"timewindowMs\":86400000},\"aggregation\":{\"type\":\"NONE\",\"limit\":200}},\"showTitle\":true,\"backgroundColor\":\"rgb(255, 255, 255)\",\"color\":\"rgba(0, 0, 0, 0.87)\",\"padding\":\"4px\",\"settings\":{\"enableSearch\":true,\"enableSelectColumnDisplay\":true,\"enableStickyHeader\":true,\"enableStickyAction\":true,\"reserveSpaceForHiddenAction\":\"true\",\"displayEntityName\":false,\"displayEntityLabel\":false,\"displayEntityType\":false,\"displayPagination\":true,\"defaultPageSize\":10,\"defaultSortOrder\":\"name\",\"useRowStyleFunction\":false,\"entitiesTitle\":\"Entities\"},\"title\":\"Entities table\",\"dropShadow\":true,\"enableFullscreen\":true,\"titleStyle\":{\"fontSize\":\"16px\",\"fontWeight\":400,\"padding\":\"5px 10px 5px 10px\"},\"useDashboardTimewindow\":false,\"showLegend\":false,\"datasources\":[{\"type\":\"function\",\"name\":\"Simulated\",\"entityAliasId\":null,\"filterId\":null,\"dataKeys\":[{\"name\":\"f(x)\",\"type\":\"function\",\"label\":\"Entity name\",\"color\":\"#2196f3\",\"settings\":{\"columnWidth\":\"0px\",\"useCellStyleFunction\":false,\"cellStyleFunction\":\"\",\"useCellContentFunction\":false,\"cellContentFunction\":\"\"},\"_hash\":0.472295003170325,\"funcBody\":\"return 'Simulated';\",\"aggregationType\":null,\"units\":null,\"decimals\":null,\"usePostProcessing\":null,\"postFuncBody\":null},{\"name\":\"f(x)\",\"type\":\"function\",\"label\":\"Entity type\",\"color\":\"#607d8b\",\"settings\":{},\"_hash\":0.782057645776538,\"funcBody\":\"return 'Device';\",\"decimals\":null,\"aggregationType\":null,\"usePostProcessing\":null,\"postFuncBody\":null},{\"name\":\"f(x)\",\"type\":\"function\",\"label\":\"Sin\",\"color\":\"#4caf50\",\"settings\":{},\"_hash\":0.904797781901171,\"funcBody\":\"return Math.round(1000*Math.sin(time/5000));\",\"decimals\":0},{\"name\":\"f(x)\",\"type\":\"function\",\"label\":\"Cos\",\"color\":\"#f44336\",\"settings\":{},\"_hash\":0.1961430898042078,\"funcBody\":\"return Math.round(1000*Math.cos(time/5000));\",\"decimals\":0},{\"name\":\"f(x)\",\"type\":\"function\",\"label\":\"Random\",\"color\":\"#ffc107\",\"settings\":{},\"_hash\":0.7678057538205878,\"funcBody\":\"var value = prevValue + Math.random() * 100 - 50;\\nvar multiplier = Math.pow(10, 2 || 0);\\nvar value = Math.round(value * multiplier) / multiplier;\\nif (value < -1000) {\\n\\tvalue = -1000;\\n} else if (value > 1000) {\\n\\tvalue = 1000;\\n}\\nreturn value;\",\"decimals\":2}],\"alarmFilterConfig\":{\"statusList\":[\"ACTIVE\"]}}],\"displayTimewindow\":false,\"configMode\":\"basic\",\"actions\":{},\"showTitleIcon\":false,\"titleIcon\":\"list\",\"iconColor\":null}" }, - "tags": [ - "administration", - "management" - ], "resources": [ { "link": "/api/images/system/entities_table_system_widget_image.png", @@ -36,5 +30,10 @@ "data": "iVBORw0KGgoAAAANSUhEUgAAAMgAAACgCAAAAABslHx1AAAAAmJLR0QA/4ePzL8AAAnhSURBVHja7d3rU5NXHsBx/7KAa8WuBRahaEAIiQRFQSAKXlrUpagYAmgBJbXKin3qKlaKYAOmtFlQLpWkgCgSRC6GiwZFQAi3J/nuC9Da2RGSTNtZmXNeJWfmOfl9Juc2c855zjoWnU8HPvD01LnAukXHpMwHnuRJx+I65yRrIE061z2V1wJEfrpugDWRBgREQAREQAREQATkQ4A4X/sBkXW34IHO7d9PfqXT6XS6Tr9DztXpsv/zP7mJZf5AFEEjNCn8nEZ2WE4HWSxjfkPUxyyXAxv/IEjoAZoUsnxxx24b5ScOaaqMquwFHqSoL3u8KK1mM0h6xrT9WYXalF7mC2MODnsPKYXdxfycFFdOR2qJKkfmB83hqDK601RFc2Sc1e61HlJVeQep/NjSpJDrEvuMERRHtF9UlHZs+nk6+N8PQhu9hLSuf10V4dakdx1RY4zvyU/2HlI43LKp9rmysUXR3xhQ3RpoHQysatlc5gr5qlt9lvAs++6/35M2LHoFuVuxxayQaSw5qJCLM+hRTBBXfjdQklTnvITIYebDxWiu0a14vf2AVBDodUVVb/xEUeKhtzQvoLnxI9hSW7UNEstaAxe5GU14DcZ99CvGvIO4E6MV8g8RTZVvIeprdwIlSWrxEkLRgaBHaK7Ro5iMPiRJ0oIPVSvtCI6NVY8Cmxs/gvCam9GQWGZbL1P1KeE1GHXeQ+hdr5CLdoycVcy/hYxvKnd++8RbSJciCjSZz/O2U6Ad6pR8aSMPA7qa1/fUBzQuQfoC7jwKKZsOufYs0eAbRGmDUqU8unvLKaXj8in6lVNk/IBVG3z4hReh1O8AUH4NmpStqk5mcsKjzV5DMq7DiVNydmhmgskWC4n1XI1IzqigPSHkn69JrOdKNkPK8b9qQBzd1A+aax/8yN4WkMeagMjjYq4lIAIiIAIiIAIiIAIiIALyZ0MG10QSVUtABERABERABERABERABOQDh3TchuYGuN3x62kZ6DeMAy3lUG8wGDrp0J9aYT+Ay2Aoaf1dTleVL7G4/vVFpZupkpNLy5XdBoOhDsau1PgOeZQIGamwy14R0ADkBzhgIiINci5YLCNPtj9sC3//hoDJTdb6/SXv5ox1+QLJPTeYcZMMqUfdCXAj22LpoU9Z3u87RA6enY/WuOZC5IqkDHB9qnbAcWMapPcCLT+Cru39kGBY3OrgSXZWtysXGuvuV/K6+OBVGXtW9uNVY0noozZ3PsRDTQHAVzUAGc1+tZH0tl9yC5vaM6goiRml6ssdDpqz7qeBWvtprgw83z63IoQTNRNRXfaomaQe0jvNeg5dGfvixliUvStqdrVYTJ/XJXe6wxZo+AzgpGrrgVeEHIk/NO075JtvCu+0nJEkKr68+jU7+2McM/Hj99OgdWp+fzXM7bGxMiTvpinFYtllqzo/Fesx62c3u3nZ9f1+i2XHqrttftpbstvOqayquFMA9mFPUcHMhl6KLvoO6Tiocc1pDndSUTChfJhClOPcLmN25A2A6jPIn1WyCmRva0WSJEmDU+rai5j1M1sArqRKkrTqppTQ19j2IdfVSpeXc7qT5kLgXqbvkIXwdMiIWKSigOPRdUQ5Hlut1+O7ZiLnOF1O7iVWgdSq5K74RboWyYxxYNYT20/9d22JMg9W3coR7qTxADC0fRgZVCNUnyDhAZLRj3Ek5TpcT4WKAjpDF4hyAJ1pUKlO3udqCtRqtRfeDwnURmc+hzJV0iEXdxLBrOd+3L7UF3yt2pM5t1osDap0zWM82lQb6NqxqXXxw9jVKUkTf+SAKLu870fl37VOF4A848Vznt82y7kBz1IxM/70WmKKIiACIiACIiACIiACIiAC8kFAxDq7qFoCIiACIiACIiACIiACIiDvJL8OlM+/+bDgPyRmnPH4jp4UAKKUSqW8WH/4KsB0dN1qZU2FaGOyfneAtD7fh1AcOp1ubyyt0QlZCwDlqp0Zs0D1Nj8gW164Ekx0awEWIgFqC7MlgNzIVdftJ4PBpJHB9e4C7tK5xJk5rzDVRs+2QU5WAcPRC2RVg1PziT+QZ/svswwZ3WVrdwOlEmDLzPMKwl4bl7QJ52fD5zh/zazHFpuhdXJ+Z7w3Lwpwq52z/4DG48DkY8irgc+b/YKkR8hvID0RZ5KOLkNmtU4vIfk37Qmye8dQTgOqF2a9RzmA6XzHHo8c+3z1aOqPg3KIsuVl3L74WX7KnfULcvLYuTcQQI4cXoKcvYmXkOOmGxE6Xdhd2xf9+zHrJ8MAvo3U6UKtq0ezuwcsW1N1ZwAY1zxmYsdr/yAvZmLuLEPsfUsll0rMRWu1IVvrvIAsRA7U5kxNTS26VedrMevlYDdz45X5U1NTq/dgHakAslx4C2Am8R7cjNHGB+7yB8KTiJHuGLvd/qptz0jztvnlNoI3/8jH9ub0LxmPbB06PYUxxIVZzxHJmVPpjPz1af7qq7MHmwDaL+ycp2l8Mc1ot/cDfv0jJdPQcHPUYDAY2rEczR0EGpd2HZlX3YLhMhiKmoD+3EwLOK5DVy2ui0eroVefeXfVWFzFHsBTZJoFaXDcYDAYSoHFIjGyC4iACIiACIiACIiACIiACMj/OUSss4uqJSACIiACIiACIiACIiAfOMQ6Bcz+eOsl4G7zqaxFi6W+782Xh8McAOCY65mXr5L3NFf2APS9Ws6YvMOoxWKx2PyAvNpwBRa0pd8px7msCvQJMhkkXdr55uxoXScbAQib7jZ59/zJE7diG/hp18Y3h8ezg+mTJCnzlB+Q8jNxMHIRjluwzHzkGyQY+mO4P4irjvuDbISe8s6w6aF2fh6+UedBNlU+6Xjv4/NaN7U5tIwlL0OaMoMBSO31A6IZzegEGIwbAnyEbB52nMunwIQzigITG7Fvu1UQOG3WE3uwZreJ7OxbcXkrFlFYASxDZuKfBQN07fOjjTxKoU4PnFFmy75D/nb0s6iGdyEFlRA6bdYT68BUMB0iY1oRYkuWf4Pk354NBjjS7AckP9l4ZpML8GT+6DskGCY/WXgHktkMYdNmPbGj1BqG1awM6Y1z8hbSEWm5HWQBh9rjO2QurMVq/dzUWwLnKvyCzATNF1fgWIaUXEUOfguZC3Xx/QqQUc0gv0HuS1JZ0BXIq/aj+zUfA2zJ84n5xrhx3yEbDLlxl7inNO5dhowqiw8HvYVwYU9x1AoQVbrRaHQvQQzdwGwwjEfO+wEZeA64bR75119cADafILLV2vES6G9+1cngGDaYaHZ2yi8H6JzjxQDuh7arKxyyt1mtVqsHumdgYA6Q2+DlE/yA/Mkp52KVsvdPKvsvhcw3Vo2IuZaACIiACIiACIiAeANZMxcEr40rmyec6xbWxiXa8rq1ca25zH8BTrZIsxZexqkAAAAASUVORK5CYII=", "public": true } + ], + "scada": false, + "tags": [ + "administration", + "management" ] } \ No newline at end of file diff --git a/ui-ngx/src/app/shared/models/alarm.models.ts b/ui-ngx/src/app/shared/models/alarm.models.ts index bcf0d7b75d..590e866465 100644 --- a/ui-ngx/src/app/shared/models/alarm.models.ts +++ b/ui-ngx/src/app/shared/models/alarm.models.ts @@ -140,6 +140,7 @@ export interface AlarmCommentInfo extends AlarmComment { export interface AlarmInfo extends Alarm { originatorName: string; originatorLabel: string; + originatorDisplayName?: string; assignee: AlarmAssignee; } @@ -172,6 +173,7 @@ export const simulatedAlarm: AlarmInfo = { clearTs: 0, assignTs: 0, originatorName: 'Simulated', + originatorDisplayName: 'Simulated', originatorLabel: 'Simulated', assignee: { firstName: '', @@ -242,6 +244,11 @@ export const alarmFields: {[fieldName: string]: AlarmField} = { value: 'originatorName', name: 'alarm.originator' }, + originatorDisplayName: { + keyName: 'originatorDisplayName', + value: 'originatorDisplayName', + name: 'alarm.originator' + }, originatorLabel: { keyName: 'originatorLabel', value: 'originatorLabel', From b6604d997bfbebeee058b25949ebccab9e4eee53 Mon Sep 17 00:00:00 2001 From: dashevchenko Date: Thu, 9 Oct 2025 13:11:00 +0300 Subject: [PATCH 086/102] added originatorDisplayName to alarmInfo object --- .../server/controller/EntityQueryControllerTest.java | 5 ++++- .../org/thingsboard/server/common/data/alarm/AlarmInfo.java | 5 +++++ .../thingsboard/server/dao/sql/query/AlarmDataAdapter.java | 1 + 3 files changed, 10 insertions(+), 1 deletion(-) diff --git a/application/src/test/java/org/thingsboard/server/controller/EntityQueryControllerTest.java b/application/src/test/java/org/thingsboard/server/controller/EntityQueryControllerTest.java index 0c40c2f868..775df230b6 100644 --- a/application/src/test/java/org/thingsboard/server/controller/EntityQueryControllerTest.java +++ b/application/src/test/java/org/thingsboard/server/controller/EntityQueryControllerTest.java @@ -432,14 +432,17 @@ public class EntityQueryControllerTest extends AbstractControllerTest { List alarmFields = new ArrayList<>(); alarmFields.add(new EntityKey(EntityKeyType.ALARM_FIELD, "type")); + alarmFields.add(new EntityKey(EntityKeyType.ALARM_FIELD, "originatorDisplayName")); EntityTypeFilter assetTypeFilter = new EntityTypeFilter(); assetTypeFilter.setEntityType(EntityType.ASSET); AlarmDataQuery assetAlarmQuery = new AlarmDataQuery(assetTypeFilter, pageLink, null, null, null, alarmFields); PageData alarmPageData = findAlarmsByQueryAndCheck(assetAlarmQuery, 10); - List retrievedAlarmTypes = alarmPageData.getData().stream().map(Alarm::getType).toList(); + List retrievedAlarmTypes = alarmPageData.getData().stream().map(AlarmData::getType).toList(); assertThat(retrievedAlarmTypes).containsExactlyInAnyOrderElementsOf(assetAlarmTypes); + List retrievedAlarmDisplayName = alarmPageData.getData().stream().map(AlarmData::getOriginatorDisplayName).toList(); + assertThat(retrievedAlarmDisplayName).containsExactlyInAnyOrderElementsOf(assets.stream().map(Asset::getLabel).toList()); KeyFilter nameFilter = buildStringKeyFilter(EntityKeyType.ENTITY_FIELD, "name", StringFilterPredicate.StringOperation.STARTS_WITH, "Asset1"); List keyFilters = Collections.singletonList(nameFilter); diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/alarm/AlarmInfo.java b/common/data/src/main/java/org/thingsboard/server/common/data/alarm/AlarmInfo.java index 7a80a09891..b316a2ef50 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/alarm/AlarmInfo.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/alarm/AlarmInfo.java @@ -38,6 +38,11 @@ public class AlarmInfo extends Alarm { @Schema(description = "Alarm originator label", example = "Thermostat label") private String originatorLabel; + @Getter + @Setter + @Schema(description = "Originator display name", example = "Thermostat") + private String originatorDisplayName; + @Getter @Setter @Schema(description = "Alarm assignee") diff --git a/dao/src/main/java/org/thingsboard/server/dao/sql/query/AlarmDataAdapter.java b/dao/src/main/java/org/thingsboard/server/dao/sql/query/AlarmDataAdapter.java index b250fc7337..f3e2ba83a6 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/sql/query/AlarmDataAdapter.java +++ b/dao/src/main/java/org/thingsboard/server/dao/sql/query/AlarmDataAdapter.java @@ -121,6 +121,7 @@ public class AlarmDataAdapter { AlarmData alarmData = new AlarmData(alarm, entityId); alarmData.setOriginatorName(originatorName); alarmData.setOriginatorLabel(originatorLabel); + alarmData.setOriginatorDisplayName(StringUtils.isBlank(originatorLabel) ? originatorName : originatorLabel); if (alarm.getAssigneeId() != null) { alarmData.setAssignee(new AlarmAssignee(alarm.getAssigneeId(), assigneeFirstName, assigneeLastName, assigneeEmail)); } From aefb7dcb41b3cc43320cebf3f7ee02e4b4288a31 Mon Sep 17 00:00:00 2001 From: VIacheslavKlimov Date: Thu, 9 Oct 2025 14:50:03 +0300 Subject: [PATCH 087/102] Version set to 4.2.1-RC --- application/pom.xml | 2 +- common/actor/pom.xml | 2 +- common/cache/pom.xml | 2 +- common/cluster-api/pom.xml | 2 +- common/coap-server/pom.xml | 2 +- common/dao-api/pom.xml | 2 +- common/data/pom.xml | 2 +- common/discovery-api/pom.xml | 2 +- common/edge-api/pom.xml | 2 +- common/edqs/pom.xml | 2 +- common/message/pom.xml | 2 +- common/pom.xml | 2 +- common/proto/pom.xml | 2 +- common/queue/pom.xml | 2 +- common/script/pom.xml | 2 +- common/script/remote-js-client/pom.xml | 2 +- common/script/script-api/pom.xml | 2 +- common/stats/pom.xml | 2 +- common/transport/coap/pom.xml | 2 +- common/transport/http/pom.xml | 2 +- common/transport/lwm2m/pom.xml | 2 +- common/transport/mqtt/pom.xml | 2 +- common/transport/pom.xml | 2 +- common/transport/snmp/pom.xml | 2 +- common/transport/transport-api/pom.xml | 2 +- common/util/pom.xml | 2 +- common/version-control/pom.xml | 2 +- dao/pom.xml | 2 +- edqs/pom.xml | 2 +- monitoring/pom.xml | 2 +- msa/black-box-tests/pom.xml | 2 +- msa/edqs/pom.xml | 2 +- msa/js-executor/package.json | 2 +- msa/js-executor/pom.xml | 2 +- msa/monitoring/pom.xml | 2 +- msa/pom.xml | 2 +- msa/tb-node/pom.xml | 2 +- msa/tb/pom.xml | 2 +- msa/transport/coap/pom.xml | 2 +- msa/transport/http/pom.xml | 2 +- msa/transport/lwm2m/pom.xml | 2 +- msa/transport/mqtt/pom.xml | 2 +- msa/transport/pom.xml | 2 +- msa/transport/snmp/pom.xml | 2 +- msa/vc-executor-docker/pom.xml | 2 +- msa/vc-executor/pom.xml | 2 +- msa/web-ui/package.json | 2 +- msa/web-ui/pom.xml | 2 +- netty-mqtt/pom.xml | 4 ++-- pom.xml | 2 +- rest-client/pom.xml | 2 +- rule-engine/pom.xml | 2 +- rule-engine/rule-engine-api/pom.xml | 2 +- rule-engine/rule-engine-components/pom.xml | 2 +- tools/pom.xml | 2 +- transport/coap/pom.xml | 2 +- transport/http/pom.xml | 2 +- transport/lwm2m/pom.xml | 2 +- transport/mqtt/pom.xml | 2 +- transport/pom.xml | 2 +- transport/snmp/pom.xml | 2 +- ui-ngx/package.json | 2 +- ui-ngx/pom.xml | 2 +- 63 files changed, 64 insertions(+), 64 deletions(-) diff --git a/application/pom.xml b/application/pom.xml index 0413f7732c..b415ee08a6 100644 --- a/application/pom.xml +++ b/application/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard - 4.2.0-RC + 4.2.1-RC thingsboard application diff --git a/common/actor/pom.xml b/common/actor/pom.xml index a6efdda77d..4149fe2ad9 100644 --- a/common/actor/pom.xml +++ b/common/actor/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard - 4.2.0-RC + 4.2.1-RC common org.thingsboard.common diff --git a/common/cache/pom.xml b/common/cache/pom.xml index 3088dad098..6831156fde 100644 --- a/common/cache/pom.xml +++ b/common/cache/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard - 4.2.0-RC + 4.2.1-RC common org.thingsboard.common diff --git a/common/cluster-api/pom.xml b/common/cluster-api/pom.xml index 939dfb1e16..78ef7980e8 100644 --- a/common/cluster-api/pom.xml +++ b/common/cluster-api/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard - 4.2.0-RC + 4.2.1-RC common org.thingsboard.common diff --git a/common/coap-server/pom.xml b/common/coap-server/pom.xml index fbfbe78b55..b24fd98f5d 100644 --- a/common/coap-server/pom.xml +++ b/common/coap-server/pom.xml @@ -22,7 +22,7 @@ 4.0.0 org.thingsboard - 4.2.0-RC + 4.2.1-RC common org.thingsboard.common diff --git a/common/dao-api/pom.xml b/common/dao-api/pom.xml index 636c7b9ef4..555b4a54bb 100644 --- a/common/dao-api/pom.xml +++ b/common/dao-api/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard - 4.2.0-RC + 4.2.1-RC common org.thingsboard.common diff --git a/common/data/pom.xml b/common/data/pom.xml index da2a0970b1..f27df3687f 100644 --- a/common/data/pom.xml +++ b/common/data/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard - 4.2.0-RC + 4.2.1-RC common org.thingsboard.common diff --git a/common/discovery-api/pom.xml b/common/discovery-api/pom.xml index 4f9ef70c5b..cb5238a664 100644 --- a/common/discovery-api/pom.xml +++ b/common/discovery-api/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard - 4.2.0-RC + 4.2.1-RC common org.thingsboard.common diff --git a/common/edge-api/pom.xml b/common/edge-api/pom.xml index 69a01be812..2b7db0cbd3 100644 --- a/common/edge-api/pom.xml +++ b/common/edge-api/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard - 4.2.0-RC + 4.2.1-RC common org.thingsboard.common diff --git a/common/edqs/pom.xml b/common/edqs/pom.xml index 140eabd270..cc5c5db14e 100644 --- a/common/edqs/pom.xml +++ b/common/edqs/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard - 4.2.0-RC + 4.2.1-RC common org.thingsboard.common diff --git a/common/message/pom.xml b/common/message/pom.xml index 834a041127..c84a4fde2d 100644 --- a/common/message/pom.xml +++ b/common/message/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard - 4.2.0-RC + 4.2.1-RC common org.thingsboard.common diff --git a/common/pom.xml b/common/pom.xml index 563f1ec3ab..f45a2bdd28 100644 --- a/common/pom.xml +++ b/common/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard - 4.2.0-RC + 4.2.1-RC thingsboard common diff --git a/common/proto/pom.xml b/common/proto/pom.xml index 6357cae711..30d4b0bc68 100644 --- a/common/proto/pom.xml +++ b/common/proto/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard - 4.2.0-RC + 4.2.1-RC common org.thingsboard.common diff --git a/common/queue/pom.xml b/common/queue/pom.xml index ccc0eadf1a..69adac81b4 100644 --- a/common/queue/pom.xml +++ b/common/queue/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard - 4.2.0-RC + 4.2.1-RC common org.thingsboard.common diff --git a/common/script/pom.xml b/common/script/pom.xml index c203bfdbf2..ab5ac032c7 100644 --- a/common/script/pom.xml +++ b/common/script/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard - 4.2.0-RC + 4.2.1-RC common org.thingsboard.common diff --git a/common/script/remote-js-client/pom.xml b/common/script/remote-js-client/pom.xml index c2f5ee2da2..1a3e00a88c 100644 --- a/common/script/remote-js-client/pom.xml +++ b/common/script/remote-js-client/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard.common - 4.2.0-RC + 4.2.1-RC script org.thingsboard.common.script diff --git a/common/script/script-api/pom.xml b/common/script/script-api/pom.xml index e35dec1b1b..316f823a44 100644 --- a/common/script/script-api/pom.xml +++ b/common/script/script-api/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard.common - 4.2.0-RC + 4.2.1-RC script org.thingsboard.common.script diff --git a/common/stats/pom.xml b/common/stats/pom.xml index 090732d175..4bf24411dc 100644 --- a/common/stats/pom.xml +++ b/common/stats/pom.xml @@ -22,7 +22,7 @@ 4.0.0 org.thingsboard - 4.2.0-RC + 4.2.1-RC common org.thingsboard.common diff --git a/common/transport/coap/pom.xml b/common/transport/coap/pom.xml index aaa9713e02..be3c62c057 100644 --- a/common/transport/coap/pom.xml +++ b/common/transport/coap/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard.common - 4.2.0-RC + 4.2.1-RC transport org.thingsboard.common.transport diff --git a/common/transport/http/pom.xml b/common/transport/http/pom.xml index 29d633d83e..a136079cb5 100644 --- a/common/transport/http/pom.xml +++ b/common/transport/http/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard.common - 4.2.0-RC + 4.2.1-RC transport org.thingsboard.common.transport diff --git a/common/transport/lwm2m/pom.xml b/common/transport/lwm2m/pom.xml index 4d7013a2bc..904e359bcc 100644 --- a/common/transport/lwm2m/pom.xml +++ b/common/transport/lwm2m/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard.common - 4.2.0-RC + 4.2.1-RC transport org.thingsboard.common.transport diff --git a/common/transport/mqtt/pom.xml b/common/transport/mqtt/pom.xml index 2baee9bda2..45f9b76af7 100644 --- a/common/transport/mqtt/pom.xml +++ b/common/transport/mqtt/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard.common - 4.2.0-RC + 4.2.1-RC transport org.thingsboard.common.transport diff --git a/common/transport/pom.xml b/common/transport/pom.xml index 2289b53aed..f786bcdd0e 100644 --- a/common/transport/pom.xml +++ b/common/transport/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard - 4.2.0-RC + 4.2.1-RC common org.thingsboard.common diff --git a/common/transport/snmp/pom.xml b/common/transport/snmp/pom.xml index 71bd0c17fe..d3b2c46d69 100644 --- a/common/transport/snmp/pom.xml +++ b/common/transport/snmp/pom.xml @@ -21,7 +21,7 @@ org.thingsboard.common - 4.2.0-RC + 4.2.1-RC transport diff --git a/common/transport/transport-api/pom.xml b/common/transport/transport-api/pom.xml index 29ace644a9..a3fee460e1 100644 --- a/common/transport/transport-api/pom.xml +++ b/common/transport/transport-api/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard.common - 4.2.0-RC + 4.2.1-RC transport org.thingsboard.common.transport diff --git a/common/util/pom.xml b/common/util/pom.xml index 3f07b2c05b..ebedf5dea3 100644 --- a/common/util/pom.xml +++ b/common/util/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard - 4.2.0-RC + 4.2.1-RC common org.thingsboard.common diff --git a/common/version-control/pom.xml b/common/version-control/pom.xml index 19278b8d13..b6a0ea1d2e 100644 --- a/common/version-control/pom.xml +++ b/common/version-control/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard - 4.2.0-RC + 4.2.1-RC common org.thingsboard.common diff --git a/dao/pom.xml b/dao/pom.xml index a32d0c4f6d..a1843959df 100644 --- a/dao/pom.xml +++ b/dao/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard - 4.2.0-RC + 4.2.1-RC thingsboard dao diff --git a/edqs/pom.xml b/edqs/pom.xml index c29710490b..d648d9d786 100644 --- a/edqs/pom.xml +++ b/edqs/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard - 4.2.0-RC + 4.2.1-RC thingsboard edqs diff --git a/monitoring/pom.xml b/monitoring/pom.xml index cee8fc0a6b..77874c5ac9 100644 --- a/monitoring/pom.xml +++ b/monitoring/pom.xml @@ -21,7 +21,7 @@ 4.0.0 org.thingsboard - 4.2.0-RC + 4.2.1-RC thingsboard diff --git a/msa/black-box-tests/pom.xml b/msa/black-box-tests/pom.xml index 84509ba48f..fed33f0002 100644 --- a/msa/black-box-tests/pom.xml +++ b/msa/black-box-tests/pom.xml @@ -21,7 +21,7 @@ org.thingsboard - 4.2.0-RC + 4.2.1-RC msa org.thingsboard.msa diff --git a/msa/edqs/pom.xml b/msa/edqs/pom.xml index d1f8291e2e..c5be28e7bb 100644 --- a/msa/edqs/pom.xml +++ b/msa/edqs/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard - 4.2.0-RC + 4.2.1-RC msa org.thingsboard.msa diff --git a/msa/js-executor/package.json b/msa/js-executor/package.json index 6e5f590260..220561f6e7 100644 --- a/msa/js-executor/package.json +++ b/msa/js-executor/package.json @@ -1,7 +1,7 @@ { "name": "thingsboard-js-executor", "private": true, - "version": "4.2.0", + "version": "4.2.1", "description": "ThingsBoard JavaScript Executor Microservice", "main": "server.ts", "bin": "server.js", diff --git a/msa/js-executor/pom.xml b/msa/js-executor/pom.xml index 01ac469b39..651b142160 100644 --- a/msa/js-executor/pom.xml +++ b/msa/js-executor/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard - 4.2.0-RC + 4.2.1-RC msa org.thingsboard.msa diff --git a/msa/monitoring/pom.xml b/msa/monitoring/pom.xml index 61f492dca8..abd12ed9e0 100644 --- a/msa/monitoring/pom.xml +++ b/msa/monitoring/pom.xml @@ -22,7 +22,7 @@ 4.0.0 org.thingsboard - 4.2.0-RC + 4.2.1-RC msa diff --git a/msa/pom.xml b/msa/pom.xml index 58482a32cf..6fcf7c3523 100644 --- a/msa/pom.xml +++ b/msa/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard - 4.2.0-RC + 4.2.1-RC thingsboard msa diff --git a/msa/tb-node/pom.xml b/msa/tb-node/pom.xml index 5f53d868c9..0103bf9352 100644 --- a/msa/tb-node/pom.xml +++ b/msa/tb-node/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard - 4.2.0-RC + 4.2.1-RC msa org.thingsboard.msa diff --git a/msa/tb/pom.xml b/msa/tb/pom.xml index 646b03bf14..28e1366a73 100644 --- a/msa/tb/pom.xml +++ b/msa/tb/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard - 4.2.0-RC + 4.2.1-RC msa org.thingsboard.msa diff --git a/msa/transport/coap/pom.xml b/msa/transport/coap/pom.xml index 4f510f937b..8aefc94122 100644 --- a/msa/transport/coap/pom.xml +++ b/msa/transport/coap/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard.msa - 4.2.0-RC + 4.2.1-RC transport org.thingsboard.msa.transport diff --git a/msa/transport/http/pom.xml b/msa/transport/http/pom.xml index d9159cd0db..a1481203de 100644 --- a/msa/transport/http/pom.xml +++ b/msa/transport/http/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard.msa - 4.2.0-RC + 4.2.1-RC transport org.thingsboard.msa.transport diff --git a/msa/transport/lwm2m/pom.xml b/msa/transport/lwm2m/pom.xml index 9972d66a18..7af2b16588 100644 --- a/msa/transport/lwm2m/pom.xml +++ b/msa/transport/lwm2m/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard.msa - 4.2.0-RC + 4.2.1-RC transport org.thingsboard.msa.transport diff --git a/msa/transport/mqtt/pom.xml b/msa/transport/mqtt/pom.xml index 185addeb3b..176dca19eb 100644 --- a/msa/transport/mqtt/pom.xml +++ b/msa/transport/mqtt/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard.msa - 4.2.0-RC + 4.2.1-RC transport org.thingsboard.msa.transport diff --git a/msa/transport/pom.xml b/msa/transport/pom.xml index 193d01ef06..afbab0c450 100644 --- a/msa/transport/pom.xml +++ b/msa/transport/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard - 4.2.0-RC + 4.2.1-RC msa org.thingsboard.msa diff --git a/msa/transport/snmp/pom.xml b/msa/transport/snmp/pom.xml index ea3d6847c0..553ed8bb37 100644 --- a/msa/transport/snmp/pom.xml +++ b/msa/transport/snmp/pom.xml @@ -21,7 +21,7 @@ org.thingsboard.msa transport - 4.2.0-RC + 4.2.1-RC org.thingsboard.msa.transport diff --git a/msa/vc-executor-docker/pom.xml b/msa/vc-executor-docker/pom.xml index a77eb1d206..d0a291e246 100644 --- a/msa/vc-executor-docker/pom.xml +++ b/msa/vc-executor-docker/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard - 4.2.0-RC + 4.2.1-RC msa org.thingsboard.msa diff --git a/msa/vc-executor/pom.xml b/msa/vc-executor/pom.xml index 0e91749cbe..cf777c4c96 100644 --- a/msa/vc-executor/pom.xml +++ b/msa/vc-executor/pom.xml @@ -21,7 +21,7 @@ org.thingsboard - 4.2.0-RC + 4.2.1-RC msa org.thingsboard.msa diff --git a/msa/web-ui/package.json b/msa/web-ui/package.json index cd5f87338b..2fcf6ab914 100644 --- a/msa/web-ui/package.json +++ b/msa/web-ui/package.json @@ -1,7 +1,7 @@ { "name": "thingsboard-web-ui", "private": true, - "version": "4.2.0", + "version": "4.2.1", "description": "ThingsBoard Web UI Microservice", "main": "server.ts", "bin": "server.js", diff --git a/msa/web-ui/pom.xml b/msa/web-ui/pom.xml index 8acd58b2b9..a56d0ca9d9 100644 --- a/msa/web-ui/pom.xml +++ b/msa/web-ui/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard - 4.2.0-RC + 4.2.1-RC msa org.thingsboard.msa diff --git a/netty-mqtt/pom.xml b/netty-mqtt/pom.xml index bbcfcfea18..08f2a55f4a 100644 --- a/netty-mqtt/pom.xml +++ b/netty-mqtt/pom.xml @@ -19,11 +19,11 @@ 4.0.0 org.thingsboard - 4.2.0-RC + 4.2.1-RC thingsboard netty-mqtt - 4.2.0-RC + 4.2.1-RC jar Netty MQTT Client diff --git a/pom.xml b/pom.xml index fd49af548f..1283dcc01c 100755 --- a/pom.xml +++ b/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard thingsboard - 4.2.0-RC + 4.2.1-RC pom Thingsboard diff --git a/rest-client/pom.xml b/rest-client/pom.xml index 07499f1129..e6e82da954 100644 --- a/rest-client/pom.xml +++ b/rest-client/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard - 4.2.0-RC + 4.2.1-RC thingsboard rest-client diff --git a/rule-engine/pom.xml b/rule-engine/pom.xml index 120d27ac89..8a9ad96d95 100644 --- a/rule-engine/pom.xml +++ b/rule-engine/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard - 4.2.0-RC + 4.2.1-RC thingsboard rule-engine diff --git a/rule-engine/rule-engine-api/pom.xml b/rule-engine/rule-engine-api/pom.xml index a6ee3492e5..8f449a5e64 100644 --- a/rule-engine/rule-engine-api/pom.xml +++ b/rule-engine/rule-engine-api/pom.xml @@ -22,7 +22,7 @@ 4.0.0 org.thingsboard - 4.2.0-RC + 4.2.1-RC rule-engine org.thingsboard.rule-engine diff --git a/rule-engine/rule-engine-components/pom.xml b/rule-engine/rule-engine-components/pom.xml index 3f2ee164df..4fc5a55c5e 100644 --- a/rule-engine/rule-engine-components/pom.xml +++ b/rule-engine/rule-engine-components/pom.xml @@ -22,7 +22,7 @@ 4.0.0 org.thingsboard - 4.2.0-RC + 4.2.1-RC rule-engine org.thingsboard.rule-engine diff --git a/tools/pom.xml b/tools/pom.xml index 1224e73777..cce1466971 100644 --- a/tools/pom.xml +++ b/tools/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard - 4.2.0-RC + 4.2.1-RC thingsboard tools diff --git a/transport/coap/pom.xml b/transport/coap/pom.xml index c96f4fddf7..8ae86d381f 100644 --- a/transport/coap/pom.xml +++ b/transport/coap/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard - 4.2.0-RC + 4.2.1-RC transport org.thingsboard.transport diff --git a/transport/http/pom.xml b/transport/http/pom.xml index e32915c51e..487e1de186 100644 --- a/transport/http/pom.xml +++ b/transport/http/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard - 4.2.0-RC + 4.2.1-RC transport org.thingsboard.transport diff --git a/transport/lwm2m/pom.xml b/transport/lwm2m/pom.xml index af17bf703b..30effe6e42 100644 --- a/transport/lwm2m/pom.xml +++ b/transport/lwm2m/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard - 4.2.0-RC + 4.2.1-RC transport org.thingsboard.transport diff --git a/transport/mqtt/pom.xml b/transport/mqtt/pom.xml index 87c98f9d78..a609e8d002 100644 --- a/transport/mqtt/pom.xml +++ b/transport/mqtt/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard - 4.2.0-RC + 4.2.1-RC transport org.thingsboard.transport diff --git a/transport/pom.xml b/transport/pom.xml index 8ddcf992d8..c6a82349f5 100644 --- a/transport/pom.xml +++ b/transport/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard - 4.2.0-RC + 4.2.1-RC thingsboard transport diff --git a/transport/snmp/pom.xml b/transport/snmp/pom.xml index 09901661a6..1894f09787 100644 --- a/transport/snmp/pom.xml +++ b/transport/snmp/pom.xml @@ -21,7 +21,7 @@ org.thingsboard - 4.2.0-RC + 4.2.1-RC transport diff --git a/ui-ngx/package.json b/ui-ngx/package.json index 4ba512a024..ea7d7c80fb 100644 --- a/ui-ngx/package.json +++ b/ui-ngx/package.json @@ -1,6 +1,6 @@ { "name": "thingsboard", - "version": "4.2.0", + "version": "4.2.1", "scripts": { "ng": "ng", "start": "node --max_old_space_size=8048 ./node_modules/@angular/cli/bin/ng serve --configuration development --host 0.0.0.0 --open", diff --git a/ui-ngx/pom.xml b/ui-ngx/pom.xml index 2f5130014a..905a1a8aca 100644 --- a/ui-ngx/pom.xml +++ b/ui-ngx/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard - 4.2.0-RC + 4.2.1-RC thingsboard org.thingsboard From ca8b110dc1c22eec9ae2d8c202da777c9a5990d4 Mon Sep 17 00:00:00 2001 From: VIacheslavKlimov Date: Thu, 9 Oct 2025 15:00:13 +0300 Subject: [PATCH 088/102] Cleanup upgrade from 4.2.0 to 4.2.1 --- .../main/data/upgrade/basic/schema_update.sql | 30 ------------------- .../DefaultDatabaseSchemaSettingsService.java | 2 +- 2 files changed, 1 insertion(+), 31 deletions(-) diff --git a/application/src/main/data/upgrade/basic/schema_update.sql b/application/src/main/data/upgrade/basic/schema_update.sql index add832ea6e..016e786776 100644 --- a/application/src/main/data/upgrade/basic/schema_update.sql +++ b/application/src/main/data/upgrade/basic/schema_update.sql @@ -14,33 +14,3 @@ -- limitations under the License. -- --- UPDATE OTA PACKAGE EXTERNAL ID START - -ALTER TABLE ota_package - ADD COLUMN IF NOT EXISTS external_id uuid; - -DO -$$ - BEGIN - IF NOT EXISTS(SELECT 1 FROM pg_constraint WHERE conname = 'ota_package_external_id_unq_key') THEN - ALTER TABLE ota_package ADD CONSTRAINT ota_package_external_id_unq_key UNIQUE (tenant_id, external_id); - END IF; - END; -$$; - --- UPDATE OTA PACKAGE EXTERNAL ID END - --- DROP INDEXES THAT DUPLICATE UNIQUE CONSTRAINT START - -DROP INDEX IF EXISTS idx_device_external_id; -DROP INDEX IF EXISTS idx_device_profile_external_id; -DROP INDEX IF EXISTS idx_asset_external_id; -DROP INDEX IF EXISTS idx_entity_view_external_id; -DROP INDEX IF EXISTS idx_rule_chain_external_id; -DROP INDEX IF EXISTS idx_dashboard_external_id; -DROP INDEX IF EXISTS idx_customer_external_id; -DROP INDEX IF EXISTS idx_widgets_bundle_external_id; - --- DROP INDEXES THAT DUPLICATE UNIQUE CONSTRAINT END - -ALTER TABLE mobile_app ADD COLUMN IF NOT EXISTS title varchar(255); \ No newline at end of file diff --git a/application/src/main/java/org/thingsboard/server/service/install/DefaultDatabaseSchemaSettingsService.java b/application/src/main/java/org/thingsboard/server/service/install/DefaultDatabaseSchemaSettingsService.java index e5bd026fb7..f41a530630 100644 --- a/application/src/main/java/org/thingsboard/server/service/install/DefaultDatabaseSchemaSettingsService.java +++ b/application/src/main/java/org/thingsboard/server/service/install/DefaultDatabaseSchemaSettingsService.java @@ -32,7 +32,7 @@ public class DefaultDatabaseSchemaSettingsService implements DatabaseSchemaSetti // This list should include all versions which are compatible for the upgrade. // The compatibility cycle usually breaks when we have some scripts written in Java that may not work after new release. - private static final List SUPPORTED_VERSIONS_FOR_UPGRADE = List.of("4.1.0"); + private static final List SUPPORTED_VERSIONS_FOR_UPGRADE = List.of("4.2.0"); private final ProjectInfo projectInfo; private final JdbcTemplate jdbcTemplate; From ad6ab9cc536537884724954b210ed355d9520ca7 Mon Sep 17 00:00:00 2001 From: dashevchenko Date: Thu, 9 Oct 2025 15:04:51 +0300 Subject: [PATCH 089/102] AI node: fixed resources validation --- .../server/actors/ruleChain/DefaultTbContext.java | 14 ++++++-------- .../org/thingsboard/rule/engine/api/TbContext.java | 2 +- .../org/thingsboard/rule/engine/ai/TbAiNode.java | 2 +- 3 files changed, 8 insertions(+), 10 deletions(-) diff --git a/application/src/main/java/org/thingsboard/server/actors/ruleChain/DefaultTbContext.java b/application/src/main/java/org/thingsboard/server/actors/ruleChain/DefaultTbContext.java index 88b04c7613..03830c1c4c 100644 --- a/application/src/main/java/org/thingsboard/server/actors/ruleChain/DefaultTbContext.java +++ b/application/src/main/java/org/thingsboard/server/actors/ruleChain/DefaultTbContext.java @@ -1063,18 +1063,16 @@ public class DefaultTbContext implements TbContext { @Override public void checkTenantEntity(EntityId entityId) throws TbNodeException { TenantId actualTenantId = TenantIdLoader.findTenantId(this, entityId); - assertSameTenantId(actualTenantId, entityId); + if (!getTenantId().equals(actualTenantId)) { + throw new TbNodeException("Entity with id: '" + entityId + "' specified in the configuration doesn't belong to the current tenant.", true); + } } @Override - public & HasTenantId, I extends EntityId> void checkTenantEntity(E entity) throws TbNodeException { + public & HasTenantId, I extends EntityId> void checkTenantOrSystemEntity(E entity) throws TbNodeException { TenantId actualTenantId = entity.getTenantId(); - assertSameTenantId(actualTenantId, entity.getId()); - } - - private void assertSameTenantId(TenantId tenantId, EntityId entityId) throws TbNodeException { - if (!getTenantId().equals(tenantId)) { - throw new TbNodeException("Entity with id: '" + entityId + "' specified in the configuration doesn't belong to the current tenant.", true); + if (!getTenantId().equals(actualTenantId) && !actualTenantId.isSysTenantId()) { + throw new TbNodeException("Entity with id: '" + entity.getId() + "' specified in the configuration doesn't belong to the current or system tenant.", true); } } diff --git a/rule-engine/rule-engine-api/src/main/java/org/thingsboard/rule/engine/api/TbContext.java b/rule-engine/rule-engine-api/src/main/java/org/thingsboard/rule/engine/api/TbContext.java index 920f00ed27..e703a7b256 100644 --- a/rule-engine/rule-engine-api/src/main/java/org/thingsboard/rule/engine/api/TbContext.java +++ b/rule-engine/rule-engine-api/src/main/java/org/thingsboard/rule/engine/api/TbContext.java @@ -255,7 +255,7 @@ public interface TbContext { void checkTenantEntity(EntityId entityId) throws TbNodeException; - & HasTenantId, I extends EntityId> void checkTenantEntity(E entity) throws TbNodeException; + & HasTenantId, I extends EntityId> void checkTenantOrSystemEntity(E entity) throws TbNodeException; boolean isLocalEntity(EntityId entityId); diff --git a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/ai/TbAiNode.java b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/ai/TbAiNode.java index 054b06fb54..654ed3f448 100644 --- a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/ai/TbAiNode.java +++ b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/ai/TbAiNode.java @@ -261,7 +261,7 @@ public final class TbAiNode extends TbAbstractExternalNode implements TbNode { if (!ResourceType.GENERAL.equals(resource.getResourceType())) { throw new TbNodeException("[" + ctx.getTenantId() + "] Resource with ID: [" + tbResourceId + "] has unsupported resource type: " + resource.getResourceType(), true); } - ctx.checkTenantEntity(resource); + ctx.checkTenantOrSystemEntity(resource); } private ListenableFuture> loadResources(TbContext ctx) { From 1c2bdd4c14567a36a9bca74d6e2a23cd9e815bc9 Mon Sep 17 00:00:00 2001 From: VIacheslavKlimov Date: Thu, 9 Oct 2025 16:08:06 +0300 Subject: [PATCH 090/102] TMP version set to 4.2.1-RC --- application/pom.xml | 2 +- common/actor/pom.xml | 2 +- common/cache/pom.xml | 2 +- common/cluster-api/pom.xml | 2 +- common/coap-server/pom.xml | 2 +- common/dao-api/pom.xml | 2 +- common/data/pom.xml | 2 +- common/discovery-api/pom.xml | 2 +- common/edge-api/pom.xml | 2 +- common/edqs/pom.xml | 2 +- common/message/pom.xml | 2 +- common/pom.xml | 2 +- common/proto/pom.xml | 2 +- common/queue/pom.xml | 2 +- common/script/pom.xml | 2 +- common/script/remote-js-client/pom.xml | 2 +- common/script/script-api/pom.xml | 2 +- common/stats/pom.xml | 2 +- common/transport/coap/pom.xml | 2 +- common/transport/http/pom.xml | 2 +- common/transport/lwm2m/pom.xml | 2 +- common/transport/mqtt/pom.xml | 2 +- common/transport/pom.xml | 2 +- common/transport/snmp/pom.xml | 2 +- common/transport/transport-api/pom.xml | 2 +- common/util/pom.xml | 2 +- common/version-control/pom.xml | 2 +- dao/pom.xml | 2 +- edqs/pom.xml | 2 +- monitoring/pom.xml | 2 +- msa/black-box-tests/pom.xml | 2 +- msa/edqs/pom.xml | 2 +- msa/js-executor/pom.xml | 2 +- msa/monitoring/pom.xml | 2 +- msa/pom.xml | 2 +- msa/tb-node/pom.xml | 2 +- msa/tb/pom.xml | 2 +- msa/transport/coap/pom.xml | 2 +- msa/transport/http/pom.xml | 2 +- msa/transport/lwm2m/pom.xml | 2 +- msa/transport/mqtt/pom.xml | 2 +- msa/transport/pom.xml | 2 +- msa/transport/snmp/pom.xml | 2 +- msa/vc-executor-docker/pom.xml | 2 +- msa/vc-executor/pom.xml | 2 +- msa/web-ui/pom.xml | 2 +- netty-mqtt/pom.xml | 4 ++-- pom.xml | 2 +- rest-client/pom.xml | 2 +- rule-engine/pom.xml | 2 +- rule-engine/rule-engine-api/pom.xml | 2 +- rule-engine/rule-engine-components/pom.xml | 2 +- tools/pom.xml | 2 +- transport/coap/pom.xml | 2 +- transport/http/pom.xml | 2 +- transport/lwm2m/pom.xml | 2 +- transport/mqtt/pom.xml | 2 +- transport/pom.xml | 2 +- transport/snmp/pom.xml | 2 +- ui-ngx/pom.xml | 2 +- 60 files changed, 61 insertions(+), 61 deletions(-) diff --git a/application/pom.xml b/application/pom.xml index 4cbd9c3b64..b415ee08a6 100644 --- a/application/pom.xml +++ b/application/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard - 4.3.0-SNAPSHOT + 4.2.1-RC thingsboard application diff --git a/common/actor/pom.xml b/common/actor/pom.xml index 92b174ea61..4149fe2ad9 100644 --- a/common/actor/pom.xml +++ b/common/actor/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard - 4.3.0-SNAPSHOT + 4.2.1-RC common org.thingsboard.common diff --git a/common/cache/pom.xml b/common/cache/pom.xml index fcf52e01b7..6831156fde 100644 --- a/common/cache/pom.xml +++ b/common/cache/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard - 4.3.0-SNAPSHOT + 4.2.1-RC common org.thingsboard.common diff --git a/common/cluster-api/pom.xml b/common/cluster-api/pom.xml index 1f4b5ff41b..78ef7980e8 100644 --- a/common/cluster-api/pom.xml +++ b/common/cluster-api/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard - 4.3.0-SNAPSHOT + 4.2.1-RC common org.thingsboard.common diff --git a/common/coap-server/pom.xml b/common/coap-server/pom.xml index 27ff645a0f..b24fd98f5d 100644 --- a/common/coap-server/pom.xml +++ b/common/coap-server/pom.xml @@ -22,7 +22,7 @@ 4.0.0 org.thingsboard - 4.3.0-SNAPSHOT + 4.2.1-RC common org.thingsboard.common diff --git a/common/dao-api/pom.xml b/common/dao-api/pom.xml index 160351d55d..555b4a54bb 100644 --- a/common/dao-api/pom.xml +++ b/common/dao-api/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard - 4.3.0-SNAPSHOT + 4.2.1-RC common org.thingsboard.common diff --git a/common/data/pom.xml b/common/data/pom.xml index 779565ed8e..f27df3687f 100644 --- a/common/data/pom.xml +++ b/common/data/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard - 4.3.0-SNAPSHOT + 4.2.1-RC common org.thingsboard.common diff --git a/common/discovery-api/pom.xml b/common/discovery-api/pom.xml index 3ae12c1bdb..cb5238a664 100644 --- a/common/discovery-api/pom.xml +++ b/common/discovery-api/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard - 4.3.0-SNAPSHOT + 4.2.1-RC common org.thingsboard.common diff --git a/common/edge-api/pom.xml b/common/edge-api/pom.xml index 7c2967cf3d..2b7db0cbd3 100644 --- a/common/edge-api/pom.xml +++ b/common/edge-api/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard - 4.3.0-SNAPSHOT + 4.2.1-RC common org.thingsboard.common diff --git a/common/edqs/pom.xml b/common/edqs/pom.xml index f7616d5b37..cc5c5db14e 100644 --- a/common/edqs/pom.xml +++ b/common/edqs/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard - 4.3.0-SNAPSHOT + 4.2.1-RC common org.thingsboard.common diff --git a/common/message/pom.xml b/common/message/pom.xml index 757c5de251..c84a4fde2d 100644 --- a/common/message/pom.xml +++ b/common/message/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard - 4.3.0-SNAPSHOT + 4.2.1-RC common org.thingsboard.common diff --git a/common/pom.xml b/common/pom.xml index a9195e49d4..f45a2bdd28 100644 --- a/common/pom.xml +++ b/common/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard - 4.3.0-SNAPSHOT + 4.2.1-RC thingsboard common diff --git a/common/proto/pom.xml b/common/proto/pom.xml index 09f9596d0c..30d4b0bc68 100644 --- a/common/proto/pom.xml +++ b/common/proto/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard - 4.3.0-SNAPSHOT + 4.2.1-RC common org.thingsboard.common diff --git a/common/queue/pom.xml b/common/queue/pom.xml index ffee41f8a9..69adac81b4 100644 --- a/common/queue/pom.xml +++ b/common/queue/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard - 4.3.0-SNAPSHOT + 4.2.1-RC common org.thingsboard.common diff --git a/common/script/pom.xml b/common/script/pom.xml index d0a3b47218..ab5ac032c7 100644 --- a/common/script/pom.xml +++ b/common/script/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard - 4.3.0-SNAPSHOT + 4.2.1-RC common org.thingsboard.common diff --git a/common/script/remote-js-client/pom.xml b/common/script/remote-js-client/pom.xml index b1547d2e4b..1a3e00a88c 100644 --- a/common/script/remote-js-client/pom.xml +++ b/common/script/remote-js-client/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard.common - 4.3.0-SNAPSHOT + 4.2.1-RC script org.thingsboard.common.script diff --git a/common/script/script-api/pom.xml b/common/script/script-api/pom.xml index b0fcf1c4ae..316f823a44 100644 --- a/common/script/script-api/pom.xml +++ b/common/script/script-api/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard.common - 4.3.0-SNAPSHOT + 4.2.1-RC script org.thingsboard.common.script diff --git a/common/stats/pom.xml b/common/stats/pom.xml index e44e098a39..4bf24411dc 100644 --- a/common/stats/pom.xml +++ b/common/stats/pom.xml @@ -22,7 +22,7 @@ 4.0.0 org.thingsboard - 4.3.0-SNAPSHOT + 4.2.1-RC common org.thingsboard.common diff --git a/common/transport/coap/pom.xml b/common/transport/coap/pom.xml index 37f33470b2..be3c62c057 100644 --- a/common/transport/coap/pom.xml +++ b/common/transport/coap/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard.common - 4.3.0-SNAPSHOT + 4.2.1-RC transport org.thingsboard.common.transport diff --git a/common/transport/http/pom.xml b/common/transport/http/pom.xml index e49af5b0a2..a136079cb5 100644 --- a/common/transport/http/pom.xml +++ b/common/transport/http/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard.common - 4.3.0-SNAPSHOT + 4.2.1-RC transport org.thingsboard.common.transport diff --git a/common/transport/lwm2m/pom.xml b/common/transport/lwm2m/pom.xml index 1787c851cd..904e359bcc 100644 --- a/common/transport/lwm2m/pom.xml +++ b/common/transport/lwm2m/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard.common - 4.3.0-SNAPSHOT + 4.2.1-RC transport org.thingsboard.common.transport diff --git a/common/transport/mqtt/pom.xml b/common/transport/mqtt/pom.xml index 9df8cf918e..45f9b76af7 100644 --- a/common/transport/mqtt/pom.xml +++ b/common/transport/mqtt/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard.common - 4.3.0-SNAPSHOT + 4.2.1-RC transport org.thingsboard.common.transport diff --git a/common/transport/pom.xml b/common/transport/pom.xml index 3bd21c96de..f786bcdd0e 100644 --- a/common/transport/pom.xml +++ b/common/transport/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard - 4.3.0-SNAPSHOT + 4.2.1-RC common org.thingsboard.common diff --git a/common/transport/snmp/pom.xml b/common/transport/snmp/pom.xml index 1286073a5f..d3b2c46d69 100644 --- a/common/transport/snmp/pom.xml +++ b/common/transport/snmp/pom.xml @@ -21,7 +21,7 @@ org.thingsboard.common - 4.3.0-SNAPSHOT + 4.2.1-RC transport diff --git a/common/transport/transport-api/pom.xml b/common/transport/transport-api/pom.xml index 19b075cb61..a3fee460e1 100644 --- a/common/transport/transport-api/pom.xml +++ b/common/transport/transport-api/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard.common - 4.3.0-SNAPSHOT + 4.2.1-RC transport org.thingsboard.common.transport diff --git a/common/util/pom.xml b/common/util/pom.xml index 804ed0de38..ebedf5dea3 100644 --- a/common/util/pom.xml +++ b/common/util/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard - 4.3.0-SNAPSHOT + 4.2.1-RC common org.thingsboard.common diff --git a/common/version-control/pom.xml b/common/version-control/pom.xml index 58c059915c..b6a0ea1d2e 100644 --- a/common/version-control/pom.xml +++ b/common/version-control/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard - 4.3.0-SNAPSHOT + 4.2.1-RC common org.thingsboard.common diff --git a/dao/pom.xml b/dao/pom.xml index fd6af62330..a1843959df 100644 --- a/dao/pom.xml +++ b/dao/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard - 4.3.0-SNAPSHOT + 4.2.1-RC thingsboard dao diff --git a/edqs/pom.xml b/edqs/pom.xml index 4a4c05be71..d648d9d786 100644 --- a/edqs/pom.xml +++ b/edqs/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard - 4.3.0-SNAPSHOT + 4.2.1-RC thingsboard edqs diff --git a/monitoring/pom.xml b/monitoring/pom.xml index 6dbcf85f07..77874c5ac9 100644 --- a/monitoring/pom.xml +++ b/monitoring/pom.xml @@ -21,7 +21,7 @@ 4.0.0 org.thingsboard - 4.3.0-SNAPSHOT + 4.2.1-RC thingsboard diff --git a/msa/black-box-tests/pom.xml b/msa/black-box-tests/pom.xml index 358c524e06..fed33f0002 100644 --- a/msa/black-box-tests/pom.xml +++ b/msa/black-box-tests/pom.xml @@ -21,7 +21,7 @@ org.thingsboard - 4.3.0-SNAPSHOT + 4.2.1-RC msa org.thingsboard.msa diff --git a/msa/edqs/pom.xml b/msa/edqs/pom.xml index 2dd4a9a584..c5be28e7bb 100644 --- a/msa/edqs/pom.xml +++ b/msa/edqs/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard - 4.3.0-SNAPSHOT + 4.2.1-RC msa org.thingsboard.msa diff --git a/msa/js-executor/pom.xml b/msa/js-executor/pom.xml index 9730e7f33b..651b142160 100644 --- a/msa/js-executor/pom.xml +++ b/msa/js-executor/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard - 4.3.0-SNAPSHOT + 4.2.1-RC msa org.thingsboard.msa diff --git a/msa/monitoring/pom.xml b/msa/monitoring/pom.xml index cb5b609e43..abd12ed9e0 100644 --- a/msa/monitoring/pom.xml +++ b/msa/monitoring/pom.xml @@ -22,7 +22,7 @@ 4.0.0 org.thingsboard - 4.3.0-SNAPSHOT + 4.2.1-RC msa diff --git a/msa/pom.xml b/msa/pom.xml index 7fbc5d3c02..6fcf7c3523 100644 --- a/msa/pom.xml +++ b/msa/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard - 4.3.0-SNAPSHOT + 4.2.1-RC thingsboard msa diff --git a/msa/tb-node/pom.xml b/msa/tb-node/pom.xml index 8df294260b..0103bf9352 100644 --- a/msa/tb-node/pom.xml +++ b/msa/tb-node/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard - 4.3.0-SNAPSHOT + 4.2.1-RC msa org.thingsboard.msa diff --git a/msa/tb/pom.xml b/msa/tb/pom.xml index 1cbbf9d1ec..28e1366a73 100644 --- a/msa/tb/pom.xml +++ b/msa/tb/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard - 4.3.0-SNAPSHOT + 4.2.1-RC msa org.thingsboard.msa diff --git a/msa/transport/coap/pom.xml b/msa/transport/coap/pom.xml index 256220078d..8aefc94122 100644 --- a/msa/transport/coap/pom.xml +++ b/msa/transport/coap/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard.msa - 4.3.0-SNAPSHOT + 4.2.1-RC transport org.thingsboard.msa.transport diff --git a/msa/transport/http/pom.xml b/msa/transport/http/pom.xml index 7e5871729d..a1481203de 100644 --- a/msa/transport/http/pom.xml +++ b/msa/transport/http/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard.msa - 4.3.0-SNAPSHOT + 4.2.1-RC transport org.thingsboard.msa.transport diff --git a/msa/transport/lwm2m/pom.xml b/msa/transport/lwm2m/pom.xml index ca6977eac6..7af2b16588 100644 --- a/msa/transport/lwm2m/pom.xml +++ b/msa/transport/lwm2m/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard.msa - 4.3.0-SNAPSHOT + 4.2.1-RC transport org.thingsboard.msa.transport diff --git a/msa/transport/mqtt/pom.xml b/msa/transport/mqtt/pom.xml index 172c4facb0..176dca19eb 100644 --- a/msa/transport/mqtt/pom.xml +++ b/msa/transport/mqtt/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard.msa - 4.3.0-SNAPSHOT + 4.2.1-RC transport org.thingsboard.msa.transport diff --git a/msa/transport/pom.xml b/msa/transport/pom.xml index a6cff4fd0b..afbab0c450 100644 --- a/msa/transport/pom.xml +++ b/msa/transport/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard - 4.3.0-SNAPSHOT + 4.2.1-RC msa org.thingsboard.msa diff --git a/msa/transport/snmp/pom.xml b/msa/transport/snmp/pom.xml index 79734822aa..553ed8bb37 100644 --- a/msa/transport/snmp/pom.xml +++ b/msa/transport/snmp/pom.xml @@ -21,7 +21,7 @@ org.thingsboard.msa transport - 4.3.0-SNAPSHOT + 4.2.1-RC org.thingsboard.msa.transport diff --git a/msa/vc-executor-docker/pom.xml b/msa/vc-executor-docker/pom.xml index 85634d8688..d0a291e246 100644 --- a/msa/vc-executor-docker/pom.xml +++ b/msa/vc-executor-docker/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard - 4.3.0-SNAPSHOT + 4.2.1-RC msa org.thingsboard.msa diff --git a/msa/vc-executor/pom.xml b/msa/vc-executor/pom.xml index 0acd863612..cf777c4c96 100644 --- a/msa/vc-executor/pom.xml +++ b/msa/vc-executor/pom.xml @@ -21,7 +21,7 @@ org.thingsboard - 4.3.0-SNAPSHOT + 4.2.1-RC msa org.thingsboard.msa diff --git a/msa/web-ui/pom.xml b/msa/web-ui/pom.xml index db7ed1424a..a56d0ca9d9 100644 --- a/msa/web-ui/pom.xml +++ b/msa/web-ui/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard - 4.3.0-SNAPSHOT + 4.2.1-RC msa org.thingsboard.msa diff --git a/netty-mqtt/pom.xml b/netty-mqtt/pom.xml index a09d1e2409..08f2a55f4a 100644 --- a/netty-mqtt/pom.xml +++ b/netty-mqtt/pom.xml @@ -19,11 +19,11 @@ 4.0.0 org.thingsboard - 4.3.0-SNAPSHOT + 4.2.1-RC thingsboard netty-mqtt - 4.3.0-SNAPSHOT + 4.2.1-RC jar Netty MQTT Client diff --git a/pom.xml b/pom.xml index 1676e3b56c..1283dcc01c 100755 --- a/pom.xml +++ b/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard thingsboard - 4.3.0-SNAPSHOT + 4.2.1-RC pom Thingsboard diff --git a/rest-client/pom.xml b/rest-client/pom.xml index c3cf2460dd..e6e82da954 100644 --- a/rest-client/pom.xml +++ b/rest-client/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard - 4.3.0-SNAPSHOT + 4.2.1-RC thingsboard rest-client diff --git a/rule-engine/pom.xml b/rule-engine/pom.xml index 7abb7be3a3..8a9ad96d95 100644 --- a/rule-engine/pom.xml +++ b/rule-engine/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard - 4.3.0-SNAPSHOT + 4.2.1-RC thingsboard rule-engine diff --git a/rule-engine/rule-engine-api/pom.xml b/rule-engine/rule-engine-api/pom.xml index 59397d4a92..8f449a5e64 100644 --- a/rule-engine/rule-engine-api/pom.xml +++ b/rule-engine/rule-engine-api/pom.xml @@ -22,7 +22,7 @@ 4.0.0 org.thingsboard - 4.3.0-SNAPSHOT + 4.2.1-RC rule-engine org.thingsboard.rule-engine diff --git a/rule-engine/rule-engine-components/pom.xml b/rule-engine/rule-engine-components/pom.xml index 98c7fde665..4fc5a55c5e 100644 --- a/rule-engine/rule-engine-components/pom.xml +++ b/rule-engine/rule-engine-components/pom.xml @@ -22,7 +22,7 @@ 4.0.0 org.thingsboard - 4.3.0-SNAPSHOT + 4.2.1-RC rule-engine org.thingsboard.rule-engine diff --git a/tools/pom.xml b/tools/pom.xml index 0b63d84b0a..cce1466971 100644 --- a/tools/pom.xml +++ b/tools/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard - 4.3.0-SNAPSHOT + 4.2.1-RC thingsboard tools diff --git a/transport/coap/pom.xml b/transport/coap/pom.xml index 67f16917d3..8ae86d381f 100644 --- a/transport/coap/pom.xml +++ b/transport/coap/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard - 4.3.0-SNAPSHOT + 4.2.1-RC transport org.thingsboard.transport diff --git a/transport/http/pom.xml b/transport/http/pom.xml index 371ecb84ca..487e1de186 100644 --- a/transport/http/pom.xml +++ b/transport/http/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard - 4.3.0-SNAPSHOT + 4.2.1-RC transport org.thingsboard.transport diff --git a/transport/lwm2m/pom.xml b/transport/lwm2m/pom.xml index 66a3066a88..30effe6e42 100644 --- a/transport/lwm2m/pom.xml +++ b/transport/lwm2m/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard - 4.3.0-SNAPSHOT + 4.2.1-RC transport org.thingsboard.transport diff --git a/transport/mqtt/pom.xml b/transport/mqtt/pom.xml index 57a73b1bbe..a609e8d002 100644 --- a/transport/mqtt/pom.xml +++ b/transport/mqtt/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard - 4.3.0-SNAPSHOT + 4.2.1-RC transport org.thingsboard.transport diff --git a/transport/pom.xml b/transport/pom.xml index a8658bbf40..c6a82349f5 100644 --- a/transport/pom.xml +++ b/transport/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard - 4.3.0-SNAPSHOT + 4.2.1-RC thingsboard transport diff --git a/transport/snmp/pom.xml b/transport/snmp/pom.xml index d5c3d1fe02..1894f09787 100644 --- a/transport/snmp/pom.xml +++ b/transport/snmp/pom.xml @@ -21,7 +21,7 @@ org.thingsboard - 4.3.0-SNAPSHOT + 4.2.1-RC transport diff --git a/ui-ngx/pom.xml b/ui-ngx/pom.xml index 56fc4ed628..905a1a8aca 100644 --- a/ui-ngx/pom.xml +++ b/ui-ngx/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard - 4.3.0-SNAPSHOT + 4.2.1-RC thingsboard org.thingsboard From 514812ae024ab346a8c6044570277440952b0ced Mon Sep 17 00:00:00 2001 From: VIacheslavKlimov Date: Thu, 9 Oct 2025 16:11:25 +0300 Subject: [PATCH 091/102] Version set to 4.3.0-SNAPSHOT --- application/pom.xml | 2 +- common/actor/pom.xml | 2 +- common/cache/pom.xml | 2 +- common/cluster-api/pom.xml | 2 +- common/coap-server/pom.xml | 2 +- common/dao-api/pom.xml | 2 +- common/data/pom.xml | 2 +- common/discovery-api/pom.xml | 2 +- common/edge-api/pom.xml | 2 +- common/edqs/pom.xml | 2 +- common/message/pom.xml | 2 +- common/pom.xml | 2 +- common/proto/pom.xml | 2 +- common/queue/pom.xml | 2 +- common/script/pom.xml | 2 +- common/script/remote-js-client/pom.xml | 2 +- common/script/script-api/pom.xml | 2 +- common/stats/pom.xml | 2 +- common/transport/coap/pom.xml | 2 +- common/transport/http/pom.xml | 2 +- common/transport/lwm2m/pom.xml | 2 +- common/transport/mqtt/pom.xml | 2 +- common/transport/pom.xml | 2 +- common/transport/snmp/pom.xml | 2 +- common/transport/transport-api/pom.xml | 2 +- common/util/pom.xml | 2 +- common/version-control/pom.xml | 2 +- dao/pom.xml | 2 +- edqs/pom.xml | 2 +- monitoring/pom.xml | 2 +- msa/black-box-tests/pom.xml | 2 +- msa/edqs/pom.xml | 2 +- msa/js-executor/pom.xml | 2 +- msa/monitoring/pom.xml | 2 +- msa/pom.xml | 2 +- msa/tb-node/pom.xml | 2 +- msa/tb/pom.xml | 2 +- msa/transport/coap/pom.xml | 2 +- msa/transport/http/pom.xml | 2 +- msa/transport/lwm2m/pom.xml | 2 +- msa/transport/mqtt/pom.xml | 2 +- msa/transport/pom.xml | 2 +- msa/transport/snmp/pom.xml | 2 +- msa/vc-executor-docker/pom.xml | 2 +- msa/vc-executor/pom.xml | 2 +- msa/web-ui/pom.xml | 2 +- netty-mqtt/pom.xml | 4 ++-- pom.xml | 2 +- rest-client/pom.xml | 2 +- rule-engine/pom.xml | 2 +- rule-engine/rule-engine-api/pom.xml | 2 +- rule-engine/rule-engine-components/pom.xml | 2 +- tools/pom.xml | 2 +- transport/coap/pom.xml | 2 +- transport/http/pom.xml | 2 +- transport/lwm2m/pom.xml | 2 +- transport/mqtt/pom.xml | 2 +- transport/pom.xml | 2 +- transport/snmp/pom.xml | 2 +- ui-ngx/pom.xml | 2 +- 60 files changed, 61 insertions(+), 61 deletions(-) diff --git a/application/pom.xml b/application/pom.xml index b415ee08a6..4cbd9c3b64 100644 --- a/application/pom.xml +++ b/application/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard - 4.2.1-RC + 4.3.0-SNAPSHOT thingsboard application diff --git a/common/actor/pom.xml b/common/actor/pom.xml index 4149fe2ad9..92b174ea61 100644 --- a/common/actor/pom.xml +++ b/common/actor/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard - 4.2.1-RC + 4.3.0-SNAPSHOT common org.thingsboard.common diff --git a/common/cache/pom.xml b/common/cache/pom.xml index 6831156fde..fcf52e01b7 100644 --- a/common/cache/pom.xml +++ b/common/cache/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard - 4.2.1-RC + 4.3.0-SNAPSHOT common org.thingsboard.common diff --git a/common/cluster-api/pom.xml b/common/cluster-api/pom.xml index 78ef7980e8..1f4b5ff41b 100644 --- a/common/cluster-api/pom.xml +++ b/common/cluster-api/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard - 4.2.1-RC + 4.3.0-SNAPSHOT common org.thingsboard.common diff --git a/common/coap-server/pom.xml b/common/coap-server/pom.xml index b24fd98f5d..27ff645a0f 100644 --- a/common/coap-server/pom.xml +++ b/common/coap-server/pom.xml @@ -22,7 +22,7 @@ 4.0.0 org.thingsboard - 4.2.1-RC + 4.3.0-SNAPSHOT common org.thingsboard.common diff --git a/common/dao-api/pom.xml b/common/dao-api/pom.xml index 555b4a54bb..160351d55d 100644 --- a/common/dao-api/pom.xml +++ b/common/dao-api/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard - 4.2.1-RC + 4.3.0-SNAPSHOT common org.thingsboard.common diff --git a/common/data/pom.xml b/common/data/pom.xml index f27df3687f..779565ed8e 100644 --- a/common/data/pom.xml +++ b/common/data/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard - 4.2.1-RC + 4.3.0-SNAPSHOT common org.thingsboard.common diff --git a/common/discovery-api/pom.xml b/common/discovery-api/pom.xml index cb5238a664..3ae12c1bdb 100644 --- a/common/discovery-api/pom.xml +++ b/common/discovery-api/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard - 4.2.1-RC + 4.3.0-SNAPSHOT common org.thingsboard.common diff --git a/common/edge-api/pom.xml b/common/edge-api/pom.xml index 2b7db0cbd3..7c2967cf3d 100644 --- a/common/edge-api/pom.xml +++ b/common/edge-api/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard - 4.2.1-RC + 4.3.0-SNAPSHOT common org.thingsboard.common diff --git a/common/edqs/pom.xml b/common/edqs/pom.xml index cc5c5db14e..f7616d5b37 100644 --- a/common/edqs/pom.xml +++ b/common/edqs/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard - 4.2.1-RC + 4.3.0-SNAPSHOT common org.thingsboard.common diff --git a/common/message/pom.xml b/common/message/pom.xml index c84a4fde2d..757c5de251 100644 --- a/common/message/pom.xml +++ b/common/message/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard - 4.2.1-RC + 4.3.0-SNAPSHOT common org.thingsboard.common diff --git a/common/pom.xml b/common/pom.xml index f45a2bdd28..a9195e49d4 100644 --- a/common/pom.xml +++ b/common/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard - 4.2.1-RC + 4.3.0-SNAPSHOT thingsboard common diff --git a/common/proto/pom.xml b/common/proto/pom.xml index 30d4b0bc68..09f9596d0c 100644 --- a/common/proto/pom.xml +++ b/common/proto/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard - 4.2.1-RC + 4.3.0-SNAPSHOT common org.thingsboard.common diff --git a/common/queue/pom.xml b/common/queue/pom.xml index 69adac81b4..ffee41f8a9 100644 --- a/common/queue/pom.xml +++ b/common/queue/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard - 4.2.1-RC + 4.3.0-SNAPSHOT common org.thingsboard.common diff --git a/common/script/pom.xml b/common/script/pom.xml index ab5ac032c7..d0a3b47218 100644 --- a/common/script/pom.xml +++ b/common/script/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard - 4.2.1-RC + 4.3.0-SNAPSHOT common org.thingsboard.common diff --git a/common/script/remote-js-client/pom.xml b/common/script/remote-js-client/pom.xml index 1a3e00a88c..b1547d2e4b 100644 --- a/common/script/remote-js-client/pom.xml +++ b/common/script/remote-js-client/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard.common - 4.2.1-RC + 4.3.0-SNAPSHOT script org.thingsboard.common.script diff --git a/common/script/script-api/pom.xml b/common/script/script-api/pom.xml index 316f823a44..b0fcf1c4ae 100644 --- a/common/script/script-api/pom.xml +++ b/common/script/script-api/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard.common - 4.2.1-RC + 4.3.0-SNAPSHOT script org.thingsboard.common.script diff --git a/common/stats/pom.xml b/common/stats/pom.xml index 4bf24411dc..e44e098a39 100644 --- a/common/stats/pom.xml +++ b/common/stats/pom.xml @@ -22,7 +22,7 @@ 4.0.0 org.thingsboard - 4.2.1-RC + 4.3.0-SNAPSHOT common org.thingsboard.common diff --git a/common/transport/coap/pom.xml b/common/transport/coap/pom.xml index be3c62c057..37f33470b2 100644 --- a/common/transport/coap/pom.xml +++ b/common/transport/coap/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard.common - 4.2.1-RC + 4.3.0-SNAPSHOT transport org.thingsboard.common.transport diff --git a/common/transport/http/pom.xml b/common/transport/http/pom.xml index a136079cb5..e49af5b0a2 100644 --- a/common/transport/http/pom.xml +++ b/common/transport/http/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard.common - 4.2.1-RC + 4.3.0-SNAPSHOT transport org.thingsboard.common.transport diff --git a/common/transport/lwm2m/pom.xml b/common/transport/lwm2m/pom.xml index 904e359bcc..1787c851cd 100644 --- a/common/transport/lwm2m/pom.xml +++ b/common/transport/lwm2m/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard.common - 4.2.1-RC + 4.3.0-SNAPSHOT transport org.thingsboard.common.transport diff --git a/common/transport/mqtt/pom.xml b/common/transport/mqtt/pom.xml index 45f9b76af7..9df8cf918e 100644 --- a/common/transport/mqtt/pom.xml +++ b/common/transport/mqtt/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard.common - 4.2.1-RC + 4.3.0-SNAPSHOT transport org.thingsboard.common.transport diff --git a/common/transport/pom.xml b/common/transport/pom.xml index f786bcdd0e..3bd21c96de 100644 --- a/common/transport/pom.xml +++ b/common/transport/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard - 4.2.1-RC + 4.3.0-SNAPSHOT common org.thingsboard.common diff --git a/common/transport/snmp/pom.xml b/common/transport/snmp/pom.xml index d3b2c46d69..1286073a5f 100644 --- a/common/transport/snmp/pom.xml +++ b/common/transport/snmp/pom.xml @@ -21,7 +21,7 @@ org.thingsboard.common - 4.2.1-RC + 4.3.0-SNAPSHOT transport diff --git a/common/transport/transport-api/pom.xml b/common/transport/transport-api/pom.xml index a3fee460e1..19b075cb61 100644 --- a/common/transport/transport-api/pom.xml +++ b/common/transport/transport-api/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard.common - 4.2.1-RC + 4.3.0-SNAPSHOT transport org.thingsboard.common.transport diff --git a/common/util/pom.xml b/common/util/pom.xml index ebedf5dea3..804ed0de38 100644 --- a/common/util/pom.xml +++ b/common/util/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard - 4.2.1-RC + 4.3.0-SNAPSHOT common org.thingsboard.common diff --git a/common/version-control/pom.xml b/common/version-control/pom.xml index b6a0ea1d2e..58c059915c 100644 --- a/common/version-control/pom.xml +++ b/common/version-control/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard - 4.2.1-RC + 4.3.0-SNAPSHOT common org.thingsboard.common diff --git a/dao/pom.xml b/dao/pom.xml index a1843959df..fd6af62330 100644 --- a/dao/pom.xml +++ b/dao/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard - 4.2.1-RC + 4.3.0-SNAPSHOT thingsboard dao diff --git a/edqs/pom.xml b/edqs/pom.xml index d648d9d786..4a4c05be71 100644 --- a/edqs/pom.xml +++ b/edqs/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard - 4.2.1-RC + 4.3.0-SNAPSHOT thingsboard edqs diff --git a/monitoring/pom.xml b/monitoring/pom.xml index 77874c5ac9..6dbcf85f07 100644 --- a/monitoring/pom.xml +++ b/monitoring/pom.xml @@ -21,7 +21,7 @@ 4.0.0 org.thingsboard - 4.2.1-RC + 4.3.0-SNAPSHOT thingsboard diff --git a/msa/black-box-tests/pom.xml b/msa/black-box-tests/pom.xml index fed33f0002..358c524e06 100644 --- a/msa/black-box-tests/pom.xml +++ b/msa/black-box-tests/pom.xml @@ -21,7 +21,7 @@ org.thingsboard - 4.2.1-RC + 4.3.0-SNAPSHOT msa org.thingsboard.msa diff --git a/msa/edqs/pom.xml b/msa/edqs/pom.xml index c5be28e7bb..2dd4a9a584 100644 --- a/msa/edqs/pom.xml +++ b/msa/edqs/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard - 4.2.1-RC + 4.3.0-SNAPSHOT msa org.thingsboard.msa diff --git a/msa/js-executor/pom.xml b/msa/js-executor/pom.xml index 651b142160..9730e7f33b 100644 --- a/msa/js-executor/pom.xml +++ b/msa/js-executor/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard - 4.2.1-RC + 4.3.0-SNAPSHOT msa org.thingsboard.msa diff --git a/msa/monitoring/pom.xml b/msa/monitoring/pom.xml index abd12ed9e0..cb5b609e43 100644 --- a/msa/monitoring/pom.xml +++ b/msa/monitoring/pom.xml @@ -22,7 +22,7 @@ 4.0.0 org.thingsboard - 4.2.1-RC + 4.3.0-SNAPSHOT msa diff --git a/msa/pom.xml b/msa/pom.xml index 6fcf7c3523..7fbc5d3c02 100644 --- a/msa/pom.xml +++ b/msa/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard - 4.2.1-RC + 4.3.0-SNAPSHOT thingsboard msa diff --git a/msa/tb-node/pom.xml b/msa/tb-node/pom.xml index 0103bf9352..8df294260b 100644 --- a/msa/tb-node/pom.xml +++ b/msa/tb-node/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard - 4.2.1-RC + 4.3.0-SNAPSHOT msa org.thingsboard.msa diff --git a/msa/tb/pom.xml b/msa/tb/pom.xml index 28e1366a73..1cbbf9d1ec 100644 --- a/msa/tb/pom.xml +++ b/msa/tb/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard - 4.2.1-RC + 4.3.0-SNAPSHOT msa org.thingsboard.msa diff --git a/msa/transport/coap/pom.xml b/msa/transport/coap/pom.xml index 8aefc94122..256220078d 100644 --- a/msa/transport/coap/pom.xml +++ b/msa/transport/coap/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard.msa - 4.2.1-RC + 4.3.0-SNAPSHOT transport org.thingsboard.msa.transport diff --git a/msa/transport/http/pom.xml b/msa/transport/http/pom.xml index a1481203de..7e5871729d 100644 --- a/msa/transport/http/pom.xml +++ b/msa/transport/http/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard.msa - 4.2.1-RC + 4.3.0-SNAPSHOT transport org.thingsboard.msa.transport diff --git a/msa/transport/lwm2m/pom.xml b/msa/transport/lwm2m/pom.xml index 7af2b16588..ca6977eac6 100644 --- a/msa/transport/lwm2m/pom.xml +++ b/msa/transport/lwm2m/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard.msa - 4.2.1-RC + 4.3.0-SNAPSHOT transport org.thingsboard.msa.transport diff --git a/msa/transport/mqtt/pom.xml b/msa/transport/mqtt/pom.xml index 176dca19eb..172c4facb0 100644 --- a/msa/transport/mqtt/pom.xml +++ b/msa/transport/mqtt/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard.msa - 4.2.1-RC + 4.3.0-SNAPSHOT transport org.thingsboard.msa.transport diff --git a/msa/transport/pom.xml b/msa/transport/pom.xml index afbab0c450..a6cff4fd0b 100644 --- a/msa/transport/pom.xml +++ b/msa/transport/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard - 4.2.1-RC + 4.3.0-SNAPSHOT msa org.thingsboard.msa diff --git a/msa/transport/snmp/pom.xml b/msa/transport/snmp/pom.xml index 553ed8bb37..79734822aa 100644 --- a/msa/transport/snmp/pom.xml +++ b/msa/transport/snmp/pom.xml @@ -21,7 +21,7 @@ org.thingsboard.msa transport - 4.2.1-RC + 4.3.0-SNAPSHOT org.thingsboard.msa.transport diff --git a/msa/vc-executor-docker/pom.xml b/msa/vc-executor-docker/pom.xml index d0a291e246..85634d8688 100644 --- a/msa/vc-executor-docker/pom.xml +++ b/msa/vc-executor-docker/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard - 4.2.1-RC + 4.3.0-SNAPSHOT msa org.thingsboard.msa diff --git a/msa/vc-executor/pom.xml b/msa/vc-executor/pom.xml index cf777c4c96..0acd863612 100644 --- a/msa/vc-executor/pom.xml +++ b/msa/vc-executor/pom.xml @@ -21,7 +21,7 @@ org.thingsboard - 4.2.1-RC + 4.3.0-SNAPSHOT msa org.thingsboard.msa diff --git a/msa/web-ui/pom.xml b/msa/web-ui/pom.xml index a56d0ca9d9..db7ed1424a 100644 --- a/msa/web-ui/pom.xml +++ b/msa/web-ui/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard - 4.2.1-RC + 4.3.0-SNAPSHOT msa org.thingsboard.msa diff --git a/netty-mqtt/pom.xml b/netty-mqtt/pom.xml index 08f2a55f4a..a09d1e2409 100644 --- a/netty-mqtt/pom.xml +++ b/netty-mqtt/pom.xml @@ -19,11 +19,11 @@ 4.0.0 org.thingsboard - 4.2.1-RC + 4.3.0-SNAPSHOT thingsboard netty-mqtt - 4.2.1-RC + 4.3.0-SNAPSHOT jar Netty MQTT Client diff --git a/pom.xml b/pom.xml index 1283dcc01c..1676e3b56c 100755 --- a/pom.xml +++ b/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard thingsboard - 4.2.1-RC + 4.3.0-SNAPSHOT pom Thingsboard diff --git a/rest-client/pom.xml b/rest-client/pom.xml index e6e82da954..c3cf2460dd 100644 --- a/rest-client/pom.xml +++ b/rest-client/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard - 4.2.1-RC + 4.3.0-SNAPSHOT thingsboard rest-client diff --git a/rule-engine/pom.xml b/rule-engine/pom.xml index 8a9ad96d95..7abb7be3a3 100644 --- a/rule-engine/pom.xml +++ b/rule-engine/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard - 4.2.1-RC + 4.3.0-SNAPSHOT thingsboard rule-engine diff --git a/rule-engine/rule-engine-api/pom.xml b/rule-engine/rule-engine-api/pom.xml index 8f449a5e64..59397d4a92 100644 --- a/rule-engine/rule-engine-api/pom.xml +++ b/rule-engine/rule-engine-api/pom.xml @@ -22,7 +22,7 @@ 4.0.0 org.thingsboard - 4.2.1-RC + 4.3.0-SNAPSHOT rule-engine org.thingsboard.rule-engine diff --git a/rule-engine/rule-engine-components/pom.xml b/rule-engine/rule-engine-components/pom.xml index 4fc5a55c5e..98c7fde665 100644 --- a/rule-engine/rule-engine-components/pom.xml +++ b/rule-engine/rule-engine-components/pom.xml @@ -22,7 +22,7 @@ 4.0.0 org.thingsboard - 4.2.1-RC + 4.3.0-SNAPSHOT rule-engine org.thingsboard.rule-engine diff --git a/tools/pom.xml b/tools/pom.xml index cce1466971..0b63d84b0a 100644 --- a/tools/pom.xml +++ b/tools/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard - 4.2.1-RC + 4.3.0-SNAPSHOT thingsboard tools diff --git a/transport/coap/pom.xml b/transport/coap/pom.xml index 8ae86d381f..67f16917d3 100644 --- a/transport/coap/pom.xml +++ b/transport/coap/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard - 4.2.1-RC + 4.3.0-SNAPSHOT transport org.thingsboard.transport diff --git a/transport/http/pom.xml b/transport/http/pom.xml index 487e1de186..371ecb84ca 100644 --- a/transport/http/pom.xml +++ b/transport/http/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard - 4.2.1-RC + 4.3.0-SNAPSHOT transport org.thingsboard.transport diff --git a/transport/lwm2m/pom.xml b/transport/lwm2m/pom.xml index 30effe6e42..66a3066a88 100644 --- a/transport/lwm2m/pom.xml +++ b/transport/lwm2m/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard - 4.2.1-RC + 4.3.0-SNAPSHOT transport org.thingsboard.transport diff --git a/transport/mqtt/pom.xml b/transport/mqtt/pom.xml index a609e8d002..57a73b1bbe 100644 --- a/transport/mqtt/pom.xml +++ b/transport/mqtt/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard - 4.2.1-RC + 4.3.0-SNAPSHOT transport org.thingsboard.transport diff --git a/transport/pom.xml b/transport/pom.xml index c6a82349f5..a8658bbf40 100644 --- a/transport/pom.xml +++ b/transport/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard - 4.2.1-RC + 4.3.0-SNAPSHOT thingsboard transport diff --git a/transport/snmp/pom.xml b/transport/snmp/pom.xml index 1894f09787..d5c3d1fe02 100644 --- a/transport/snmp/pom.xml +++ b/transport/snmp/pom.xml @@ -21,7 +21,7 @@ org.thingsboard - 4.2.1-RC + 4.3.0-SNAPSHOT transport diff --git a/ui-ngx/pom.xml b/ui-ngx/pom.xml index 905a1a8aca..56fc4ed628 100644 --- a/ui-ngx/pom.xml +++ b/ui-ngx/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard - 4.2.1-RC + 4.3.0-SNAPSHOT thingsboard org.thingsboard From 2e37b53d096dee3017e5fc8ee5de6c2a91e077d7 Mon Sep 17 00:00:00 2001 From: VIacheslavKlimov Date: Thu, 9 Oct 2025 16:15:00 +0300 Subject: [PATCH 092/102] Update supported versions for upgrade --- .../service/install/DefaultDatabaseSchemaSettingsService.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/application/src/main/java/org/thingsboard/server/service/install/DefaultDatabaseSchemaSettingsService.java b/application/src/main/java/org/thingsboard/server/service/install/DefaultDatabaseSchemaSettingsService.java index f41a530630..f48f4621ef 100644 --- a/application/src/main/java/org/thingsboard/server/service/install/DefaultDatabaseSchemaSettingsService.java +++ b/application/src/main/java/org/thingsboard/server/service/install/DefaultDatabaseSchemaSettingsService.java @@ -32,7 +32,7 @@ public class DefaultDatabaseSchemaSettingsService implements DatabaseSchemaSetti // This list should include all versions which are compatible for the upgrade. // The compatibility cycle usually breaks when we have some scripts written in Java that may not work after new release. - private static final List SUPPORTED_VERSIONS_FOR_UPGRADE = List.of("4.2.0"); + private static final List SUPPORTED_VERSIONS_FOR_UPGRADE = List.of("4.2.1"); private final ProjectInfo projectInfo; private final JdbcTemplate jdbcTemplate; From f1da967a7d0ee27d59ad0be213e052e1d1434aff Mon Sep 17 00:00:00 2001 From: dashevchenko Date: Fri, 10 Oct 2025 17:46:15 +0300 Subject: [PATCH 093/102] added uniquifyStrategy --- .../main/data/upgrade/basic/schema_update.sql | 2 -- .../server/controller/AssetController.java | 14 +++++++---- .../controller/ControllerConstants.java | 7 +++++- .../server/controller/CustomerController.java | 14 +++++++---- .../server/controller/DeviceController.java | 24 ++++++++++++------- .../controller/EntityViewController.java | 14 +++++++---- .../server/controller/Lwm2mController.java | 2 +- .../controller/AssetControllerTest.java | 18 +++++++++----- .../controller/CustomerControllerTest.java | 19 ++++++++++----- .../controller/DeviceControllerTest.java | 18 +++++++++----- .../controller/EntityViewControllerTest.java | 18 +++++++++----- .../common/data/NameConflictStrategy.java | 4 ++-- .../server/common/data/UniquifyStrategy.java | 23 ++++++++++++++++++ .../java/org/thingsboard/server/dao/Dao.java | 2 +- .../dao/entity/AbstractEntityService.java | 23 ++++++++++++------ .../dao/entityview/EntityViewServiceImpl.java | 12 ++++------ .../validator/EntityViewDataValidator.java | 8 +++++++ .../server/dao/sql/asset/AssetRepository.java | 4 ++-- .../server/dao/sql/asset/JpaAssetDao.java | 6 ++--- .../dao/sql/customer/CustomerRepository.java | 4 ++-- .../dao/sql/customer/JpaCustomerDao.java | 4 ++-- .../dao/sql/device/DeviceRepository.java | 4 ++-- .../server/dao/sql/device/JpaDeviceDao.java | 4 ++-- .../sql/entityview/EntityViewRepository.java | 4 ++-- .../dao/sql/entityview/JpaEntityViewDao.java | 4 ++-- .../main/resources/sql/schema-entities.sql | 1 - 26 files changed, 170 insertions(+), 87 deletions(-) create mode 100644 common/data/src/main/java/org/thingsboard/server/common/data/UniquifyStrategy.java diff --git a/application/src/main/data/upgrade/basic/schema_update.sql b/application/src/main/data/upgrade/basic/schema_update.sql index 3986a3c222..0e7b0d9455 100644 --- a/application/src/main/data/upgrade/basic/schema_update.sql +++ b/application/src/main/data/upgrade/basic/schema_update.sql @@ -47,5 +47,3 @@ WHERE NOT ( -- UPDATE TENANT PROFILE CONFIGURATION END -ALTER TABLE entity_view ADD CONSTRAINT entity_view_name_unq_key UNIQUE (tenant_id, name); - diff --git a/application/src/main/java/org/thingsboard/server/controller/AssetController.java b/application/src/main/java/org/thingsboard/server/controller/AssetController.java index d00725f1c1..d4223d30f1 100644 --- a/application/src/main/java/org/thingsboard/server/controller/AssetController.java +++ b/application/src/main/java/org/thingsboard/server/controller/AssetController.java @@ -36,6 +36,7 @@ import org.thingsboard.server.common.data.Customer; import org.thingsboard.server.common.data.EntitySubtype; import org.thingsboard.server.common.data.NameConflictPolicy; import org.thingsboard.server.common.data.NameConflictStrategy; +import org.thingsboard.server.common.data.UniquifyStrategy; import org.thingsboard.server.common.data.asset.Asset; import org.thingsboard.server.common.data.asset.AssetInfo; import org.thingsboard.server.common.data.asset.AssetSearchQuery; @@ -79,7 +80,7 @@ import static org.thingsboard.server.controller.ControllerConstants.EDGE_ID_PARA import static org.thingsboard.server.controller.ControllerConstants.EDGE_UNASSIGN_ASYNC_FIRST_STEP_DESCRIPTION; import static org.thingsboard.server.controller.ControllerConstants.EDGE_UNASSIGN_RECEIVE_STEP_DESCRIPTION; import static org.thingsboard.server.controller.ControllerConstants.NAME_CONFLICT_POLICY_DESC; -import static org.thingsboard.server.controller.ControllerConstants.NAME_CONFLICT_SEPARATOR_DESC; +import static org.thingsboard.server.controller.ControllerConstants.UNIQUIFY_SEPARATOR_DESC; import static org.thingsboard.server.controller.ControllerConstants.PAGE_DATA_PARAMETERS; import static org.thingsboard.server.controller.ControllerConstants.PAGE_NUMBER_DESCRIPTION; import static org.thingsboard.server.controller.ControllerConstants.PAGE_SIZE_DESCRIPTION; @@ -87,6 +88,7 @@ import static org.thingsboard.server.controller.ControllerConstants.SORT_ORDER_D import static org.thingsboard.server.controller.ControllerConstants.SORT_PROPERTY_DESCRIPTION; import static org.thingsboard.server.controller.ControllerConstants.TENANT_AUTHORITY_PARAGRAPH; import static org.thingsboard.server.controller.ControllerConstants.TENANT_OR_CUSTOMER_AUTHORITY_PARAGRAPH; +import static org.thingsboard.server.controller.ControllerConstants.UNIQUIFY_STRATEGY_DESC; import static org.thingsboard.server.controller.ControllerConstants.UUID_WIKI_LINK; import static org.thingsboard.server.controller.EdgeController.EDGE_ID; @@ -143,12 +145,14 @@ public class AssetController extends BaseController { @ResponseBody public Asset saveAsset(@io.swagger.v3.oas.annotations.parameters.RequestBody(description = "A JSON value representing the asset.") @RequestBody Asset asset, @Parameter(description = NAME_CONFLICT_POLICY_DESC) - @RequestParam(name = "policy", defaultValue = "FAIL") NameConflictPolicy policy, - @Parameter(description = NAME_CONFLICT_SEPARATOR_DESC) - @RequestParam(name = "separator", defaultValue = "_") String separator) throws Exception { + @RequestParam(name = "nameConflictPolicy", defaultValue = "FAIL") NameConflictPolicy nameConflictPolicy, + @Parameter(description = UNIQUIFY_SEPARATOR_DESC) + @RequestParam(name = "uniquifySeparator", defaultValue = "_") String uniquifySeparator, + @Parameter(description = UNIQUIFY_STRATEGY_DESC) + @RequestParam(name = "uniquifyStrategy", defaultValue = "RANDOM") UniquifyStrategy uniquifyStrategy) throws Exception { asset.setTenantId(getTenantId()); checkEntity(asset.getId(), asset, Resource.ASSET); - return tbAssetService.save(asset, new NameConflictStrategy(policy, separator), getCurrentUser()); + return tbAssetService.save(asset, new NameConflictStrategy(nameConflictPolicy, uniquifySeparator, uniquifyStrategy), getCurrentUser()); } @ApiOperation(value = "Delete asset (deleteAsset)", diff --git a/application/src/main/java/org/thingsboard/server/controller/ControllerConstants.java b/application/src/main/java/org/thingsboard/server/controller/ControllerConstants.java index 7b5bf8b165..9e533f6d6e 100644 --- a/application/src/main/java/org/thingsboard/server/controller/ControllerConstants.java +++ b/application/src/main/java/org/thingsboard/server/controller/ControllerConstants.java @@ -1749,7 +1749,12 @@ public class ControllerConstants { " If omitted, FAIL policy is applied. FAIL policy implies exception will be thrown if an entity with the same name already exists. " + " UNIQUIFY policy appends a suffix to the entity name, if a name conflict occurs."; - public static final String NAME_CONFLICT_SEPARATOR_DESC = "Optional value of name suffix separator used by UNIQUIFY policy. By default, underscore separator is used. " + + public static final String UNIQUIFY_SEPARATOR_DESC = "Optional value of name suffix separator used by UNIQUIFY policy. By default, underscore separator is used. " + "For example, strategy is UNIQUIFY, separator is '-'; if a name conflict occurs for entity name 'test-name', " + "created entity will have name like 'test-name-7fsh4f'."; + + public static final String UNIQUIFY_STRATEGY_DESC = "Optional value of uniquify strategy used by UNIQUIFY policy. Possible values: RANDOM or INCREMENTAL. " + + "By default, RANDOM strategy is used, which means random alphanumeric string will be added as a suffix to entity name. " + + "For example, strategy is UNIQUIFY, uniquify strategy is INCREMENTAL; if a name conflict occurs for entity name 'test-name', " + + "created entity will have name like 'test-name-1."; } diff --git a/application/src/main/java/org/thingsboard/server/controller/CustomerController.java b/application/src/main/java/org/thingsboard/server/controller/CustomerController.java index 839bcc984f..fca7ce3e86 100644 --- a/application/src/main/java/org/thingsboard/server/controller/CustomerController.java +++ b/application/src/main/java/org/thingsboard/server/controller/CustomerController.java @@ -34,6 +34,7 @@ import org.thingsboard.common.util.JacksonUtil; import org.thingsboard.server.common.data.Customer; import org.thingsboard.server.common.data.NameConflictStrategy; import org.thingsboard.server.common.data.NameConflictPolicy; +import org.thingsboard.server.common.data.UniquifyStrategy; import org.thingsboard.server.common.data.exception.ThingsboardException; import org.thingsboard.server.common.data.id.CustomerId; import org.thingsboard.server.common.data.id.TenantId; @@ -50,7 +51,7 @@ import static org.thingsboard.server.controller.ControllerConstants.CUSTOMER_ID_ import static org.thingsboard.server.controller.ControllerConstants.CUSTOMER_TEXT_SEARCH_DESCRIPTION; import static org.thingsboard.server.controller.ControllerConstants.HOME_DASHBOARD; import static org.thingsboard.server.controller.ControllerConstants.NAME_CONFLICT_POLICY_DESC; -import static org.thingsboard.server.controller.ControllerConstants.NAME_CONFLICT_SEPARATOR_DESC; +import static org.thingsboard.server.controller.ControllerConstants.UNIQUIFY_SEPARATOR_DESC; import static org.thingsboard.server.controller.ControllerConstants.PAGE_DATA_PARAMETERS; import static org.thingsboard.server.controller.ControllerConstants.PAGE_NUMBER_DESCRIPTION; import static org.thingsboard.server.controller.ControllerConstants.PAGE_SIZE_DESCRIPTION; @@ -58,6 +59,7 @@ import static org.thingsboard.server.controller.ControllerConstants.SORT_ORDER_D import static org.thingsboard.server.controller.ControllerConstants.SORT_PROPERTY_DESCRIPTION; import static org.thingsboard.server.controller.ControllerConstants.TENANT_AUTHORITY_PARAGRAPH; import static org.thingsboard.server.controller.ControllerConstants.TENANT_OR_CUSTOMER_AUTHORITY_PARAGRAPH; +import static org.thingsboard.server.controller.ControllerConstants.UNIQUIFY_STRATEGY_DESC; import static org.thingsboard.server.controller.ControllerConstants.UUID_WIKI_LINK; @RestController @@ -134,12 +136,14 @@ public class CustomerController extends BaseController { @ResponseBody public Customer saveCustomer(@io.swagger.v3.oas.annotations.parameters.RequestBody(description = "A JSON value representing the customer.") @RequestBody Customer customer, @Parameter(description = NAME_CONFLICT_POLICY_DESC) - @RequestParam(name = "policy", defaultValue = "FAIL") NameConflictPolicy policy, - @Parameter(description = NAME_CONFLICT_SEPARATOR_DESC) - @RequestParam(name = "separator", defaultValue = "_") String separator) throws Exception { + @RequestParam(name = "nameConflictPolicy", defaultValue = "FAIL") NameConflictPolicy nameConflictPolicy, + @Parameter(description = UNIQUIFY_SEPARATOR_DESC) + @RequestParam(name = "uniquifySeparator", defaultValue = "_") String uniquifySeparator, + @Parameter(description = UNIQUIFY_STRATEGY_DESC) + @RequestParam(name = "uniquifyStrategy", defaultValue = "RANDOM") UniquifyStrategy uniquifyStrategy) throws Exception { customer.setTenantId(getTenantId()); checkEntity(customer.getId(), customer, Resource.CUSTOMER); - return tbCustomerService.save(customer, new NameConflictStrategy(policy, separator), getCurrentUser()); + return tbCustomerService.save(customer, new NameConflictStrategy(nameConflictPolicy, uniquifySeparator, uniquifyStrategy), getCurrentUser()); } @ApiOperation(value = "Delete Customer (deleteCustomer)", diff --git a/application/src/main/java/org/thingsboard/server/controller/DeviceController.java b/application/src/main/java/org/thingsboard/server/controller/DeviceController.java index a5efdc1ca4..8bbf4ae2f6 100644 --- a/application/src/main/java/org/thingsboard/server/controller/DeviceController.java +++ b/application/src/main/java/org/thingsboard/server/controller/DeviceController.java @@ -50,6 +50,7 @@ import org.thingsboard.server.common.data.NameConflictPolicy; import org.thingsboard.server.common.data.NameConflictStrategy; import org.thingsboard.server.common.data.SaveDeviceWithCredentialsRequest; import org.thingsboard.server.common.data.Tenant; +import org.thingsboard.server.common.data.UniquifyStrategy; import org.thingsboard.server.common.data.device.DeviceSearchQuery; import org.thingsboard.server.common.data.edge.Edge; import org.thingsboard.server.common.data.exception.ThingsboardErrorCode; @@ -111,7 +112,7 @@ import static org.thingsboard.server.controller.ControllerConstants.EDGE_ID_PARA import static org.thingsboard.server.controller.ControllerConstants.EDGE_UNASSIGN_ASYNC_FIRST_STEP_DESCRIPTION; import static org.thingsboard.server.controller.ControllerConstants.EDGE_UNASSIGN_RECEIVE_STEP_DESCRIPTION; import static org.thingsboard.server.controller.ControllerConstants.NAME_CONFLICT_POLICY_DESC; -import static org.thingsboard.server.controller.ControllerConstants.NAME_CONFLICT_SEPARATOR_DESC; +import static org.thingsboard.server.controller.ControllerConstants.UNIQUIFY_SEPARATOR_DESC; import static org.thingsboard.server.controller.ControllerConstants.PAGE_DATA_PARAMETERS; import static org.thingsboard.server.controller.ControllerConstants.PAGE_NUMBER_DESCRIPTION; import static org.thingsboard.server.controller.ControllerConstants.PAGE_SIZE_DESCRIPTION; @@ -121,6 +122,7 @@ import static org.thingsboard.server.controller.ControllerConstants.TENANT_AUTHO import static org.thingsboard.server.controller.ControllerConstants.TENANT_ID; import static org.thingsboard.server.controller.ControllerConstants.TENANT_ID_PARAM_DESCRIPTION; import static org.thingsboard.server.controller.ControllerConstants.TENANT_OR_CUSTOMER_AUTHORITY_PARAGRAPH; +import static org.thingsboard.server.controller.ControllerConstants.UNIQUIFY_STRATEGY_DESC; import static org.thingsboard.server.controller.ControllerConstants.UUID_WIKI_LINK; import static org.thingsboard.server.controller.EdgeController.EDGE_ID; @@ -184,16 +186,18 @@ public class DeviceController extends BaseController { "If omitted, access token will be auto-generated.") @RequestParam(name = "accessToken", required = false) String accessToken, @Parameter(description = NAME_CONFLICT_POLICY_DESC) - @RequestParam(name = "policy", defaultValue = "FAIL") NameConflictPolicy policy, - @Parameter(description = NAME_CONFLICT_SEPARATOR_DESC) - @RequestParam(name = "separator", defaultValue = "_") String separator) throws Exception { + @RequestParam(name = "nameConflictPolicy", defaultValue = "FAIL") NameConflictPolicy nameConflictPolicy, + @Parameter(description = UNIQUIFY_SEPARATOR_DESC) + @RequestParam(name = "uniquifySeparator", defaultValue = "_") String uniquifySeparator, + @Parameter(description = UNIQUIFY_STRATEGY_DESC) + @RequestParam(name = "uniquifyStrategy", defaultValue = "RANDOM") UniquifyStrategy uniquifyStrategy) throws Exception { device.setTenantId(getCurrentUser().getTenantId()); if (device.getId() != null) { checkDeviceId(device.getId(), Operation.WRITE); } else { checkEntity(null, device, Resource.DEVICE); } - return tbDeviceService.save(device, accessToken, new NameConflictStrategy(policy, separator), getCurrentUser()); + return tbDeviceService.save(device, accessToken, new NameConflictStrategy(nameConflictPolicy, uniquifySeparator, uniquifyStrategy), getCurrentUser()); } @ApiOperation(value = "Create Device (saveDevice) with credentials ", @@ -220,14 +224,16 @@ public class DeviceController extends BaseController { public Device saveDeviceWithCredentials(@Parameter(description = "The JSON object with device and credentials. See method description above for example.") @Valid @RequestBody SaveDeviceWithCredentialsRequest deviceAndCredentials, @Parameter(description = NAME_CONFLICT_POLICY_DESC) - @RequestParam(name = "policy", defaultValue = "FAIL") NameConflictPolicy policy, - @Parameter(description = NAME_CONFLICT_SEPARATOR_DESC) - @RequestParam(name = "separator", defaultValue = "_") String separator) throws ThingsboardException { + @RequestParam(name = "nameConflictPolicy", defaultValue = "FAIL") NameConflictPolicy nameConflictPolicy, + @Parameter(description = UNIQUIFY_SEPARATOR_DESC) + @RequestParam(name = "uniquifySeparator", defaultValue = "_") String uniquifySeparator, + @Parameter(description = UNIQUIFY_STRATEGY_DESC) + @RequestParam(name = "uniquifyStrategy", defaultValue = "RANDOM") UniquifyStrategy uniquifyStrategy) throws ThingsboardException { Device device = deviceAndCredentials.getDevice(); DeviceCredentials credentials = deviceAndCredentials.getCredentials(); device.setTenantId(getCurrentUser().getTenantId()); checkEntity(device.getId(), device, Resource.DEVICE); - return tbDeviceService.saveDeviceWithCredentials(device, credentials, new NameConflictStrategy(policy, separator), getCurrentUser()); + return tbDeviceService.saveDeviceWithCredentials(device, credentials, new NameConflictStrategy(nameConflictPolicy, uniquifySeparator, uniquifyStrategy), getCurrentUser()); } @ApiOperation(value = "Delete device (deleteDevice)", 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 4cf9a51227..67fc02ab29 100644 --- a/application/src/main/java/org/thingsboard/server/controller/EntityViewController.java +++ b/application/src/main/java/org/thingsboard/server/controller/EntityViewController.java @@ -36,6 +36,7 @@ import org.thingsboard.server.common.data.EntityView; import org.thingsboard.server.common.data.EntityViewInfo; import org.thingsboard.server.common.data.NameConflictPolicy; import org.thingsboard.server.common.data.NameConflictStrategy; +import org.thingsboard.server.common.data.UniquifyStrategy; import org.thingsboard.server.common.data.edge.Edge; import org.thingsboard.server.common.data.entityview.EntityViewSearchQuery; import org.thingsboard.server.common.data.exception.ThingsboardException; @@ -72,7 +73,7 @@ import static org.thingsboard.server.controller.ControllerConstants.ENTITY_VIEW_ import static org.thingsboard.server.controller.ControllerConstants.ENTITY_VIEW_TYPE; import static org.thingsboard.server.controller.ControllerConstants.MODEL_DESCRIPTION; import static org.thingsboard.server.controller.ControllerConstants.NAME_CONFLICT_POLICY_DESC; -import static org.thingsboard.server.controller.ControllerConstants.NAME_CONFLICT_SEPARATOR_DESC; +import static org.thingsboard.server.controller.ControllerConstants.UNIQUIFY_SEPARATOR_DESC; import static org.thingsboard.server.controller.ControllerConstants.PAGE_DATA_PARAMETERS; import static org.thingsboard.server.controller.ControllerConstants.PAGE_NUMBER_DESCRIPTION; import static org.thingsboard.server.controller.ControllerConstants.PAGE_SIZE_DESCRIPTION; @@ -80,6 +81,7 @@ import static org.thingsboard.server.controller.ControllerConstants.SORT_ORDER_D import static org.thingsboard.server.controller.ControllerConstants.SORT_PROPERTY_DESCRIPTION; import static org.thingsboard.server.controller.ControllerConstants.TENANT_AUTHORITY_PARAGRAPH; import static org.thingsboard.server.controller.ControllerConstants.TENANT_OR_CUSTOMER_AUTHORITY_PARAGRAPH; +import static org.thingsboard.server.controller.ControllerConstants.UNIQUIFY_STRATEGY_DESC; import static org.thingsboard.server.controller.EdgeController.EDGE_ID; /** @@ -134,9 +136,11 @@ public class EntityViewController extends BaseController { @Parameter(description = "A JSON object representing the entity view.") @RequestBody EntityView entityView, @Parameter(description = NAME_CONFLICT_POLICY_DESC) - @RequestParam(name = "policy", defaultValue = "FAIL") NameConflictPolicy policy, - @Parameter(description = NAME_CONFLICT_SEPARATOR_DESC) - @RequestParam(name = "separator", defaultValue = "_") String separator) throws Exception { + @RequestParam(name = "nameConflictPolicy", defaultValue = "FAIL") NameConflictPolicy nameConflictPolicy, + @Parameter(description = UNIQUIFY_SEPARATOR_DESC) + @RequestParam(name = "uniquifySeparator", defaultValue = "_") String uniquifySeparator, + @Parameter(description = UNIQUIFY_STRATEGY_DESC) + @RequestParam(name = "uniquifyStrategy", defaultValue = "RANDOM") UniquifyStrategy uniquifyStrategy) throws Exception { entityView.setTenantId(getCurrentUser().getTenantId()); EntityView existingEntityView = null; if (entityView.getId() == null) { @@ -145,7 +149,7 @@ public class EntityViewController extends BaseController { } else { existingEntityView = checkEntityViewId(entityView.getId(), Operation.WRITE); } - return tbEntityViewService.save(entityView, existingEntityView, new NameConflictStrategy(policy, separator), getCurrentUser()); + return tbEntityViewService.save(entityView, existingEntityView, new NameConflictStrategy(nameConflictPolicy, uniquifySeparator, uniquifyStrategy), getCurrentUser()); } @ApiOperation(value = "Delete entity view (deleteEntityView)", diff --git a/application/src/main/java/org/thingsboard/server/controller/Lwm2mController.java b/application/src/main/java/org/thingsboard/server/controller/Lwm2mController.java index cdab0805a1..7e7cd84a12 100644 --- a/application/src/main/java/org/thingsboard/server/controller/Lwm2mController.java +++ b/application/src/main/java/org/thingsboard/server/controller/Lwm2mController.java @@ -76,6 +76,6 @@ public class Lwm2mController extends BaseController { public Device saveDeviceWithCredentials(@RequestBody Map, Object> deviceWithDeviceCredentials) throws ThingsboardException { Device device = checkNotNull(JacksonUtil.convertValue(deviceWithDeviceCredentials.get(Device.class), Device.class)); DeviceCredentials credentials = checkNotNull(JacksonUtil.convertValue(deviceWithDeviceCredentials.get(DeviceCredentials.class), DeviceCredentials.class)); - return deviceController.saveDeviceWithCredentials(new SaveDeviceWithCredentialsRequest(device, credentials), DEFAULT.policy(), DEFAULT.separator()); + return deviceController.saveDeviceWithCredentials(new SaveDeviceWithCredentialsRequest(device, credentials), DEFAULT.policy(), DEFAULT.separator(), DEFAULT.uniquifyStrategy()); } } diff --git a/application/src/test/java/org/thingsboard/server/controller/AssetControllerTest.java b/application/src/test/java/org/thingsboard/server/controller/AssetControllerTest.java index bb0d90e45a..e160fff59e 100644 --- a/application/src/test/java/org/thingsboard/server/controller/AssetControllerTest.java +++ b/application/src/test/java/org/thingsboard/server/controller/AssetControllerTest.java @@ -1083,19 +1083,25 @@ public class AssetControllerTest extends AbstractControllerTest { @Test public void testSaveAssetWithUniquifyStrategy() throws Exception { Asset asset = new Asset(); - asset.setName("My asset"); + asset.setName("My unique asset"); asset.setType("default"); doPost("/api/asset", asset, Asset.class); doPost("/api/asset", asset).andExpect(status().isBadRequest()); - doPost("/api/asset?policy=FAIL", asset).andExpect(status().isBadRequest()); + doPost("/api/asset?nameConflictPolicy=FAIL", asset).andExpect(status().isBadRequest()); + + Asset secondAsset = doPost("/api/asset?nameConflictPolicy=UNIQUIFY", asset, Asset.class); + assertThat(secondAsset.getName()).startsWith("My unique asset_"); + + Asset thirdAsset = doPost("/api/asset?nameConflictPolicy=UNIQUIFY&uniquifySeparator=-", asset, Asset.class); + assertThat(thirdAsset.getName()).startsWith("My unique asset-"); - Asset secondAsset = doPost("/api/asset?policy=UNIQUIFY", asset, Asset.class); - assertThat(secondAsset.getName()).startsWith("My asset_"); + Asset fourthAsset = doPost("/api/asset?nameConflictPolicy=UNIQUIFY&uniquifyStrategy=INCREMENTAL", asset, Asset.class); + assertThat(fourthAsset.getName()).isEqualTo("My unique asset_1"); - Asset thirdAsset = doPost("/api/asset?policy=UNIQUIFY&separator=-", asset, Asset.class); - assertThat(thirdAsset.getName()).startsWith("My asset-"); + Asset fifthAsset = doPost("/api/asset?nameConflictPolicy=UNIQUIFY&uniquifyStrategy=INCREMENTAL", asset, Asset.class); + assertThat(fifthAsset.getName()).isEqualTo("My unique asset_2"); } private Asset createAsset(String name) { diff --git a/application/src/test/java/org/thingsboard/server/controller/CustomerControllerTest.java b/application/src/test/java/org/thingsboard/server/controller/CustomerControllerTest.java index 08eecf3f10..f4e91f993e 100644 --- a/application/src/test/java/org/thingsboard/server/controller/CustomerControllerTest.java +++ b/application/src/test/java/org/thingsboard/server/controller/CustomerControllerTest.java @@ -33,6 +33,7 @@ import org.springframework.context.annotation.Primary; import org.springframework.test.context.ContextConfiguration; import org.thingsboard.common.util.ThingsBoardExecutors; import org.thingsboard.server.common.data.Customer; +import org.thingsboard.server.common.data.Device; import org.thingsboard.server.common.data.StringUtils; import org.thingsboard.server.common.data.Tenant; import org.thingsboard.server.common.data.User; @@ -465,16 +466,22 @@ public class CustomerControllerTest extends AbstractControllerTest { @Test public void testSaveCustomerWithUniquifyStrategy() throws Exception { Customer customer = new Customer(); - customer.setTitle("My customer"); + customer.setTitle("My unique customer"); Customer savedCustomer = doPost("/api/customer", customer, Customer.class); - doPost("/api/customer?policy=FAIL", customer).andExpect(status().isBadRequest()); + doPost("/api/customer?nameConflictPolicy=FAIL", customer).andExpect(status().isBadRequest()); + + Customer secondCustomer = doPost("/api/customer?nameConflictPolicy=UNIQUIFY", customer, Customer.class); + assertThat(secondCustomer.getName()).startsWith("My unique customer_"); + + Customer thirdCustomer = doPost("/api/customer?nameConflictPolicy=UNIQUIFY&uniquifySeparator=-", customer, Customer.class); + assertThat(thirdCustomer.getName()).startsWith("My unique customer-"); - Customer secondCustomer = doPost("/api/customer?policy=UNIQUIFY", customer, Customer.class); - assertThat(secondCustomer.getName()).startsWith("My customer_"); + Customer fourthCustomer = doPost("/api/customer?nameConflictPolicy=UNIQUIFY&uniquifyStrategy=INCREMENTAL", customer, Customer.class); + assertThat(fourthCustomer.getName()).isEqualTo("My unique customer_1"); - Customer thirdCustomer = doPost("/api/customer?policy=UNIQUIFY&separator=-", customer, Customer.class); - assertThat(thirdCustomer.getName()).startsWith("My customer-"); + Customer fifthCustomer = doPost("/api/customer?nameConflictPolicy=UNIQUIFY&uniquifyStrategy=INCREMENTAL", customer, Customer.class); + assertThat(fifthCustomer.getName()).isEqualTo("My unique customer_2"); } private Customer createCustomer(String title) { diff --git a/application/src/test/java/org/thingsboard/server/controller/DeviceControllerTest.java b/application/src/test/java/org/thingsboard/server/controller/DeviceControllerTest.java index 118e0f1137..ca5f146a88 100644 --- a/application/src/test/java/org/thingsboard/server/controller/DeviceControllerTest.java +++ b/application/src/test/java/org/thingsboard/server/controller/DeviceControllerTest.java @@ -1611,19 +1611,25 @@ public class DeviceControllerTest extends AbstractControllerTest { @Test public void testSaveDeviceWithUniquifyStrategy() throws Exception { Device device = new Device(); - device.setName("My device"); + device.setName("My unique device"); device.setType("default"); Device savedDevice = doPost("/api/device", device, Device.class); doPost("/api/device", device).andExpect(status().isBadRequest()); - doPost("/api/device?policy=FAIL", device).andExpect(status().isBadRequest()); + doPost("/api/device?nameConflictPolicy=FAIL", device).andExpect(status().isBadRequest()); + + Device secondDevice = doPost("/api/device?nameConflictPolicy=UNIQUIFY", device, Device.class); + assertThat(secondDevice.getName()).startsWith("My unique device_"); + + Device thirdDevice = doPost("/api/device?nameConflictPolicy=UNIQUIFY&uniquifySeparator=-", device, Device.class); + assertThat(thirdDevice.getName()).startsWith("My unique device-"); - Device secondDevice = doPost("/api/device?policy=UNIQUIFY", device, Device.class); - assertThat(secondDevice.getName()).startsWith("My device_"); + Device fourthDevice = doPost("/api/device?nameConflictPolicy=UNIQUIFY&uniquifyStrategy=INCREMENTAL", device, Device.class); + assertThat(fourthDevice.getName()).isEqualTo("My unique device_1"); - Device thirdDevice = doPost("/api/device?policy=UNIQUIFY&separator=-", device, Device.class); - assertThat(thirdDevice.getName()).startsWith("My device-"); + Device fifthDevice = doPost("/api/device?nameConflictPolicy=UNIQUIFY&uniquifyStrategy=INCREMENTAL", device, Device.class); + assertThat(fifthDevice.getName()).isEqualTo("My unique device_2"); } private Device createDevice(String name) { diff --git a/application/src/test/java/org/thingsboard/server/controller/EntityViewControllerTest.java b/application/src/test/java/org/thingsboard/server/controller/EntityViewControllerTest.java index 0549e17e43..167048a969 100644 --- a/application/src/test/java/org/thingsboard/server/controller/EntityViewControllerTest.java +++ b/application/src/test/java/org/thingsboard/server/controller/EntityViewControllerTest.java @@ -860,16 +860,22 @@ public class EntityViewControllerTest extends AbstractControllerTest { view.setEntityId(testDevice.getId()); view.setTenantId(tenantId); view.setType("default"); - view.setName("Test device view"); + view.setName("My unique view"); EntityView savedView = doPost("/api/entityView", view, EntityView.class); - doPost("/api/entityView?policy=FAIL", view).andExpect(status().isBadRequest()); + doPost("/api/entityView?nameConflictPolicy=FAIL", view).andExpect(status().isBadRequest()); - EntityView secondView = doPost("/api/entityView?policy=UNIQUIFY", view, EntityView.class); - assertThat(secondView.getName()).startsWith("Test device view_"); + EntityView secondView = doPost("/api/entityView?nameConflictPolicy=UNIQUIFY", view, EntityView.class); + assertThat(secondView.getName()).startsWith("My unique view_"); - EntityView thirdView = doPost("/api/entityView?policy=UNIQUIFY&separator=-", view, EntityView.class); - assertThat(thirdView.getName()).startsWith("Test device view-"); + EntityView thirdView = doPost("/api/entityView?nameConflictPolicy=UNIQUIFY&uniquifySeparator=-", view, EntityView.class); + assertThat(thirdView.getName()).startsWith("My unique view-"); + + EntityView fourthView = doPost("/api/entityView?nameConflictPolicy=UNIQUIFY&uniquifyStrategy=INCREMENTAL", view, EntityView.class); + assertThat(fourthView.getName()).isEqualTo("My unique view_1"); + + EntityView fifthEntityView = doPost("/api/entityView?nameConflictPolicy=UNIQUIFY&uniquifyStrategy=INCREMENTAL", view, EntityView.class); + assertThat(fifthEntityView.getName()).isEqualTo("My unique view_2"); } } diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/NameConflictStrategy.java b/common/data/src/main/java/org/thingsboard/server/common/data/NameConflictStrategy.java index 00b72f7223..9624b8c978 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/NameConflictStrategy.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/NameConflictStrategy.java @@ -18,8 +18,8 @@ package org.thingsboard.server.common.data; import io.swagger.v3.oas.annotations.media.Schema; @Schema -public record NameConflictStrategy(NameConflictPolicy policy, String separator) { +public record NameConflictStrategy(NameConflictPolicy policy, String separator, UniquifyStrategy uniquifyStrategy) { - public static final NameConflictStrategy DEFAULT = new NameConflictStrategy(NameConflictPolicy.FAIL, null); + public static final NameConflictStrategy DEFAULT = new NameConflictStrategy(NameConflictPolicy.FAIL, null, null); } diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/UniquifyStrategy.java b/common/data/src/main/java/org/thingsboard/server/common/data/UniquifyStrategy.java new file mode 100644 index 0000000000..5c9841f096 --- /dev/null +++ b/common/data/src/main/java/org/thingsboard/server/common/data/UniquifyStrategy.java @@ -0,0 +1,23 @@ +/** + * 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.common.data; + +public enum UniquifyStrategy { + + RANDOM, + INCREMENTAL; + +} diff --git a/dao/src/main/java/org/thingsboard/server/dao/Dao.java b/dao/src/main/java/org/thingsboard/server/dao/Dao.java index 6c1ab74764..4934059cd5 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/Dao.java +++ b/dao/src/main/java/org/thingsboard/server/dao/Dao.java @@ -33,7 +33,7 @@ public interface Dao { ListenableFuture findByIdAsync(TenantId tenantId, UUID id); - default EntityInfo findEntityInfoByName(TenantId tenantId, String name) { + default List findEntityInfosByNamePrefix(TenantId tenantId, String name) { throw new UnsupportedOperationException(); } diff --git a/dao/src/main/java/org/thingsboard/server/dao/entity/AbstractEntityService.java b/dao/src/main/java/org/thingsboard/server/dao/entity/AbstractEntityService.java index 5f83646fd4..bf3f0f346d 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/entity/AbstractEntityService.java +++ b/dao/src/main/java/org/thingsboard/server/dao/entity/AbstractEntityService.java @@ -51,8 +51,12 @@ import java.util.Collections; import java.util.List; import java.util.Map; import java.util.Optional; +import java.util.Set; import java.util.concurrent.TimeUnit; import java.util.function.Consumer; +import java.util.stream.Collectors; + +import static org.thingsboard.server.common.data.UniquifyStrategy.RANDOM; @Slf4j public abstract class AbstractEntityService { @@ -167,18 +171,23 @@ public abstract class AbstractEntityService { return now + TimeUnit.MINUTES.toMillis(DebugModeUtil.getMaxDebugAllDuration(tbTenantProfileCache.get(tenantId).getDefaultProfileConfiguration().getMaxDebugModeDurationMinutes(), defaultDebugDurationMinutes)); } - protected & HasTenantId & HasName> void uniquifyEntityName(E entity, E oldEntity, Consumer setName, EntityType entityType, NameConflictStrategy nameConflictStrategy) { + protected & HasTenantId & HasName> void uniquifyEntityName(E entity, E oldEntity, Consumer setName, EntityType entityType, NameConflictStrategy strategy) { Dao dao = entityDaoRegistry.getDao(entityType); - EntityInfo existingEntity = dao.findEntityInfoByName(entity.getTenantId(), entity.getName()); - if (existingEntity != null && (oldEntity == null || !existingEntity.getId().equals(oldEntity.getId()))) { - String suffix = StringUtils.randomAlphanumeric(6); + List existingEntities = dao.findEntityInfosByNamePrefix(entity.getTenantId(), entity.getName()); + Set existingNames = existingEntities.stream() + .filter(e -> (oldEntity == null || !e.getId().equals(oldEntity.getId()))) + .map(EntityInfo::getName) + .collect(Collectors.toSet()); + if (!existingNames.isEmpty()) { + int idx = 1; + String suffix = (strategy.uniquifyStrategy() == RANDOM) ? StringUtils.randomAlphanumeric(6) : String.valueOf(idx); while (true) { - String newName = entity.getName() + nameConflictStrategy.separator() + suffix; - if (dao.findEntityInfoByName(entity.getTenantId(), newName) == null) { + String newName = entity.getName() + strategy.separator() + suffix; + if (!existingNames.contains(newName)) { setName.accept(newName); break; } - suffix = StringUtils.randomAlphanumeric(6); + suffix = (strategy.uniquifyStrategy() == RANDOM) ? StringUtils.randomAlphanumeric(6) : String.valueOf(idx++); } } } diff --git a/dao/src/main/java/org/thingsboard/server/dao/entityview/EntityViewServiceImpl.java b/dao/src/main/java/org/thingsboard/server/dao/entityview/EntityViewServiceImpl.java index 6588e06e85..4c7269f17b 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/entityview/EntityViewServiceImpl.java +++ b/dao/src/main/java/org/thingsboard/server/dao/entityview/EntityViewServiceImpl.java @@ -124,16 +124,14 @@ public class EntityViewServiceImpl extends CachedVersionedEntityService { private final TenantService tenantService; private final CustomerDao customerDao; + @Override + protected void validateCreate(TenantId tenantId, EntityView entityView) { + entityViewDao.findEntityViewByTenantIdAndName(entityView.getTenantId().getId(), entityView.getName()) + .ifPresent(e -> { + throw new DataValidationException("Entity view with such name already exists!"); + }); + } + @Override protected EntityView validateUpdate(TenantId tenantId, EntityView entityView) { var opt = entityViewDao.findEntityViewByTenantIdAndName(entityView.getTenantId().getId(), entityView.getName()); diff --git a/dao/src/main/java/org/thingsboard/server/dao/sql/asset/AssetRepository.java b/dao/src/main/java/org/thingsboard/server/dao/sql/asset/AssetRepository.java index 0ac50432bb..aa9c29df49 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/sql/asset/AssetRepository.java +++ b/dao/src/main/java/org/thingsboard/server/dao/sql/asset/AssetRepository.java @@ -105,8 +105,8 @@ public interface AssetRepository extends JpaRepository, Expor AssetEntity findByTenantIdAndName(UUID tenantId, String name); @Query("SELECT new org.thingsboard.server.common.data.EntityInfo(a.id, 'ASSET', a.name) " + - "FROM AssetEntity a WHERE a.tenantId = :tenantId AND a.name = :name") - EntityInfo findEntityInfoByName(UUID tenantId, String name); + "FROM AssetEntity a WHERE a.tenantId = :tenantId AND a.name LIKE CONCAT(:prefix, '%')") + List findEntityInfosByNamePrefix(UUID tenantId, String prefix); @Query("SELECT a FROM AssetEntity a WHERE a.tenantId = :tenantId " + "AND a.type = :type " + diff --git a/dao/src/main/java/org/thingsboard/server/dao/sql/asset/JpaAssetDao.java b/dao/src/main/java/org/thingsboard/server/dao/sql/asset/JpaAssetDao.java index 123cd17a69..593a672d8a 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/sql/asset/JpaAssetDao.java +++ b/dao/src/main/java/org/thingsboard/server/dao/sql/asset/JpaAssetDao.java @@ -269,9 +269,9 @@ public class JpaAssetDao extends JpaAbstractDao implements A } @Override - public EntityInfo findEntityInfoByName(TenantId tenantId, String name) { - log.debug("Find asset entity info by name [{}]", name); - return assetRepository.findEntityInfoByName(tenantId.getId(), name); + public List findEntityInfosByNamePrefix(TenantId tenantId, String name) { + log.debug("Find asset entity infos by name [{}]", name); + return assetRepository.findEntityInfosByNamePrefix(tenantId.getId(), name); } @Override diff --git a/dao/src/main/java/org/thingsboard/server/dao/sql/customer/CustomerRepository.java b/dao/src/main/java/org/thingsboard/server/dao/sql/customer/CustomerRepository.java index ea6276f37d..9c196a5072 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/sql/customer/CustomerRepository.java +++ b/dao/src/main/java/org/thingsboard/server/dao/sql/customer/CustomerRepository.java @@ -43,8 +43,8 @@ public interface CustomerRepository extends JpaRepository, CustomerEntity findByTenantIdAndTitle(UUID tenantId, String title); @Query("SELECT new org.thingsboard.server.common.data.EntityInfo(a.id, 'CUSTOMER', a.title) " + - "FROM CustomerEntity a WHERE a.tenantId = :tenantId AND a.title = :name") - EntityInfo findEntityInfoByName(UUID tenantId, String name); + "FROM CustomerEntity a WHERE a.tenantId = :tenantId AND a.title LIKE CONCAT(:prefix, '%')") + List findEntityInfosByNamePrefix(UUID tenantId, String prefix); @Query(value = "SELECT * FROM customer c WHERE c.tenant_id = :tenantId " + "AND c.is_public IS TRUE ORDER BY c.id ASC LIMIT 1", nativeQuery = true) diff --git a/dao/src/main/java/org/thingsboard/server/dao/sql/customer/JpaCustomerDao.java b/dao/src/main/java/org/thingsboard/server/dao/sql/customer/JpaCustomerDao.java index b6827383b2..2e1d75a738 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/sql/customer/JpaCustomerDao.java +++ b/dao/src/main/java/org/thingsboard/server/dao/sql/customer/JpaCustomerDao.java @@ -119,8 +119,8 @@ public class JpaCustomerDao extends JpaAbstractDao imp } @Override - public EntityInfo findEntityInfoByName(TenantId tenantId, String name) { - return customerRepository.findEntityInfoByName(tenantId.getId(), name); + public List findEntityInfosByNamePrefix(TenantId tenantId, String name) { + return customerRepository.findEntityInfosByNamePrefix(tenantId.getId(), name); } @Override diff --git a/dao/src/main/java/org/thingsboard/server/dao/sql/device/DeviceRepository.java b/dao/src/main/java/org/thingsboard/server/dao/sql/device/DeviceRepository.java index 14e66826ba..a9ec9d1d36 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/sql/device/DeviceRepository.java +++ b/dao/src/main/java/org/thingsboard/server/dao/sql/device/DeviceRepository.java @@ -153,8 +153,8 @@ public interface DeviceRepository extends JpaRepository, Exp DeviceEntity findByTenantIdAndName(UUID tenantId, String name); @Query("SELECT new org.thingsboard.server.common.data.EntityInfo(a.id, 'DEVICE', a.name) " + - "FROM DeviceEntity a WHERE a.tenantId = :tenantId AND a.name = :name") - EntityInfo findEntityInfoByName(UUID tenantId, String name); + "FROM DeviceEntity a WHERE a.tenantId = :tenantId AND a.name LIKE CONCAT(:prefix, '%')") + List findEntityInfosByNamePrefix(UUID tenantId, String prefix); List findDevicesByTenantIdAndCustomerIdAndIdIn(UUID tenantId, UUID customerId, List deviceIds); diff --git a/dao/src/main/java/org/thingsboard/server/dao/sql/device/JpaDeviceDao.java b/dao/src/main/java/org/thingsboard/server/dao/sql/device/JpaDeviceDao.java index 5d70ee53e5..beb3b7c913 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/sql/device/JpaDeviceDao.java +++ b/dao/src/main/java/org/thingsboard/server/dao/sql/device/JpaDeviceDao.java @@ -116,8 +116,8 @@ public class JpaDeviceDao extends JpaAbstractDao implement } @Override - public EntityInfo findEntityInfoByName(TenantId tenantId, String name) { - return deviceRepository.findEntityInfoByName(tenantId.getId(), name); + public List findEntityInfosByNamePrefix(TenantId tenantId, String name) { + return deviceRepository.findEntityInfosByNamePrefix(tenantId.getId(), name); } @Override diff --git a/dao/src/main/java/org/thingsboard/server/dao/sql/entityview/EntityViewRepository.java b/dao/src/main/java/org/thingsboard/server/dao/sql/entityview/EntityViewRepository.java index 0e66850be8..9d51d024b8 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/sql/entityview/EntityViewRepository.java +++ b/dao/src/main/java/org/thingsboard/server/dao/sql/entityview/EntityViewRepository.java @@ -120,8 +120,8 @@ public interface EntityViewRepository extends JpaRepository findEntityInfosByNamePrefix(UUID tenantId, String prefix); List findAllByTenantIdAndEntityId(UUID tenantId, UUID entityId); diff --git a/dao/src/main/java/org/thingsboard/server/dao/sql/entityview/JpaEntityViewDao.java b/dao/src/main/java/org/thingsboard/server/dao/sql/entityview/JpaEntityViewDao.java index bd4f39162f..27400961ef 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/sql/entityview/JpaEntityViewDao.java +++ b/dao/src/main/java/org/thingsboard/server/dao/sql/entityview/JpaEntityViewDao.java @@ -232,8 +232,8 @@ public class JpaEntityViewDao extends JpaAbstractDao findEntityInfosByNamePrefix(TenantId tenantId, String name) { + return entityViewRepository.findEntityInfosByNamePrefix(tenantId.getId(), name); } @Override diff --git a/dao/src/main/resources/sql/schema-entities.sql b/dao/src/main/resources/sql/schema-entities.sql index b2033c079e..6ccf2f6d95 100644 --- a/dao/src/main/resources/sql/schema-entities.sql +++ b/dao/src/main/resources/sql/schema-entities.sql @@ -551,7 +551,6 @@ CREATE TABLE IF NOT EXISTS entity_view ( additional_info varchar, external_id uuid, version BIGINT DEFAULT 1, - CONSTRAINT entity_view_name_unq_key UNIQUE (tenant_id, name), CONSTRAINT entity_view_external_id_unq_key UNIQUE (tenant_id, external_id) ); From e8d888e22b2a5746bf2992ed288f8b8d55ec5697 Mon Sep 17 00:00:00 2001 From: Vladyslav_Prykhodko Date: Fri, 10 Oct 2025 18:35:41 +0300 Subject: [PATCH 094/102] UI: Add support name conflict strategy --- ui-ngx/src/app/core/http/asset.service.ts | 9 +++-- ui-ngx/src/app/core/http/customer.service.ts | 9 +++-- ui-ngx/src/app/core/http/device.service.ts | 18 ++++++---- .../src/app/core/http/entity-view.service.ts | 9 +++-- ui-ngx/src/app/core/http/http-utils.ts | 34 ++++++++++++++++--- .../interceptors/interceptor-http-params.ts | 2 +- ui-ngx/src/app/shared/models/device.models.ts | 6 +++- ui-ngx/src/app/shared/models/entity.models.ts | 16 +++++++++ 8 files changed, 81 insertions(+), 22 deletions(-) diff --git a/ui-ngx/src/app/core/http/asset.service.ts b/ui-ngx/src/app/core/http/asset.service.ts index a24be23593..0efeec3eef 100644 --- a/ui-ngx/src/app/core/http/asset.service.ts +++ b/ui-ngx/src/app/core/http/asset.service.ts @@ -15,7 +15,7 @@ /// import { Injectable } from '@angular/core'; -import { defaultHttpOptionsFromConfig, RequestConfig } from './http-utils'; +import { createDefaultHttpOptions, defaultHttpOptionsFromConfig, RequestConfig } from './http-utils'; import { Observable } from 'rxjs'; import { HttpClient } from '@angular/common/http'; import { PageLink } from '@shared/models/page/page-link'; @@ -23,6 +23,7 @@ import { PageData } from '@shared/models/page/page-data'; import { EntitySubtype } from '@shared/models/entity-type.models'; import { Asset, AssetInfo, AssetSearchQuery } from '@shared/models/asset.models'; import { BulkImportRequest, BulkImportResult } from '@shared/import-export/import-export.models'; +import { SaveEntityParams } from '@shared/models/entity.models'; @Injectable({ providedIn: 'root' @@ -69,8 +70,10 @@ export class AssetService { return this.http.get(`/api/asset/info/${assetId}`, defaultHttpOptionsFromConfig(config)); } - public saveAsset(asset: Asset, config?: RequestConfig): Observable { - return this.http.post('/api/asset', asset, defaultHttpOptionsFromConfig(config)); + public saveAsset(asset: Asset, config?: RequestConfig): Observable; + public saveAsset(asset: Asset, saveParams: SaveEntityParams, config?: RequestConfig): Observable; + public saveAsset(asset: Asset, saveParamsOrConfig?: SaveEntityParams | RequestConfig, config?: RequestConfig): Observable { + return this.http.post('/api/asset', asset, createDefaultHttpOptions(saveParamsOrConfig, config)); } public deleteAsset(assetId: string, config?: RequestConfig) { diff --git a/ui-ngx/src/app/core/http/customer.service.ts b/ui-ngx/src/app/core/http/customer.service.ts index faf78f6f75..ec8955ca4b 100644 --- a/ui-ngx/src/app/core/http/customer.service.ts +++ b/ui-ngx/src/app/core/http/customer.service.ts @@ -15,12 +15,13 @@ /// import { Injectable } from '@angular/core'; -import { defaultHttpOptionsFromConfig, RequestConfig } from './http-utils'; +import { createDefaultHttpOptions, defaultHttpOptionsFromConfig, RequestConfig } from './http-utils'; import { Observable } from 'rxjs'; import { HttpClient } from '@angular/common/http'; import { PageLink } from '@shared/models/page/page-link'; import { PageData } from '@shared/models/page/page-data'; import { Customer } from '@shared/models/customer.model'; +import { SaveEntityParams } from '@shared/models/entity.models'; @Injectable({ providedIn: 'root' @@ -40,8 +41,10 @@ export class CustomerService { return this.http.get(`/api/customer/${customerId}`, defaultHttpOptionsFromConfig(config)); } - public saveCustomer(customer: Customer, config?: RequestConfig): Observable { - return this.http.post('/api/customer', customer, defaultHttpOptionsFromConfig(config)); + public saveCustomer(customer: Customer, config?: RequestConfig): Observable; + public saveCustomer(customer: Customer, saveParams: SaveEntityParams, config?: RequestConfig): Observable; + public saveCustomer(customer: Customer, saveParamsOrConfig?: SaveEntityParams | RequestConfig, config?: RequestConfig): Observable { + return this.http.post('/api/customer', customer, createDefaultHttpOptions(saveParamsOrConfig, config)); } public deleteCustomer(customerId: string, config?: RequestConfig) { diff --git a/ui-ngx/src/app/core/http/device.service.ts b/ui-ngx/src/app/core/http/device.service.ts index 1e3a304774..69ed2f8a66 100644 --- a/ui-ngx/src/app/core/http/device.service.ts +++ b/ui-ngx/src/app/core/http/device.service.ts @@ -15,7 +15,7 @@ /// import { Injectable } from '@angular/core'; -import { defaultHttpOptionsFromConfig, RequestConfig } from './http-utils'; +import { createDefaultHttpOptions, defaultHttpOptionsFromConfig, RequestConfig } from './http-utils'; import { Observable, ReplaySubject } from 'rxjs'; import { HttpClient } from '@angular/common/http'; import { PageLink } from '@shared/models/page/page-link'; @@ -28,13 +28,15 @@ import { DeviceInfo, DeviceInfoQuery, DeviceSearchQuery, - PublishTelemetryCommand + PublishTelemetryCommand, + SaveDeviceParams } from '@shared/models/device.models'; import { EntitySubtype } from '@shared/models/entity-type.models'; import { AuthService } from '@core/auth/auth.service'; import { BulkImportRequest, BulkImportResult } from '@shared/import-export/import-export.models'; import { PersistentRpc, RpcStatus } from '@shared/models/rpc.models'; import { ResourcesService } from '@core/services/resources.service'; +import { SaveEntityParams } from '@shared/models/entity.models'; @Injectable({ providedIn: 'root' @@ -87,15 +89,19 @@ export class DeviceService { return this.http.get(`/api/device/info/${deviceId}`, defaultHttpOptionsFromConfig(config)); } - public saveDevice(device: Device, config?: RequestConfig): Observable { - return this.http.post('/api/device', device, defaultHttpOptionsFromConfig(config)); + public saveDevice(device: Device, config?: RequestConfig): Observable; + public saveDevice(device: Device, saveParams?: SaveDeviceParams, config?: RequestConfig): Observable; + public saveDevice(device: Device, saveParamsOrConfig?: SaveDeviceParams | RequestConfig, config?: RequestConfig): Observable { + return this.http.post('/api/device', device, createDefaultHttpOptions(saveParamsOrConfig, config)); } - public saveDeviceWithCredentials(device: Device, credentials: DeviceCredentials, config?: RequestConfig): Observable { + public saveDeviceWithCredentials(device: Device, credentials: DeviceCredentials, config?: RequestConfig): Observable; + public saveDeviceWithCredentials(device: Device, credentials: DeviceCredentials, saveParams: SaveEntityParams, config?: RequestConfig): Observable; + public saveDeviceWithCredentials(device: Device, credentials: DeviceCredentials, saveParamsOrConfig?: SaveEntityParams | RequestConfig, config?: RequestConfig): Observable { return this.http.post('/api/device-with-credentials', { device, credentials - }, defaultHttpOptionsFromConfig(config)); + }, createDefaultHttpOptions(saveParamsOrConfig, config)); } public deleteDevice(deviceId: string, config?: RequestConfig) { diff --git a/ui-ngx/src/app/core/http/entity-view.service.ts b/ui-ngx/src/app/core/http/entity-view.service.ts index ffd17d9a49..7285c5420f 100644 --- a/ui-ngx/src/app/core/http/entity-view.service.ts +++ b/ui-ngx/src/app/core/http/entity-view.service.ts @@ -15,13 +15,14 @@ /// import { Injectable } from '@angular/core'; -import { defaultHttpOptionsFromConfig, RequestConfig } from './http-utils'; +import { createDefaultHttpOptions, defaultHttpOptionsFromConfig, RequestConfig } from './http-utils'; import { Observable } from 'rxjs'; import { HttpClient } from '@angular/common/http'; import { PageLink } from '@shared/models/page/page-link'; import { PageData } from '@shared/models/page/page-data'; import { EntitySubtype } from '@app/shared/models/entity-type.models'; import { EntityView, EntityViewInfo, EntityViewSearchQuery } from '@app/shared/models/entity-view.models'; +import { SaveEntityParams } from '@shared/models/entity.models'; @Injectable({ providedIn: 'root' @@ -51,8 +52,10 @@ export class EntityViewService { return this.http.get(`/api/entityView/info/${entityViewId}`, defaultHttpOptionsFromConfig(config)); } - public saveEntityView(entityView: EntityView, config?: RequestConfig): Observable { - return this.http.post('/api/entityView', entityView, defaultHttpOptionsFromConfig(config)); + public saveEntityView(entityView: EntityView, config?: RequestConfig): Observable; + public saveEntityView(entityView: EntityView, saveParams: SaveEntityParams, config?: RequestConfig): Observable; + public saveEntityView(entityView: EntityView, saveParamsOrConfig?: SaveEntityParams | RequestConfig, config?: RequestConfig): Observable { + return this.http.post('/api/entityView', entityView, createDefaultHttpOptions(saveParamsOrConfig, config)); } public deleteEntityView(entityViewId: string, config?: RequestConfig) { diff --git a/ui-ngx/src/app/core/http/http-utils.ts b/ui-ngx/src/app/core/http/http-utils.ts index 787abcfbf3..ebd08f13fe 100644 --- a/ui-ngx/src/app/core/http/http-utils.ts +++ b/ui-ngx/src/app/core/http/http-utils.ts @@ -18,32 +18,56 @@ import { InterceptorHttpParams } from '../interceptors/interceptor-http-params'; import { HttpHeaders } from '@angular/common/http'; import { InterceptorConfig } from '../interceptors/interceptor-config'; +export type QueryParams = { [param:string]: any }; + export interface RequestConfig { ignoreLoading?: boolean; ignoreErrors?: boolean; resendRequest?: boolean; + queryParams?: QueryParams; +} + +export function hasRequestConfig(config?: any): boolean { + if (!config) { + return false; + } + return config.hasOwnProperty('ignoreLoading') || config.hasOwnProperty('ignoreErrors') || config.hasOwnProperty('resendRequest') || config.hasOwnProperty('queryParams'); +} + +export function createDefaultHttpOptions(queryParamsOrConfig?: QueryParams | RequestConfig, config?: RequestConfig) { + if (hasRequestConfig(queryParamsOrConfig)) { + return defaultHttpOptionsFromConfig(queryParamsOrConfig as RequestConfig); + } + const queryParams = queryParamsOrConfig as QueryParams; + const finalConfig = { + ...config, + ...(queryParams && { queryParams }), + }; + return defaultHttpOptionsFromConfig(finalConfig); } export function defaultHttpOptionsFromConfig(config?: RequestConfig) { if (!config) { config = {}; } - return defaultHttpOptions(config.ignoreLoading, config.ignoreErrors, config.resendRequest); + return defaultHttpOptions(config.ignoreLoading, config.ignoreErrors, config.resendRequest, config.queryParams); } export function defaultHttpOptions(ignoreLoading: boolean = false, ignoreErrors: boolean = false, - resendRequest: boolean = false) { + resendRequest: boolean = false, + queryParams?: QueryParams) { return { headers: new HttpHeaders({'Content-Type': 'application/json'}), - params: new InterceptorHttpParams(new InterceptorConfig(ignoreLoading, ignoreErrors, resendRequest)) + params: new InterceptorHttpParams(new InterceptorConfig(ignoreLoading, ignoreErrors, resendRequest), queryParams) }; } export function defaultHttpUploadOptions(ignoreLoading: boolean = false, ignoreErrors: boolean = false, - resendRequest: boolean = false) { + resendRequest: boolean = false, + queryParams?: QueryParams) { return { - params: new InterceptorHttpParams(new InterceptorConfig(ignoreLoading, ignoreErrors, resendRequest)) + params: new InterceptorHttpParams(new InterceptorConfig(ignoreLoading, ignoreErrors, resendRequest), queryParams) }; } diff --git a/ui-ngx/src/app/core/interceptors/interceptor-http-params.ts b/ui-ngx/src/app/core/interceptors/interceptor-http-params.ts index 1d9cbdddaa..ab75102464 100644 --- a/ui-ngx/src/app/core/interceptors/interceptor-http-params.ts +++ b/ui-ngx/src/app/core/interceptors/interceptor-http-params.ts @@ -20,7 +20,7 @@ import { InterceptorConfig } from './interceptor-config'; export class InterceptorHttpParams extends HttpParams { constructor( public interceptorConfig: InterceptorConfig, - params?: { [param: string]: string | string[] } + params?: { [param: string]: string | number | boolean | ReadonlyArray; } ) { super({ fromObject: params }); } diff --git a/ui-ngx/src/app/shared/models/device.models.ts b/ui-ngx/src/app/shared/models/device.models.ts index 8298d3a1fe..c2b95037a4 100644 --- a/ui-ngx/src/app/shared/models/device.models.ts +++ b/ui-ngx/src/app/shared/models/device.models.ts @@ -22,7 +22,7 @@ import { DeviceCredentialsId } from '@shared/models/id/device-credentials-id'; import { EntitySearchQuery } from '@shared/models/relation.models'; import { DeviceProfileId } from '@shared/models/id/device-profile-id'; import { RuleChainId } from '@shared/models/id/rule-chain-id'; -import { EntityInfoData, HasTenantId, HasVersion } from '@shared/models/entity.models'; +import { EntityInfoData, HasTenantId, HasVersion, SaveEntityParams } from '@shared/models/entity.models'; import { FilterPredicateValue, KeyFilter } from '@shared/models/query/query.models'; import { TimeUnit } from '@shared/models/time/time.models'; import _moment from 'moment'; @@ -739,6 +739,10 @@ export interface DeviceInfoFilter { active?: boolean; } +export interface SaveDeviceParams extends SaveEntityParams { + accessToken?: string; +} + export class DeviceInfoQuery { pageLink: PageLink; diff --git a/ui-ngx/src/app/shared/models/entity.models.ts b/ui-ngx/src/app/shared/models/entity.models.ts index 5aa526b583..54b5e98df3 100644 --- a/ui-ngx/src/app/shared/models/entity.models.ts +++ b/ui-ngx/src/app/shared/models/entity.models.ts @@ -209,3 +209,19 @@ export interface EntityTestScriptResult { } export type VersionedEntity = EntityInfoData & HasVersion | RuleChainMetaData; + +export enum NameConflictPolicy { + FAIL = 'FAIL', + UNIQUIFY = 'UNIQUIFY', +} + +export enum UniquifyStrategy { + RANDOM = 'RANDOM', + INCREMENTAL = 'INCREMENTAL' +} + +export interface SaveEntityParams { + nameConflictPolicy?: NameConflictPolicy; + uniquifyStrategy?: UniquifyStrategy; + uniquifySeparator?: string; +} From 03077c2bc0201710225b899d26057a81cd0389a7 Mon Sep 17 00:00:00 2001 From: dashevchenko Date: Mon, 13 Oct 2025 12:30:46 +0300 Subject: [PATCH 095/102] moved name update before validation --- .../server/dao/asset/BaseAssetService.java | 12 +++++------- .../server/dao/customer/CustomerServiceImpl.java | 10 +++++----- .../server/dao/device/DeviceServiceImpl.java | 12 +++++------- .../server/dao/entityview/EntityViewServiceImpl.java | 2 +- 4 files changed, 16 insertions(+), 20 deletions(-) diff --git a/dao/src/main/java/org/thingsboard/server/dao/asset/BaseAssetService.java b/dao/src/main/java/org/thingsboard/server/dao/asset/BaseAssetService.java index ad6adee1ee..5f8cbe7064 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/asset/BaseAssetService.java +++ b/dao/src/main/java/org/thingsboard/server/dao/asset/BaseAssetService.java @@ -160,11 +160,12 @@ public class BaseAssetService extends AbstractCachedEntityService Date: Mon, 13 Oct 2025 12:54:10 +0300 Subject: [PATCH 096/102] improved UNIQUIFY_STRATEGY_DESC description --- .../org/thingsboard/server/controller/ControllerConstants.java | 1 + 1 file changed, 1 insertion(+) diff --git a/application/src/main/java/org/thingsboard/server/controller/ControllerConstants.java b/application/src/main/java/org/thingsboard/server/controller/ControllerConstants.java index 9e533f6d6e..276d2f615c 100644 --- a/application/src/main/java/org/thingsboard/server/controller/ControllerConstants.java +++ b/application/src/main/java/org/thingsboard/server/controller/ControllerConstants.java @@ -1755,6 +1755,7 @@ public class ControllerConstants { public static final String UNIQUIFY_STRATEGY_DESC = "Optional value of uniquify strategy used by UNIQUIFY policy. Possible values: RANDOM or INCREMENTAL. " + "By default, RANDOM strategy is used, which means random alphanumeric string will be added as a suffix to entity name. " + + "INCREMENTAL implies the first possible number starting from 1 will be added as a name suffix. " + "For example, strategy is UNIQUIFY, uniquify strategy is INCREMENTAL; if a name conflict occurs for entity name 'test-name', " + "created entity will have name like 'test-name-1."; } From 374b6cf22f427f76080cf50e112b925bd865589f Mon Sep 17 00:00:00 2001 From: dashevchenko Date: Mon, 13 Oct 2025 12:57:21 +0300 Subject: [PATCH 097/102] import cleanup --- application/src/main/data/upgrade/basic/schema_update.sql | 1 - .../server/service/device/DeviceBulkImportService.java | 2 -- 2 files changed, 3 deletions(-) diff --git a/application/src/main/data/upgrade/basic/schema_update.sql b/application/src/main/data/upgrade/basic/schema_update.sql index 0e7b0d9455..0add4c0545 100644 --- a/application/src/main/data/upgrade/basic/schema_update.sql +++ b/application/src/main/data/upgrade/basic/schema_update.sql @@ -46,4 +46,3 @@ WHERE NOT ( ); -- UPDATE TENANT PROFILE CONFIGURATION END - diff --git a/application/src/main/java/org/thingsboard/server/service/device/DeviceBulkImportService.java b/application/src/main/java/org/thingsboard/server/service/device/DeviceBulkImportService.java index 7bd0dc06ea..d042fb2657 100644 --- a/application/src/main/java/org/thingsboard/server/service/device/DeviceBulkImportService.java +++ b/application/src/main/java/org/thingsboard/server/service/device/DeviceBulkImportService.java @@ -29,8 +29,6 @@ import org.thingsboard.server.common.data.DeviceProfileProvisionType; import org.thingsboard.server.common.data.DeviceProfileType; import org.thingsboard.server.common.data.DeviceTransportType; import org.thingsboard.server.common.data.EntityType; -import org.thingsboard.server.common.data.NameConflictPolicy; -import org.thingsboard.server.common.data.NameConflictStrategy; import org.thingsboard.server.common.data.StringUtils; import org.thingsboard.server.common.data.device.credentials.BasicMqttCredentials; import org.thingsboard.server.common.data.device.credentials.lwm2m.LwM2MClientCredential; From 2f3ce3c323e8e4f9c3b5dea97b95ea0dcecea3e9 Mon Sep 17 00:00:00 2001 From: VIacheslavKlimov Date: Tue, 14 Oct 2025 11:31:02 +0300 Subject: [PATCH 098/102] Fix AiModelEdgeTest --- .../test/java/org/thingsboard/server/edge/AiModelEdgeTest.java | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/application/src/test/java/org/thingsboard/server/edge/AiModelEdgeTest.java b/application/src/test/java/org/thingsboard/server/edge/AiModelEdgeTest.java index 66a681e7b7..30c8448f5b 100644 --- a/application/src/test/java/org/thingsboard/server/edge/AiModelEdgeTest.java +++ b/application/src/test/java/org/thingsboard/server/edge/AiModelEdgeTest.java @@ -24,7 +24,6 @@ import org.thingsboard.common.util.JacksonUtil; import org.thingsboard.server.common.data.ai.AiModel; import org.thingsboard.server.common.data.ai.model.chat.OpenAiChatModelConfig; import org.thingsboard.server.common.data.ai.provider.OpenAiProviderConfig; -import org.thingsboard.server.common.data.id.TenantId; import org.thingsboard.server.dao.service.DaoSqlTest; import org.thingsboard.server.gen.edge.v1.AiModelUpdateMsg; import org.thingsboard.server.gen.edge.v1.UpdateMsgType; @@ -152,7 +151,7 @@ public class AiModelEdgeTest extends AbstractEdgeTest { aiModel.setTenantId(tenantId); aiModel.setName(name); aiModel.setConfiguration(OpenAiChatModelConfig.builder() - .providerConfig(new OpenAiProviderConfig("test-api-key")) + .providerConfig(new OpenAiProviderConfig(null, "test-api-key")) .modelId("gpt-4o") .temperature(0.5) .topP(0.3) From 3fa9b877ab71309d14217bc2efdd7cafde3cd457 Mon Sep 17 00:00:00 2001 From: dashevchenko Date: Tue, 14 Oct 2025 15:57:24 +0300 Subject: [PATCH 099/102] deleted redundant constraint check, refactoring --- .../dao/entity/AbstractEntityService.java | 29 ++++++++++++------- .../dao/entityview/EntityViewServiceImpl.java | 3 +- 2 files changed, 19 insertions(+), 13 deletions(-) diff --git a/dao/src/main/java/org/thingsboard/server/dao/entity/AbstractEntityService.java b/dao/src/main/java/org/thingsboard/server/dao/entity/AbstractEntityService.java index bf3f0f346d..366aa67b63 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/entity/AbstractEntityService.java +++ b/dao/src/main/java/org/thingsboard/server/dao/entity/AbstractEntityService.java @@ -178,18 +178,25 @@ public abstract class AbstractEntityService { .filter(e -> (oldEntity == null || !e.getId().equals(oldEntity.getId()))) .map(EntityInfo::getName) .collect(Collectors.toSet()); - if (!existingNames.isEmpty()) { - int idx = 1; - String suffix = (strategy.uniquifyStrategy() == RANDOM) ? StringUtils.randomAlphanumeric(6) : String.valueOf(idx); - while (true) { - String newName = entity.getName() + strategy.separator() + suffix; - if (!existingNames.contains(newName)) { - setName.accept(newName); - break; - } - suffix = (strategy.uniquifyStrategy() == RANDOM) ? StringUtils.randomAlphanumeric(6) : String.valueOf(idx++); - } + + if (existingNames.contains(entity.getName())) { + String uniqueName = generateUniqueName(entity.getName(), existingNames, strategy); + setName.accept(uniqueName); } } + private String generateUniqueName(String baseName, Set existingNames, NameConflictStrategy strategy) { + String newName; + int index = 1; + String separator = strategy.separator(); + boolean isRandom = strategy.uniquifyStrategy() == RANDOM; + + do { + String suffix = isRandom ? StringUtils.randomAlphanumeric(6) : String.valueOf(index++); + newName = baseName + separator + suffix; + } while (existingNames.contains(newName)); + + return newName; + } + } diff --git a/dao/src/main/java/org/thingsboard/server/dao/entityview/EntityViewServiceImpl.java b/dao/src/main/java/org/thingsboard/server/dao/entityview/EntityViewServiceImpl.java index 88d87f4227..5c836a11c2 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/entityview/EntityViewServiceImpl.java +++ b/dao/src/main/java/org/thingsboard/server/dao/entityview/EntityViewServiceImpl.java @@ -139,8 +139,7 @@ public class EntityViewServiceImpl extends CachedVersionedEntityService Date: Wed, 15 Oct 2025 12:34:17 +0300 Subject: [PATCH 100/102] extended deleteTenant API to allow tenant admins delete tenant --- .../server/controller/TenantController.java | 2 +- .../permission/TenantAdminPermissions.java | 2 +- .../controller/TenantControllerTest.java | 23 +++++++++++++++++++ 3 files changed, 25 insertions(+), 2 deletions(-) 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 3ded7b7171..2f8be6589f 100644 --- a/application/src/main/java/org/thingsboard/server/controller/TenantController.java +++ b/application/src/main/java/org/thingsboard/server/controller/TenantController.java @@ -115,7 +115,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("hasAuthority('SYS_ADMIN')") + @PreAuthorize("hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN')") @RequestMapping(value = "/tenant/{tenantId}", method = RequestMethod.DELETE) @ResponseStatus(value = HttpStatus.OK) public void deleteTenant(@Parameter(description = TENANT_ID_PARAM_DESCRIPTION) diff --git a/application/src/main/java/org/thingsboard/server/service/security/permission/TenantAdminPermissions.java b/application/src/main/java/org/thingsboard/server/service/security/permission/TenantAdminPermissions.java index 7a824ca735..bc5e5b8697 100644 --- a/application/src/main/java/org/thingsboard/server/service/security/permission/TenantAdminPermissions.java +++ b/application/src/main/java/org/thingsboard/server/service/security/permission/TenantAdminPermissions.java @@ -73,7 +73,7 @@ public class TenantAdminPermissions extends AbstractPermissions { }; private static final PermissionChecker tenantPermissionChecker = - new PermissionChecker.GenericPermissionChecker(Operation.READ, Operation.READ_ATTRIBUTES, Operation.READ_TELEMETRY) { + new PermissionChecker.GenericPermissionChecker(Operation.READ, Operation.READ_ATTRIBUTES, Operation.READ_TELEMETRY, Operation.DELETE) { @Override @SuppressWarnings("unchecked") 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 ba45be8e28..a33e8ca411 100644 --- a/application/src/test/java/org/thingsboard/server/controller/TenantControllerTest.java +++ b/application/src/test/java/org/thingsboard/server/controller/TenantControllerTest.java @@ -243,6 +243,29 @@ public class TenantControllerTest extends AbstractControllerTest { .andExpect(statusReason(containsString(msgErrorNoFound("Tenant", tenantIdStr)))); } + @Test + public void testDeleteTenantByTenantAdmin() throws Exception { + loginSysAdmin(); + Tenant tenant = new Tenant(); + tenant.setTitle("My tenant"); + Tenant savedTenant = saveTenant(tenant); + + //login as tenant admin + User tenantAdminUser = new User(); + tenantAdminUser.setAuthority(Authority.TENANT_ADMIN); + tenantAdminUser.setTenantId(savedTenant.getId()); + tenantAdminUser.setEmail("tenantToDelete@thingsboard.io"); + + createUserAndLogin(tenantAdminUser, TENANT_ADMIN_PASSWORD); + + String tenantIdStr = savedTenant.getId().getId().toString(); + deleteTenant(savedTenant.getId()); + loginSysAdmin(); + doGet("/api/tenant/" + tenantIdStr) + .andExpect(status().isNotFound()) + .andExpect(statusReason(containsString(msgErrorNoFound("Tenant", tenantIdStr)))); + } + @Test public void testFindTenants() throws Exception { loginSysAdmin(); From 846d9f6a8bc49540b93d0ef8c7cfe63c9e64f592 Mon Sep 17 00:00:00 2001 From: ArtemDzhereleiko Date: Wed, 15 Oct 2025 13:28:21 +0300 Subject: [PATCH 101/102] UI: Remove ondestroy --- .../pages/admin/two-factor-auth-settings.component.ts | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/ui-ngx/src/app/modules/home/pages/admin/two-factor-auth-settings.component.ts b/ui-ngx/src/app/modules/home/pages/admin/two-factor-auth-settings.component.ts index 38a6230b6f..2770f904bb 100644 --- a/ui-ngx/src/app/modules/home/pages/admin/two-factor-auth-settings.component.ts +++ b/ui-ngx/src/app/modules/home/pages/admin/two-factor-auth-settings.component.ts @@ -14,7 +14,7 @@ /// limitations under the License. /// -import { Component, DestroyRef, OnDestroy, OnInit, QueryList, ViewChildren } from '@angular/core'; +import { Component, DestroyRef, OnInit, QueryList, ViewChildren } from '@angular/core'; import { PageComponent } from '@shared/components/page.component'; import { HasConfirmForm } from '@core/guards/confirm-on-exit.guard'; import { Store } from '@ngrx/store'; @@ -39,7 +39,7 @@ import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; templateUrl: './two-factor-auth-settings.component.html', styleUrls: [ './settings-card.scss', './two-factor-auth-settings.component.scss'] }) -export class TwoFactorAuthSettingsComponent extends PageComponent implements OnInit, HasConfirmForm, OnDestroy { +export class TwoFactorAuthSettingsComponent extends PageComponent implements OnInit, HasConfirmForm { private readonly posIntValidation = [Validators.required, Validators.min(1), Validators.pattern(/^\d*$/)]; @@ -72,10 +72,6 @@ export class TwoFactorAuthSettingsComponent extends PageComponent implements OnI }); } - ngOnDestroy() { - super.ngOnDestroy(); - } - confirmForm(): UntypedFormGroup { return this.twoFaFormGroup; } From 4f89242e60ca7741180cf8a6be0958308d2530ad Mon Sep 17 00:00:00 2001 From: dashevchenko Date: Fri, 17 Oct 2025 11:04:17 +0300 Subject: [PATCH 102/102] fixed entity save methods to not retrieve old value from cache --- .../java/org/thingsboard/server/dao/asset/BaseAssetService.java | 2 +- .../thingsboard/server/dao/customer/CustomerServiceImpl.java | 2 +- .../org/thingsboard/server/dao/device/DeviceServiceImpl.java | 2 +- .../server/dao/entityview/EntityViewServiceImpl.java | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/dao/src/main/java/org/thingsboard/server/dao/asset/BaseAssetService.java b/dao/src/main/java/org/thingsboard/server/dao/asset/BaseAssetService.java index 5f8cbe7064..7d8b2e0e8c 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/asset/BaseAssetService.java +++ b/dao/src/main/java/org/thingsboard/server/dao/asset/BaseAssetService.java @@ -160,7 +160,7 @@ public class BaseAssetService extends AbstractCachedEntityService