From c121b6b5e46b16fe991cdf2800d7f5cbdf8de856 Mon Sep 17 00:00:00 2001 From: Igor Kulikov Date: Mon, 8 Dec 2025 17:19:12 +0200 Subject: [PATCH 1/5] Update entities limit exception processing. Implement entities limit increase request notification. --- .../server/controller/BaseController.java | 3 + .../controller/NotificationController.java | 39 +++++++ .../exception/ThingsboardErrorResponse.java | 28 +++++ .../ThingsboardErrorResponseHandler.java | 24 +++++ .../install/ThingsboardInstallService.java | 1 + .../data/exception/ThingsboardErrorCode.java | 1 + .../data/notification/NotificationType.java | 1 + ...sLimitIncreaseRequestNotificationInfo.java | 50 +++++++++ .../dao/exception/EntitiesLimitException.java | 6 +- .../DefaultNotificationSettingsService.java | 4 + .../notification/DefaultNotifications.java | 38 ++++++- .../server/dao/service/DataValidator.java | 3 +- .../src/app/core/http/notification.service.ts | 5 + .../interceptors/global-http-interceptor.ts | 4 + .../src/app/core/services/dialog.service.ts | 15 +++ .../template-notification-dialog.component.ts | 1 + ...ntity-limit-exceeded-dialog.component.html | 40 +++++++ ...ntity-limit-exceeded-dialog.component.scss | 97 +++++++++++++++++ .../entity-limit-exceeded-dialog.component.ts | 100 ++++++++++++++++++ .../notification/notification.component.ts | 2 + ui-ngx/src/app/shared/models/constants.ts | 1 + .../app/shared/models/notification.models.ts | 8 ++ ui-ngx/src/app/shared/shared.module.ts | 3 + ui-ngx/src/assets/entity-limit-reached.svg | 20 ++++ .../entities_limit_increase_request.md | 46 ++++++++ .../assets/locale/locale.constant-en_US.json | 11 +- 26 files changed, 544 insertions(+), 7 deletions(-) create mode 100644 common/data/src/main/java/org/thingsboard/server/common/data/notification/info/EntitiesLimitIncreaseRequestNotificationInfo.java create mode 100644 ui-ngx/src/app/shared/components/dialog/entity-limit-exceeded-dialog.component.html create mode 100644 ui-ngx/src/app/shared/components/dialog/entity-limit-exceeded-dialog.component.scss create mode 100644 ui-ngx/src/app/shared/components/dialog/entity-limit-exceeded-dialog.component.ts create mode 100644 ui-ngx/src/assets/entity-limit-reached.svg create mode 100644 ui-ngx/src/assets/help/en_US/notification/entities_limit_increase_request.md diff --git a/application/src/main/java/org/thingsboard/server/controller/BaseController.java b/application/src/main/java/org/thingsboard/server/controller/BaseController.java index 621657c02d..a58ae911a2 100644 --- a/application/src/main/java/org/thingsboard/server/controller/BaseController.java +++ b/application/src/main/java/org/thingsboard/server/controller/BaseController.java @@ -151,6 +151,7 @@ import org.thingsboard.server.dao.domain.DomainService; import org.thingsboard.server.dao.edge.EdgeService; import org.thingsboard.server.dao.entityview.EntityViewService; import org.thingsboard.server.dao.exception.DataValidationException; +import org.thingsboard.server.dao.exception.EntitiesLimitException; import org.thingsboard.server.dao.exception.IncorrectParameterException; import org.thingsboard.server.dao.job.JobService; import org.thingsboard.server.dao.mobile.MobileAppBundleService; @@ -452,6 +453,8 @@ public abstract class BaseController { } if (exception instanceof ThingsboardException) { return (ThingsboardException) exception; + } else if (exception instanceof EntitiesLimitException) { + return new ThingsboardException(exception, ThingsboardErrorCode.ENTITIES_LIMIT_EXCEEDED); } else if (exception instanceof IllegalArgumentException || exception instanceof IncorrectParameterException || exception instanceof DataValidationException || cause instanceof IncorrectParameterException) { return new ThingsboardException(exception.getMessage(), ThingsboardErrorCode.BAD_REQUEST_PARAMS); diff --git a/application/src/main/java/org/thingsboard/server/controller/NotificationController.java b/application/src/main/java/org/thingsboard/server/controller/NotificationController.java index b536b9bb8a..1508354f19 100644 --- a/application/src/main/java/org/thingsboard/server/controller/NotificationController.java +++ b/application/src/main/java/org/thingsboard/server/controller/NotificationController.java @@ -17,6 +17,7 @@ package org.thingsboard.server.controller; import io.swagger.v3.oas.annotations.Parameter; import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.servlet.http.HttpServletRequest; import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @@ -35,6 +36,8 @@ import org.thingsboard.rule.engine.api.NotificationCenter; import org.thingsboard.server.common.data.EntityType; import org.thingsboard.server.common.data.User; import org.thingsboard.server.common.data.exception.ThingsboardException; +import org.thingsboard.server.common.data.id.CustomerId; +import org.thingsboard.server.common.data.id.EntityId; import org.thingsboard.server.common.data.id.NotificationId; import org.thingsboard.server.common.data.id.NotificationRequestId; import org.thingsboard.server.common.data.id.NotificationTargetId; @@ -44,6 +47,9 @@ import org.thingsboard.server.common.data.notification.NotificationDeliveryMetho import org.thingsboard.server.common.data.notification.NotificationRequest; import org.thingsboard.server.common.data.notification.NotificationRequestInfo; import org.thingsboard.server.common.data.notification.NotificationRequestPreview; +import org.thingsboard.server.common.data.notification.NotificationType; +import org.thingsboard.server.common.data.notification.info.EntitiesLimitIncreaseRequestNotificationInfo; +import org.thingsboard.server.common.data.notification.info.NotificationInfo; import org.thingsboard.server.common.data.notification.settings.NotificationSettings; import org.thingsboard.server.common.data.notification.settings.UserNotificationSettings; import org.thingsboard.server.common.data.notification.targets.MicrosoftTeamsNotificationTargetConfig; @@ -51,6 +57,7 @@ import org.thingsboard.server.common.data.notification.targets.NotificationRecip import org.thingsboard.server.common.data.notification.targets.NotificationTarget; import org.thingsboard.server.common.data.notification.targets.NotificationTargetType; import org.thingsboard.server.common.data.notification.targets.platform.PlatformUsersNotificationTargetConfig; +import org.thingsboard.server.common.data.notification.targets.platform.UsersFilterType; import org.thingsboard.server.common.data.notification.targets.slack.SlackConversation; import org.thingsboard.server.common.data.notification.targets.slack.SlackNotificationTargetConfig; import org.thingsboard.server.common.data.notification.template.DeliveryMethodNotificationTemplate; @@ -69,6 +76,7 @@ import org.thingsboard.server.service.notification.NotificationProcessingContext 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 org.thingsboard.server.service.security.system.SystemSecurityService; import java.util.Comparator; import java.util.HashMap; @@ -76,6 +84,7 @@ import java.util.LinkedHashMap; import java.util.LinkedHashSet; import java.util.List; import java.util.Map; +import java.util.Optional; import java.util.Set; import java.util.UUID; import java.util.stream.Collectors; @@ -90,6 +99,7 @@ import static org.thingsboard.server.controller.ControllerConstants.PAGE_SIZE_DE import static org.thingsboard.server.controller.ControllerConstants.SORT_ORDER_DESCRIPTION; import static org.thingsboard.server.controller.ControllerConstants.SORT_PROPERTY_DESCRIPTION; import static org.thingsboard.server.controller.ControllerConstants.SYSTEM_OR_TENANT_AUTHORITY_PARAGRAPH; +import static org.thingsboard.server.controller.ControllerConstants.TENANT_AUTHORITY_PARAGRAPH; import static org.thingsboard.server.service.security.permission.Resource.NOTIFICATION; @RestController @@ -105,6 +115,7 @@ public class NotificationController extends BaseController { private final NotificationTargetService notificationTargetService; private final NotificationCenter notificationCenter; private final NotificationSettingsService notificationSettingsService; + private final SystemSecurityService systemSecurityService; @ApiOperation(value = "Get notifications (getNotifications)", notes = "Returns the page of notifications for current user." + NEW_LINE + @@ -282,6 +293,34 @@ public class NotificationController extends BaseController { return doSaveAndLog(EntityType.NOTIFICATION_REQUEST, notificationRequest, (tenantId, request) -> notificationCenter.processNotificationRequest(tenantId, request, null)); } + @ApiOperation(value = "Send entity limit increase request notification to System administrators (sendEntitiesLimitIncreaseRequest)", + notes = "Send entity limit increase request notification by Tenant Administrator to System administrators." + + TENANT_AUTHORITY_PARAGRAPH) + @PostMapping("/notification/entitiesLimitIncreaseRequest/{entityType}") + @PreAuthorize("hasAnyAuthority('TENANT_ADMIN')") + public void sendEntitiesLimitIncreaseRequest(@Parameter(description = "Entity type", required = true, schema = @Schema(allowableValues = {"DEVICE", "ASSET", "CUSTOMER", "USER", "DASHBOARD", "RULE_CHAIN", "EDGE"})) + @PathVariable("entityType") String strEntityType, + @AuthenticationPrincipal SecurityUser user, + HttpServletRequest request) throws Exception { + EntityType entityType = checkEnumParameter("entityType", strEntityType, EntityType::valueOf); + Optional sysAdmins = notificationTargetService.findNotificationTargetsByTenantIdAndUsersFilterType(TenantId.SYS_TENANT_ID, UsersFilterType.SYSTEM_ADMINISTRATORS) + .stream().findFirst(); + if (sysAdmins.isPresent()) { + NotificationTargetId notificationTargetId = sysAdmins.get().getId(); + String baseUrl = systemSecurityService.getBaseUrl(TenantId.SYS_TENANT_ID, new CustomerId(EntityId.NULL_UUID), request); + NotificationInfo info = EntitiesLimitIncreaseRequestNotificationInfo.builder() + .entityType(entityType) + .userEmail(user.getEmail()) + .increaseLimitActionLabel("Set new limit") + .increaseLimitLink("/tenantProfiles/"+tenantService.findTenantById(user.getTenantId()).getTenantProfileId().toString()) + .baseUrl(baseUrl) + .build(); + notificationCenter.sendSystemNotification(TenantId.SYS_TENANT_ID, notificationTargetId, NotificationType.ENTITIES_LIMIT_INCREASE_REQUEST, info); + } else { + throw new IllegalArgumentException("Notification target for 'System administrators' not found"); + } + } + @ApiOperation(value = "Get notification request preview (getNotificationRequestPreview)", notes = "Returns preview for notification request." + NEW_LINE + "`processedTemplates` shows how the notifications for each delivery method will look like " + diff --git a/application/src/main/java/org/thingsboard/server/exception/ThingsboardErrorResponse.java b/application/src/main/java/org/thingsboard/server/exception/ThingsboardErrorResponse.java index d34ae3c89b..f38b86e394 100644 --- a/application/src/main/java/org/thingsboard/server/exception/ThingsboardErrorResponse.java +++ b/application/src/main/java/org/thingsboard/server/exception/ThingsboardErrorResponse.java @@ -15,8 +15,10 @@ */ package org.thingsboard.server.exception; +import com.fasterxml.jackson.databind.JsonNode; import io.swagger.v3.oas.annotations.media.Schema; import org.springframework.http.HttpStatus; +import org.thingsboard.server.common.data.EntityType; import org.thingsboard.server.common.data.exception.ThingsboardErrorCode; @Schema @@ -32,9 +34,19 @@ public class ThingsboardErrorResponse { private final long timestamp; + private EntityType entityType; + + private Long limit; + protected ThingsboardErrorResponse(final String message, final ThingsboardErrorCode errorCode, HttpStatus status) { + this(message, errorCode, null, null, status); + } + + protected ThingsboardErrorResponse(final String message, final ThingsboardErrorCode errorCode, EntityType entityType, Long limit, HttpStatus status) { this.message = message; this.errorCode = errorCode; + this.entityType = entityType; + this.limit = limit; this.status = status; this.timestamp = System.currentTimeMillis(); } @@ -43,6 +55,14 @@ public class ThingsboardErrorResponse { return new ThingsboardErrorResponse(message, errorCode, status); } + public static ThingsboardErrorResponse ofEntityLimitExceeded(final String message, + EntityType entityType, + Long limit, + HttpStatus status) { + return new ThingsboardErrorResponse(message, ThingsboardErrorCode.ENTITIES_LIMIT_EXCEEDED, + entityType, limit, status); + } + @Schema(description = "HTTP Response Status Code", example = "401", accessMode = Schema.AccessMode.READ_ONLY) public Integer getStatus() { return status.value(); @@ -75,4 +95,12 @@ public class ThingsboardErrorResponse { public long getTimestamp() { return timestamp; } + + public EntityType getEntityType() { + return entityType; + } + + public Long getLimit() { + return limit; + } } diff --git a/application/src/main/java/org/thingsboard/server/exception/ThingsboardErrorResponseHandler.java b/application/src/main/java/org/thingsboard/server/exception/ThingsboardErrorResponseHandler.java index 56292dfa1b..e6e27e6a75 100644 --- a/application/src/main/java/org/thingsboard/server/exception/ThingsboardErrorResponseHandler.java +++ b/application/src/main/java/org/thingsboard/server/exception/ThingsboardErrorResponseHandler.java @@ -47,10 +47,12 @@ import org.springframework.web.context.request.WebRequest; import org.springframework.web.servlet.mvc.method.annotation.ResponseEntityExceptionHandler; import org.springframework.web.util.WebUtils; import org.thingsboard.common.util.JacksonUtil; +import org.thingsboard.server.common.data.EntityType; import org.thingsboard.server.common.data.exception.ThingsboardErrorCode; import org.thingsboard.server.common.data.exception.ThingsboardException; import org.thingsboard.server.common.msg.tools.MaxPayloadSizeExceededException; import org.thingsboard.server.common.msg.tools.TbRateLimitsException; +import org.thingsboard.server.dao.exception.EntitiesLimitException; import org.thingsboard.server.service.security.exception.AuthMethodNotSupportedException; import org.thingsboard.server.service.security.exception.JwtExpiredTokenException; import org.thingsboard.server.service.security.exception.UserPasswordExpiredException; @@ -95,6 +97,7 @@ public class ThingsboardErrorResponseHandler extends ResponseEntityExceptionHand errorCodeToStatusMap.put(ThingsboardErrorCode.TOO_MANY_REQUESTS, HttpStatus.TOO_MANY_REQUESTS); errorCodeToStatusMap.put(ThingsboardErrorCode.TOO_MANY_UPDATES, HttpStatus.TOO_MANY_REQUESTS); errorCodeToStatusMap.put(ThingsboardErrorCode.SUBSCRIPTION_VIOLATION, HttpStatus.FORBIDDEN); + errorCodeToStatusMap.put(ThingsboardErrorCode.ENTITIES_LIMIT_EXCEEDED, HttpStatus.FORBIDDEN); errorCodeToStatusMap.put(ThingsboardErrorCode.VERSION_CONFLICT, HttpStatus.CONFLICT); } @@ -143,6 +146,12 @@ public class ThingsboardErrorResponseHandler extends ResponseEntityExceptionHand handleSubscriptionException(thingsboardException, response); } else if (thingsboardException.getErrorCode() == ThingsboardErrorCode.DATABASE) { handleDatabaseException(thingsboardException.getCause(), response); + } else if (thingsboardException.getErrorCode() == ThingsboardErrorCode.ENTITIES_LIMIT_EXCEEDED) { + if (thingsboardException.getCause() instanceof EntitiesLimitException entitiesLimitException) { + handleEntitiesLimitException(entitiesLimitException, response); + } else { + handleEntitiesLimitException(thingsboardException, response); + } } else { handleThingsboardException(thingsboardException, response); } @@ -218,6 +227,21 @@ public class ThingsboardErrorResponseHandler extends ResponseEntityExceptionHand writeResponse(errorResponse, response); } + private void handleEntitiesLimitException(ThingsboardException entitiesLimitException, HttpServletResponse response) throws IOException { + response.setStatus(HttpStatus.FORBIDDEN.value()); + JacksonUtil.writeValue(response.getWriter(), + JacksonUtil.fromBytes(((HttpClientErrorException) entitiesLimitException.getCause()).getResponseBodyAsByteArray(), Object.class)); + } + + private void handleEntitiesLimitException(EntitiesLimitException entitiesLimitException, HttpServletResponse response) throws IOException { + EntityType entityType = entitiesLimitException.getEntityType(); + Long limit = entitiesLimitException.getLimit(); + HttpStatus status = HttpStatus.FORBIDDEN; + response.setStatus(status.value()); + JacksonUtil.writeValue(response.getWriter(), + ThingsboardErrorResponse.ofEntityLimitExceeded(entitiesLimitException.getMessage(), entityType, limit, status)); + } + private void handleAccessDeniedException(HttpServletResponse response) throws IOException { response.setStatus(HttpStatus.FORBIDDEN.value()); JacksonUtil.writeValue(response.getWriter(), diff --git a/application/src/main/java/org/thingsboard/server/install/ThingsboardInstallService.java b/application/src/main/java/org/thingsboard/server/install/ThingsboardInstallService.java index 6765e95246..5e5185ac8b 100644 --- a/application/src/main/java/org/thingsboard/server/install/ThingsboardInstallService.java +++ b/application/src/main/java/org/thingsboard/server/install/ThingsboardInstallService.java @@ -116,6 +116,7 @@ public class ThingsboardInstallService { entityDatabaseSchemaService.createDatabaseIndexes(); // TODO: cleanup update code after each release + systemDataLoaderService.updateDefaultNotificationConfigs(false); // Runs upgrade scripts that are not possible in plain SQL. dataUpdateService.updateData(); diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/exception/ThingsboardErrorCode.java b/common/data/src/main/java/org/thingsboard/server/common/data/exception/ThingsboardErrorCode.java index deae86a9de..1025e9a90e 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/exception/ThingsboardErrorCode.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/exception/ThingsboardErrorCode.java @@ -31,6 +31,7 @@ public enum ThingsboardErrorCode { TOO_MANY_UPDATES(34), VERSION_CONFLICT(35), SUBSCRIPTION_VIOLATION(40), + ENTITIES_LIMIT_EXCEEDED(41), PASSWORD_VIOLATION(45), DATABASE(46); diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/notification/NotificationType.java b/common/data/src/main/java/org/thingsboard/server/common/data/notification/NotificationType.java index d4d3d11e8b..005e2f64d3 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/notification/NotificationType.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/notification/NotificationType.java @@ -32,6 +32,7 @@ public enum NotificationType { ALARM_ASSIGNMENT, NEW_PLATFORM_VERSION, ENTITIES_LIMIT, + ENTITIES_LIMIT_INCREASE_REQUEST, API_USAGE_LIMIT, RULE_NODE, RATE_LIMITS, diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/notification/info/EntitiesLimitIncreaseRequestNotificationInfo.java b/common/data/src/main/java/org/thingsboard/server/common/data/notification/info/EntitiesLimitIncreaseRequestNotificationInfo.java new file mode 100644 index 0000000000..208b43282e --- /dev/null +++ b/common/data/src/main/java/org/thingsboard/server/common/data/notification/info/EntitiesLimitIncreaseRequestNotificationInfo.java @@ -0,0 +1,50 @@ +/** + * 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.info; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; +import org.thingsboard.server.common.data.EntityType; + +import java.util.Map; + +import static org.thingsboard.server.common.data.util.CollectionsUtil.mapOf; + +@Data +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class EntitiesLimitIncreaseRequestNotificationInfo implements NotificationInfo { + + private EntityType entityType; + private String userEmail; + private String increaseLimitActionLabel; + private String increaseLimitLink; + private String baseUrl; + + @Override + public Map getTemplateData() { + return mapOf( + "entityType", entityType.getNormalName(), + "userEmail", userEmail, + "increaseLimitActionLabel", increaseLimitActionLabel, + "increaseLimitLink", increaseLimitLink, + "baseUrl", baseUrl + ); + } +} diff --git a/dao/src/main/java/org/thingsboard/server/dao/exception/EntitiesLimitException.java b/dao/src/main/java/org/thingsboard/server/dao/exception/EntitiesLimitException.java index 64ed1d1328..559ef94e45 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/exception/EntitiesLimitException.java +++ b/dao/src/main/java/org/thingsboard/server/dao/exception/EntitiesLimitException.java @@ -27,9 +27,13 @@ public class EntitiesLimitException extends DataValidationException { @Getter private final EntityType entityType; - public EntitiesLimitException(TenantId tenantId, EntityType entityType) { + @Getter + private final long limit; + + public EntitiesLimitException(TenantId tenantId, EntityType entityType, long limit) { super(entityType.getNormalName() + "s limit reached"); this.tenantId = tenantId; this.entityType = entityType; + this.limit = limit; } } diff --git a/dao/src/main/java/org/thingsboard/server/dao/notification/DefaultNotificationSettingsService.java b/dao/src/main/java/org/thingsboard/server/dao/notification/DefaultNotificationSettingsService.java index ea7d3488b2..038d24b386 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/notification/DefaultNotificationSettingsService.java +++ b/dao/src/main/java/org/thingsboard/server/dao/notification/DefaultNotificationSettingsService.java @@ -177,6 +177,7 @@ public class DefaultNotificationSettingsService implements NotificationSettingsS defaultNotifications.create(tenantId, DefaultNotifications.entitiesLimitForSysadmin, sysAdmins.getId()); defaultNotifications.create(tenantId, DefaultNotifications.entitiesLimitForTenant, affectedTenantAdmins.getId()); + defaultNotifications.create(tenantId, DefaultNotifications.entitiesLimitIncreaseRequest, sysAdmins.getId()); defaultNotifications.create(tenantId, DefaultNotifications.apiFeatureWarningForSysadmin, sysAdmins.getId()); defaultNotifications.create(tenantId, DefaultNotifications.apiFeatureWarningForTenant, affectedTenantAdmins.getId()); defaultNotifications.create(tenantId, DefaultNotifications.apiFeatureDisabledForSysadmin, sysAdmins.getId()); @@ -225,6 +226,9 @@ public class DefaultNotificationSettingsService implements NotificationSettingsS if (!isNotificationConfigured(tenantId, NotificationType.RESOURCES_SHORTAGE)) { defaultNotifications.create(tenantId, DefaultNotifications.resourcesShortage, sysAdmins.getId()); } + if (!isNotificationConfigured(tenantId, NotificationType.ENTITIES_LIMIT_INCREASE_REQUEST)) { + defaultNotifications.create(tenantId, DefaultNotifications.entitiesLimitIncreaseRequest, sysAdmins.getId()); + } } else { var requiredNotificationTypes = List.of(NotificationType.EDGE_CONNECTION, NotificationType.EDGE_COMMUNICATION_FAILURE); var existingNotificationTypes = notificationTemplateService.findNotificationTemplatesByTenantIdAndNotificationTypes( diff --git a/dao/src/main/java/org/thingsboard/server/dao/notification/DefaultNotifications.java b/dao/src/main/java/org/thingsboard/server/dao/notification/DefaultNotifications.java index 7cee3d04ae..7e82ae9472 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/notification/DefaultNotifications.java +++ b/dao/src/main/java/org/thingsboard/server/dao/notification/DefaultNotifications.java @@ -52,12 +52,15 @@ import org.thingsboard.server.common.data.notification.rule.trigger.config.RateL import org.thingsboard.server.common.data.notification.rule.trigger.config.ResourcesShortageNotificationRuleTriggerConfig; import org.thingsboard.server.common.data.notification.rule.trigger.config.RuleEngineComponentLifecycleEventNotificationRuleTriggerConfig; import org.thingsboard.server.common.data.notification.rule.trigger.config.TaskProcessingFailureNotificationRuleTriggerConfig; +import org.thingsboard.server.common.data.notification.template.DeliveryMethodNotificationTemplate; +import org.thingsboard.server.common.data.notification.template.EmailDeliveryMethodNotificationTemplate; import org.thingsboard.server.common.data.notification.template.NotificationTemplate; import org.thingsboard.server.common.data.notification.template.NotificationTemplateConfig; import org.thingsboard.server.common.data.notification.template.WebDeliveryMethodNotificationTemplate; import org.thingsboard.server.common.data.plugin.ComponentLifecycleEvent; import java.util.Arrays; +import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.Set; @@ -102,7 +105,17 @@ public class DefaultNotifications { .description("Send notification to tenant admins when count of entities of some type reached 80% threshold of the limit") .build()) .build(); - + public static final DefaultNotification entitiesLimitIncreaseRequest = DefaultNotification.builder() + .name("Entities limit increase request for sysadmin") + .type(NotificationType.ENTITIES_LIMIT_INCREASE_REQUEST) + .subject("${entityType} limit increase request") + .text("${userEmail} has reached the maximum number of ${entityType:lowerCase}s allowed and is requesting an increase to the ${entityType:lowerCase} limit.") + .button("${increaseLimitActionLabel}").link("${increaseLimitLink}") + .emailTemplate(DefaultEmailTemplate.builder() + .subject("${entityType} limit increase request") + .body("${userEmail} has reached the maximum number of ${entityType:lowerCase}s allowed and is requesting an increase to the ${entityType:lowerCase} limit.
${increaseLimitActionLabel}") + .build()) + .build(); public static final DefaultNotification apiFeatureWarningForSysadmin = DefaultNotification.builder() .name("API feature warning notification for sysadmin") .type(NotificationType.API_USAGE_LIMIT) @@ -415,6 +428,8 @@ public class DefaultNotifications { private final String button; private final String link; + private final DefaultEmailTemplate emailTemplate; + private final DefaultRule rule; public NotificationTemplate toTemplate() { @@ -448,9 +463,17 @@ public class DefaultNotifications { } webTemplate.setAdditionalConfig(additionalConfig); webTemplate.setEnabled(true); - templateConfig.setDeliveryMethodsTemplates(Map.of( - NotificationDeliveryMethod.WEB, webTemplate - )); + + Map deliveryMethodsTemplates = new HashMap<>(); + deliveryMethodsTemplates.put(NotificationDeliveryMethod.WEB, webTemplate); + if (this.emailTemplate != null) { + EmailDeliveryMethodNotificationTemplate emailTemplate = new EmailDeliveryMethodNotificationTemplate(); + emailTemplate.setSubject(this.emailTemplate.getSubject()); + emailTemplate.setBody(this.emailTemplate.getBody()); + emailTemplate.setEnabled(true); + deliveryMethodsTemplates.put(NotificationDeliveryMethod.EMAIL, emailTemplate); + } + templateConfig.setDeliveryMethodsTemplates(deliveryMethodsTemplates); template.setConfiguration(templateConfig); return template; } @@ -482,6 +505,13 @@ public class DefaultNotifications { } + @Data + @Builder(toBuilder = true) + public static class DefaultEmailTemplate { + private final String subject; + private final String body; + } + @Data @Builder(toBuilder = true) public static class DefaultRule { diff --git a/dao/src/main/java/org/thingsboard/server/dao/service/DataValidator.java b/dao/src/main/java/org/thingsboard/server/dao/service/DataValidator.java index 76a7906b03..ffa88fe39c 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/service/DataValidator.java +++ b/dao/src/main/java/org/thingsboard/server/dao/service/DataValidator.java @@ -123,7 +123,8 @@ public abstract class DataValidator> { protected void validateNumberOfEntitiesPerTenant(TenantId tenantId, EntityType entityType) { if (!apiLimitService.checkEntitiesLimit(tenantId, entityType)) { - throw new EntitiesLimitException(tenantId, entityType); + long limit = apiLimitService.getLimit(tenantId, profileConfiguration -> profileConfiguration.getEntitiesLimit(entityType)); + throw new EntitiesLimitException(tenantId, entityType, limit); } } diff --git a/ui-ngx/src/app/core/http/notification.service.ts b/ui-ngx/src/app/core/http/notification.service.ts index 93adc36c97..a07caed745 100644 --- a/ui-ngx/src/app/core/http/notification.service.ts +++ b/ui-ngx/src/app/core/http/notification.service.ts @@ -37,6 +37,7 @@ import { } from '@shared/models/notification.models'; import { User } from '@shared/models/user.model'; import { isNotEmptyStr } from '@core/utils'; +import { EntityType } from '@shared/models/entity-type.models'; @Injectable({ providedIn: 'root' @@ -69,6 +70,10 @@ export class NotificationService { return this.http.post('/api/notification/request', notification, defaultHttpOptionsFromConfig(config)); } + public sendEntitiesLimitIncreaseRequest(entityType: EntityType, config?: RequestConfig): Observable { + return this.http.post(`/api/notification/entitiesLimitIncreaseRequest/${entityType}`, defaultHttpOptionsFromConfig(config)); + } + public getNotificationRequestById(id: string, config?: RequestConfig): Observable { return this.http.get(`/api/notification/request/${id}`, defaultHttpOptionsFromConfig(config)); } diff --git a/ui-ngx/src/app/core/interceptors/global-http-interceptor.ts b/ui-ngx/src/app/core/interceptors/global-http-interceptor.ts index af204fc99b..f09e3321ff 100644 --- a/ui-ngx/src/app/core/interceptors/global-http-interceptor.ts +++ b/ui-ngx/src/app/core/interceptors/global-http-interceptor.ts @@ -109,6 +109,10 @@ export class GlobalHttpInterceptor implements HttpInterceptor { } else if (errorCode !== Constants.serverErrorCode.credentialsExpired) { unhandled = true; } + } else if (errorCode && errorCode === Constants.serverErrorCode.entitiesLimitExceeded) { + if (!ignoreErrors) { + this.dialogService.entitiesLimitExceeded(errorResponse.error); + } } else if (errorResponse.status === 429) { if (resendRequest) { return this.retryRequest(req, next); diff --git a/ui-ngx/src/app/core/services/dialog.service.ts b/ui-ngx/src/app/core/services/dialog.service.ts index 7844ffbd2d..631c1224b3 100644 --- a/ui-ngx/src/app/core/services/dialog.service.ts +++ b/ui-ngx/src/app/core/services/dialog.service.ts @@ -36,6 +36,11 @@ import { ErrorAlertDialogData } from '@shared/components/dialog/error-alert-dialog.component'; import { TodoDialogComponent } from '@shared/components/dialog/todo-dialog.component'; +import { EntityType } from '@shared/models/entity-type.models'; +import { + EntityLimitExceededDialogComponent, + EntityLimitExceededDialogData +} from '@shared/components/dialog/entity-limit-exceeded-dialog.component'; @Injectable({ providedIn: 'root' @@ -125,6 +130,16 @@ export class DialogService { }).afterClosed(); } + entitiesLimitExceeded(entityLimitData: {entityType: EntityType, limit: number}): Observable { + return this.dialog.open(EntityLimitExceededDialogComponent, + { + disableClose: true, + panelClass: ['tb-dialog', 'tb-fullscreen-dialog'], + data: entityLimitData, + autoFocus: false + }).afterClosed(); + } + private permissionDenied() { this.alert( this.translate.instant('access.permission-denied'), diff --git a/ui-ngx/src/app/modules/home/pages/notification/template/template-notification-dialog.component.ts b/ui-ngx/src/app/modules/home/pages/notification/template/template-notification-dialog.component.ts index 0f13409a3e..6fe3540854 100644 --- a/ui-ngx/src/app/modules/home/pages/notification/template/template-notification-dialog.component.ts +++ b/ui-ngx/src/app/modules/home/pages/notification/template/template-notification-dialog.component.ts @@ -181,6 +181,7 @@ export class TemplateNotificationDialogComponent private allowNotificationType(): NotificationType[] { const sysAdminAllowNotificationTypes = new Set([ NotificationType.ENTITIES_LIMIT, + NotificationType.ENTITIES_LIMIT_INCREASE_REQUEST, NotificationType.API_USAGE_LIMIT, NotificationType.NEW_PLATFORM_VERSION, NotificationType.RATE_LIMITS, diff --git a/ui-ngx/src/app/shared/components/dialog/entity-limit-exceeded-dialog.component.html b/ui-ngx/src/app/shared/components/dialog/entity-limit-exceeded-dialog.component.html new file mode 100644 index 0000000000..3bc2591c24 --- /dev/null +++ b/ui-ngx/src/app/shared/components/dialog/entity-limit-exceeded-dialog.component.html @@ -0,0 +1,40 @@ + +
+ +
+
{{ 'entity.limit-reached' | translate }}
+
+
+
+
+ + +
+
+ {{ 'entity.request-sysadmin-text' | translate }} + {{ 'entity.login-here' | translate }} + {{ 'entity.to-increase-limit' | translate }} +
+
diff --git a/ui-ngx/src/app/shared/components/dialog/entity-limit-exceeded-dialog.component.scss b/ui-ngx/src/app/shared/components/dialog/entity-limit-exceeded-dialog.component.scss new file mode 100644 index 0000000000..7d55e824b2 --- /dev/null +++ b/ui-ngx/src/app/shared/components/dialog/entity-limit-exceeded-dialog.component.scss @@ -0,0 +1,97 @@ +/** + * 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'; + +.entity-limit-exceeded-dialog { + max-width: 100%; + height: 100%; + + @media #{$mat-gt-xs} { + max-width: 500px; + max-height: 80vh; + } + + position: relative; + padding: 40px 40px 24px 40px; + display: flex; + flex-direction: column; + gap: 20px; + + .close-btn { + position: absolute; + top: 8px; + right: 8px; + color: rgba(0, 0, 0, 0.54); + } + + .entity-limit-exceeded-bg { + position: relative; + width: 100%; + height: 140px; + &:before { + content: ""; + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: $tb-primary-color; + mask-image: url(/assets/entity-limit-reached.svg); + -webkit-mask-image: url(/assets/entity-limit-reached.svg); + mask-repeat: no-repeat; + -webkit-mask-repeat: no-repeat; + mask-size: contain; + -webkit-mask-size: contain; + mask-position: center; + -webkit-mask-position: center; + } + } + + .entity-limit-exceeded-title { + font-size: 24px; + font-style: normal; + font-weight: 500; + line-height: 32px; + color: rgba(0, 0, 0, 0.87); + text-align: center; + } + + .entity-limit-exceeded-container { + flex: 1; + overflow-y: auto; + display: flex; + flex-direction: column; + justify-content: center; + .entity-limit-exceeded-text { + font-size: 16px; + font-style: normal; + font-weight: 400; + line-height: 24px; + color: rgba(0, 0, 0, 0.76); + text-align: center; + } + } + + .request-entity-limit-increase-sysadmin-prompt { + text-align: center; + font-size: 14px; + font-style: normal; + font-weight: 400; + line-height: 20px; + color: rgba(0, 0, 0, 0.54); + } + +} diff --git a/ui-ngx/src/app/shared/components/dialog/entity-limit-exceeded-dialog.component.ts b/ui-ngx/src/app/shared/components/dialog/entity-limit-exceeded-dialog.component.ts new file mode 100644 index 0000000000..7efabb0fcb --- /dev/null +++ b/ui-ngx/src/app/shared/components/dialog/entity-limit-exceeded-dialog.component.ts @@ -0,0 +1,100 @@ +/// +/// 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 { EntityType, entityTypeTranslations } from '@shared/models/entity-type.models'; +import { Component, Inject, ViewEncapsulation } 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 { TranslateService } from '@ngx-translate/core'; +import { DialogService } from '@core/services/dialog.service'; +import { AuthService } from '@core/auth/auth.service'; +import { TenantService } from '@core/http/tenant.service'; +import { getCurrentAuthUser } from '@core/auth/auth.selectors'; +import { NotificationService } from '@core/http/notification.service'; + +export interface EntityLimitExceededDialogData { + entityType: EntityType; + limit: number; +} + +// @dynamic +@Component({ + selector: 'tb-entity-limit-exceeded-dialog', + templateUrl: './entity-limit-exceeded-dialog.component.html', + styleUrls: ['./entity-limit-exceeded-dialog.component.scss'], + encapsulation: ViewEncapsulation.None +}) +export class EntityLimitExceededDialogComponent extends DialogComponent { + + limitReachedText: string; + + constructor(protected store: Store, + protected router: Router, + @Inject(MAT_DIALOG_DATA) public data: EntityLimitExceededDialogData, + public dialogRef: MatDialogRef, + private authService: AuthService, + private dialogs: DialogService, + private translate: TranslateService, + private tenantService: TenantService, + private notificationService: NotificationService) { + super(store, router, dialogRef); + + let entitiesText: string; + if (data.limit > 1) { + entitiesText = data.limit + ' ' + (this.translate.instant(entityTypeTranslations.get(data.entityType).typePlural) as string).toLowerCase(); + } else { + entitiesText = '1 ' + (this.translate.instant(entityTypeTranslations.get(data.entityType).type) as string).toLowerCase(); + } + this.limitReachedText = this.translate.instant('entity.limit-reached-text', { entities: entitiesText, entity: (this.translate.instant(entityTypeTranslations.get(data.entityType).type) as string).toLowerCase() }); + } + + cancel(): void { + this.dialogRef.close(); + } + + requestLimitIncrease($event: Event) { + if ($event) { + $event.stopPropagation(); + } + this.notificationService.sendEntitiesLimitIncreaseRequest(this.data.entityType).subscribe( + () => { + this.dialogRef.close(); + this.dialogs.alert( + this.translate.instant('entity.increase-limit-request-sent-title'), + this.translate.instant('entity.increase-limit-request-sent-text'), + this.translate.instant('action.close') + ); + } + ); + } + + loginAsSysAdmin($event: Event) { + if ($event) { + $event.preventDefault(); + $event.stopPropagation(); + } + this.tenantService.getTenant(getCurrentAuthUser(this.store).tenantId).subscribe( + (tenant) => { + this.authService.redirectUrl = `/tenantProfiles/${tenant.tenantProfileId.id}`; + this.authService.logout(); + } + ); + } + +} diff --git a/ui-ngx/src/app/shared/components/notification/notification.component.ts b/ui-ngx/src/app/shared/components/notification/notification.component.ts index 8f7d9365f2..77be7ff7cd 100644 --- a/ui-ngx/src/app/shared/components/notification/notification.component.ts +++ b/ui-ngx/src/app/shared/components/notification/notification.component.ts @@ -157,6 +157,8 @@ export class NotificationComponent implements OnInit { return {color: AlarmSeverityNotificationColors.get(this.notification.info.alarmSeverity)}; } else if (this.notification.type === NotificationType.RULE_ENGINE_COMPONENT_LIFECYCLE_EVENT) { return {color: '#D12730'}; + } else if (this.notification.type === NotificationType.ENTITIES_LIMIT_INCREASE_REQUEST) { + return {color: '#305680'}; } return null; } diff --git a/ui-ngx/src/app/shared/models/constants.ts b/ui-ngx/src/app/shared/models/constants.ts index 68391a42f3..72315e8ed5 100644 --- a/ui-ngx/src/app/shared/models/constants.ts +++ b/ui-ngx/src/app/shared/models/constants.ts @@ -31,6 +31,7 @@ export const Constants = { itemNotFound: 32, tooManyRequests: 33, tooManyUpdates: 34, + entitiesLimitExceeded: 41, passwordViolation: 45 }, entryPoints: { diff --git a/ui-ngx/src/app/shared/models/notification.models.ts b/ui-ngx/src/app/shared/models/notification.models.ts index 63d7edd2a2..f9abf6462b 100644 --- a/ui-ngx/src/app/shared/models/notification.models.ts +++ b/ui-ngx/src/app/shared/models/notification.models.ts @@ -526,6 +526,7 @@ export enum NotificationType { ALARM_ASSIGNMENT = 'ALARM_ASSIGNMENT', RULE_ENGINE_COMPONENT_LIFECYCLE_EVENT = 'RULE_ENGINE_COMPONENT_LIFECYCLE_EVENT', ENTITIES_LIMIT = 'ENTITIES_LIMIT', + ENTITIES_LIMIT_INCREASE_REQUEST = 'ENTITIES_LIMIT_INCREASE_REQUEST', API_USAGE_LIMIT = 'API_USAGE_LIMIT', NEW_PLATFORM_VERSION = 'NEW_PLATFORM_VERSION', RULE_NODE = 'RULE_NODE', @@ -544,6 +545,7 @@ export const NotificationTypeIcons = new Map([ [NotificationType.ALARM_ASSIGNMENT, 'assignment_turned_in'], [NotificationType.RULE_ENGINE_COMPONENT_LIFECYCLE_EVENT, 'settings_ethernet'], [NotificationType.ENTITIES_LIMIT, 'data_thresholding'], + [NotificationType.ENTITIES_LIMIT_INCREASE_REQUEST, 'mdi:file-cog'], [NotificationType.API_USAGE_LIMIT, 'insert_chart'], [NotificationType.TASK_PROCESSING_FAILURE, 'warning'], [NotificationType.RESOURCES_SHORTAGE, 'warning'] @@ -623,6 +625,12 @@ export const NotificationTemplateTypeTranslateMap = new Map + + + + + + + + + + + + + + + + + + + diff --git a/ui-ngx/src/assets/help/en_US/notification/entities_limit_increase_request.md b/ui-ngx/src/assets/help/en_US/notification/entities_limit_increase_request.md new file mode 100644 index 0000000000..388f0f532e --- /dev/null +++ b/ui-ngx/src/assets/help/en_US/notification/entities_limit_increase_request.md @@ -0,0 +1,46 @@ +#### Entity count limit increase request notification templatization + +
+
+ +Notification subject and message fields support templatization. +The list of available templatization parameters depends on the template type. +See the available types and parameters below: + +Available template parameters: + +* `entityType` - one of: 'Device', 'Asset', 'Customer', 'User', 'Dashboard', 'Rule chain', 'Edge'; +* `userEmail` - email of the user who sends the request; +* `increaseLimitActionLabel` - label of the button used to open Limits Management page, for ex: 'Set new limit'; +* `increaseLimitLink` - link to the Limits Management page; +* `baseUrl` - used to construct the full URL for the Limits Management page in email notifications; + +Parameter names must be wrapped using `${...}`. For example: `${userEmail}`. +You may also modify the value of the parameter with one of the suffixes: + +* `upperCase`, for example - `${userEmail:upperCase}` +* `lowerCase`, for example - `${userEmail:lowerCase}` +* `capitalize`, for example - `${userEmail:capitalize}` + +
+ +##### Examples + +Let's assume the notification about the increasing limit of the maximum allowed devices for the tenant. +The following template: + +```text +${userEmail} has reached the maximum number of ${entityType:lowerCase}s allowed and is requesting an increase to the ${entityType:lowerCase} limit. +{:copy-code} +``` + +will be transformed to: + +```text +johndoe@company.com has reached the maximum number of devices allowed and is requesting an increase to the device limit. +``` + +
+ +
+
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 477c1fa012..da71326f10 100644 --- a/ui-ngx/src/assets/locale/locale.constant-en_US.json +++ b/ui-ngx/src/assets/locale/locale.constant-en_US.json @@ -3174,7 +3174,15 @@ "list-of-mobile-apps": "{ count, plural, =1 {One Mobile application} other {List of # Mobile applications} }", "type-mobile-app-bundle": "Mobile bundle", "type-mobile-app-bundles": "Mobile bundles", - "list-of-mobile-app-bundles": "{ count, plural, =1 {One mobile bundle} other {List of # mobile bundles} }" + "list-of-mobile-app-bundles": "{ count, plural, =1 {One mobile bundle} other {List of # mobile bundles} }", + "limit-reached": "Limit reached", + "limit-reached-text": "You have reached the limit of {{ entities }}. To add more, please ask your System Administrator to increase your {{ entity }} limit.", + "request-limit-increase": "Request limit increase", + "request-sysadmin-text": "Are you the System Administrator?", + "login-here": "Login here", + "to-increase-limit": "to increase limit.", + "increase-limit-request-sent-title": "We have sent an automated request to your System Administrator to increase the limit", + "increase-limit-request-sent-text": "Please allow some time for them to review the request and update the settings. You may need to refresh this page to see the changes." }, "entity-field": { "created-time": "Created time", @@ -4700,6 +4708,7 @@ "api-usage-limit": "API usage limit", "device-activity": "Device activity", "entities-limit": "Entities limit", + "entities-limit-increase-request": "Entities limit increase request", "entity-action": "Entity action", "general": "General", "rule-engine-lifecycle-event": "Rule engine lifecycle event", From 846deebb43c282d0ca5cd0d1ccfb3efa9e9009b1 Mon Sep 17 00:00:00 2001 From: Igor Kulikov Date: Tue, 9 Dec 2025 12:11:05 +0200 Subject: [PATCH 2/5] Minor changes --- .../server/dao/notification/DefaultNotifications.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dao/src/main/java/org/thingsboard/server/dao/notification/DefaultNotifications.java b/dao/src/main/java/org/thingsboard/server/dao/notification/DefaultNotifications.java index 7e82ae9472..5e5092eb5b 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/notification/DefaultNotifications.java +++ b/dao/src/main/java/org/thingsboard/server/dao/notification/DefaultNotifications.java @@ -84,7 +84,7 @@ public class DefaultNotifications { .build(); public static final DefaultNotification entitiesLimitForSysadmin = DefaultNotification.builder() - .name("Entities count limit notification for sysadmin") + .name("Entities count limit notification to sysadmin") .type(NotificationType.ENTITIES_LIMIT) .subject("${entityType}s limit will be reached soon for tenant ${tenantName}") .text("${entityType}s usage: ${currentCount}/${limit} (${percents}%)") From a44d2618a1886557288eee6c0947248e671d9656 Mon Sep 17 00:00:00 2001 From: Igor Kulikov Date: Tue, 9 Dec 2025 13:41:41 +0200 Subject: [PATCH 3/5] Update notification template --- .../server/dao/notification/DefaultNotifications.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dao/src/main/java/org/thingsboard/server/dao/notification/DefaultNotifications.java b/dao/src/main/java/org/thingsboard/server/dao/notification/DefaultNotifications.java index 5e5092eb5b..32efe64d25 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/notification/DefaultNotifications.java +++ b/dao/src/main/java/org/thingsboard/server/dao/notification/DefaultNotifications.java @@ -106,7 +106,7 @@ public class DefaultNotifications { .build()) .build(); public static final DefaultNotification entitiesLimitIncreaseRequest = DefaultNotification.builder() - .name("Entities limit increase request for sysadmin") + .name("Entities limit increase request") .type(NotificationType.ENTITIES_LIMIT_INCREASE_REQUEST) .subject("${entityType} limit increase request") .text("${userEmail} has reached the maximum number of ${entityType:lowerCase}s allowed and is requesting an increase to the ${entityType:lowerCase} limit.") From 4c9189023c546095164d3212401bcc157815ee32 Mon Sep 17 00:00:00 2001 From: Igor Kulikov Date: Tue, 9 Dec 2025 13:56:51 +0200 Subject: [PATCH 4/5] Revert --- .../server/dao/notification/DefaultNotifications.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dao/src/main/java/org/thingsboard/server/dao/notification/DefaultNotifications.java b/dao/src/main/java/org/thingsboard/server/dao/notification/DefaultNotifications.java index 32efe64d25..920fd8c144 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/notification/DefaultNotifications.java +++ b/dao/src/main/java/org/thingsboard/server/dao/notification/DefaultNotifications.java @@ -84,7 +84,7 @@ public class DefaultNotifications { .build(); public static final DefaultNotification entitiesLimitForSysadmin = DefaultNotification.builder() - .name("Entities count limit notification to sysadmin") + .name("Entities count limit notification for sysadmin") .type(NotificationType.ENTITIES_LIMIT) .subject("${entityType}s limit will be reached soon for tenant ${tenantName}") .text("${entityType}s usage: ${currentCount}/${limit} (${percents}%)") From 551574af3b438681322d9f6c3a3e07793a3e8b28 Mon Sep 17 00:00:00 2001 From: Igor Kulikov Date: Tue, 9 Dec 2025 16:19:12 +0200 Subject: [PATCH 5/5] Improve 'Entities Limit Exceeded' Error and Notification Template. --- .../server/controller/BaseController.java | 4 +- ...ngsboardEntitiesLimitExceededResponse.java | 50 +++++++++++++++++++ .../exception/ThingsboardErrorResponse.java | 31 +----------- .../ThingsboardErrorResponseHandler.java | 23 ++++----- .../transport/DefaultTransportApiService.java | 4 +- ...va => EntitiesLimitExceededException.java} | 4 +- .../notification/DefaultNotifications.java | 25 +++++++++- .../server/dao/service/DataValidator.java | 4 +- ui-ngx/src/app/shared/models/constants.ts | 1 + .../assets/locale/locale.constant-en_US.json | 3 +- 10 files changed, 98 insertions(+), 51 deletions(-) create mode 100644 application/src/main/java/org/thingsboard/server/exception/ThingsboardEntitiesLimitExceededResponse.java rename dao/src/main/java/org/thingsboard/server/dao/exception/{EntitiesLimitException.java => EntitiesLimitExceededException.java} (87%) diff --git a/application/src/main/java/org/thingsboard/server/controller/BaseController.java b/application/src/main/java/org/thingsboard/server/controller/BaseController.java index a58ae911a2..83603f7ea5 100644 --- a/application/src/main/java/org/thingsboard/server/controller/BaseController.java +++ b/application/src/main/java/org/thingsboard/server/controller/BaseController.java @@ -151,7 +151,7 @@ import org.thingsboard.server.dao.domain.DomainService; import org.thingsboard.server.dao.edge.EdgeService; import org.thingsboard.server.dao.entityview.EntityViewService; import org.thingsboard.server.dao.exception.DataValidationException; -import org.thingsboard.server.dao.exception.EntitiesLimitException; +import org.thingsboard.server.dao.exception.EntitiesLimitExceededException; import org.thingsboard.server.dao.exception.IncorrectParameterException; import org.thingsboard.server.dao.job.JobService; import org.thingsboard.server.dao.mobile.MobileAppBundleService; @@ -453,7 +453,7 @@ public abstract class BaseController { } if (exception instanceof ThingsboardException) { return (ThingsboardException) exception; - } else if (exception instanceof EntitiesLimitException) { + } else if (exception instanceof EntitiesLimitExceededException) { return new ThingsboardException(exception, ThingsboardErrorCode.ENTITIES_LIMIT_EXCEEDED); } else if (exception instanceof IllegalArgumentException || exception instanceof IncorrectParameterException || exception instanceof DataValidationException || cause instanceof IncorrectParameterException) { diff --git a/application/src/main/java/org/thingsboard/server/exception/ThingsboardEntitiesLimitExceededResponse.java b/application/src/main/java/org/thingsboard/server/exception/ThingsboardEntitiesLimitExceededResponse.java new file mode 100644 index 0000000000..c1faabb569 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/exception/ThingsboardEntitiesLimitExceededResponse.java @@ -0,0 +1,50 @@ +/** + * 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.exception; + +import io.swagger.v3.oas.annotations.media.Schema; +import org.springframework.http.HttpStatus; +import org.thingsboard.server.common.data.EntityType; +import org.thingsboard.server.common.data.exception.ThingsboardErrorCode; + +@Schema +public class ThingsboardEntitiesLimitExceededResponse extends ThingsboardErrorResponse { + + private final EntityType entityType; + + private final Long limit; + + protected ThingsboardEntitiesLimitExceededResponse(String message, EntityType entityType, Long limit) { + super(message, ThingsboardErrorCode.ENTITIES_LIMIT_EXCEEDED, HttpStatus.FORBIDDEN); + this.entityType = entityType; + this.limit = limit; + } + + public static ThingsboardEntitiesLimitExceededResponse of(final String message, final EntityType entityType, final Long limit) { + return new ThingsboardEntitiesLimitExceededResponse(message, entityType, limit); + } + + @Schema(description = "Entity type", accessMode = Schema.AccessMode.READ_ONLY) + public EntityType getEntityType() { + return entityType; + } + + @Schema(description = "Limit", accessMode = Schema.AccessMode.READ_ONLY) + public Long getLimit() { + return limit; + } + +} diff --git a/application/src/main/java/org/thingsboard/server/exception/ThingsboardErrorResponse.java b/application/src/main/java/org/thingsboard/server/exception/ThingsboardErrorResponse.java index f38b86e394..8a564dedca 100644 --- a/application/src/main/java/org/thingsboard/server/exception/ThingsboardErrorResponse.java +++ b/application/src/main/java/org/thingsboard/server/exception/ThingsboardErrorResponse.java @@ -15,10 +15,8 @@ */ package org.thingsboard.server.exception; -import com.fasterxml.jackson.databind.JsonNode; import io.swagger.v3.oas.annotations.media.Schema; import org.springframework.http.HttpStatus; -import org.thingsboard.server.common.data.EntityType; import org.thingsboard.server.common.data.exception.ThingsboardErrorCode; @Schema @@ -34,19 +32,9 @@ public class ThingsboardErrorResponse { private final long timestamp; - private EntityType entityType; - - private Long limit; - protected ThingsboardErrorResponse(final String message, final ThingsboardErrorCode errorCode, HttpStatus status) { - this(message, errorCode, null, null, status); - } - - protected ThingsboardErrorResponse(final String message, final ThingsboardErrorCode errorCode, EntityType entityType, Long limit, HttpStatus status) { this.message = message; this.errorCode = errorCode; - this.entityType = entityType; - this.limit = limit; this.status = status; this.timestamp = System.currentTimeMillis(); } @@ -55,14 +43,6 @@ public class ThingsboardErrorResponse { return new ThingsboardErrorResponse(message, errorCode, status); } - public static ThingsboardErrorResponse ofEntityLimitExceeded(final String message, - EntityType entityType, - Long limit, - HttpStatus status) { - return new ThingsboardErrorResponse(message, ThingsboardErrorCode.ENTITIES_LIMIT_EXCEEDED, - entityType, limit, status); - } - @Schema(description = "HTTP Response Status Code", example = "401", accessMode = Schema.AccessMode.READ_ONLY) public Integer getStatus() { return status.value(); @@ -84,7 +64,8 @@ public class ThingsboardErrorResponse { "\n\n* `32` - Item not found (HTTP: 404 - Not Found)" + "\n\n* `33` - Too many requests (HTTP: 429 - Too Many Requests)" + "\n\n* `34` - Too many updates (Too many updates over Websocket session)" + - "\n\n* `40` - Subscription violation (HTTP: 403 - Forbidden)", + "\n\n* `40` - Subscription violation (HTTP: 403 - Forbidden)" + + "\n\n* `41` - Entities limit exceeded (HTTP: 403 - Forbidden)", example = "10", type = "integer", accessMode = Schema.AccessMode.READ_ONLY) public ThingsboardErrorCode getErrorCode() { @@ -95,12 +76,4 @@ public class ThingsboardErrorResponse { public long getTimestamp() { return timestamp; } - - public EntityType getEntityType() { - return entityType; - } - - public Long getLimit() { - return limit; - } } diff --git a/application/src/main/java/org/thingsboard/server/exception/ThingsboardErrorResponseHandler.java b/application/src/main/java/org/thingsboard/server/exception/ThingsboardErrorResponseHandler.java index e6e27e6a75..3ef24e2db9 100644 --- a/application/src/main/java/org/thingsboard/server/exception/ThingsboardErrorResponseHandler.java +++ b/application/src/main/java/org/thingsboard/server/exception/ThingsboardErrorResponseHandler.java @@ -52,7 +52,7 @@ import org.thingsboard.server.common.data.exception.ThingsboardErrorCode; import org.thingsboard.server.common.data.exception.ThingsboardException; import org.thingsboard.server.common.msg.tools.MaxPayloadSizeExceededException; import org.thingsboard.server.common.msg.tools.TbRateLimitsException; -import org.thingsboard.server.dao.exception.EntitiesLimitException; +import org.thingsboard.server.dao.exception.EntitiesLimitExceededException; import org.thingsboard.server.service.security.exception.AuthMethodNotSupportedException; import org.thingsboard.server.service.security.exception.JwtExpiredTokenException; import org.thingsboard.server.service.security.exception.UserPasswordExpiredException; @@ -147,10 +147,10 @@ public class ThingsboardErrorResponseHandler extends ResponseEntityExceptionHand } else if (thingsboardException.getErrorCode() == ThingsboardErrorCode.DATABASE) { handleDatabaseException(thingsboardException.getCause(), response); } else if (thingsboardException.getErrorCode() == ThingsboardErrorCode.ENTITIES_LIMIT_EXCEEDED) { - if (thingsboardException.getCause() instanceof EntitiesLimitException entitiesLimitException) { - handleEntitiesLimitException(entitiesLimitException, response); + if (thingsboardException.getCause() instanceof EntitiesLimitExceededException entitiesLimitExceededException) { + handleEntitiesLimitExceededException(entitiesLimitExceededException, response); } else { - handleEntitiesLimitException(thingsboardException, response); + handleEntitiesLimitExceededException(thingsboardException, response); } } else { handleThingsboardException(thingsboardException, response); @@ -227,19 +227,18 @@ public class ThingsboardErrorResponseHandler extends ResponseEntityExceptionHand writeResponse(errorResponse, response); } - private void handleEntitiesLimitException(ThingsboardException entitiesLimitException, HttpServletResponse response) throws IOException { + private void handleEntitiesLimitExceededException(ThingsboardException entitiesLimitExceededException, HttpServletResponse response) throws IOException { response.setStatus(HttpStatus.FORBIDDEN.value()); JacksonUtil.writeValue(response.getWriter(), - JacksonUtil.fromBytes(((HttpClientErrorException) entitiesLimitException.getCause()).getResponseBodyAsByteArray(), Object.class)); + JacksonUtil.fromBytes(((HttpClientErrorException) entitiesLimitExceededException.getCause()).getResponseBodyAsByteArray(), Object.class)); } - private void handleEntitiesLimitException(EntitiesLimitException entitiesLimitException, HttpServletResponse response) throws IOException { - EntityType entityType = entitiesLimitException.getEntityType(); - Long limit = entitiesLimitException.getLimit(); - HttpStatus status = HttpStatus.FORBIDDEN; - response.setStatus(status.value()); + private void handleEntitiesLimitExceededException(EntitiesLimitExceededException entitiesLimitExceededException, HttpServletResponse response) throws IOException { + EntityType entityType = entitiesLimitExceededException.getEntityType(); + Long limit = entitiesLimitExceededException.getLimit(); + response.setStatus(HttpStatus.FORBIDDEN.value()); JacksonUtil.writeValue(response.getWriter(), - ThingsboardErrorResponse.ofEntityLimitExceeded(entitiesLimitException.getMessage(), entityType, limit, status)); + ThingsboardEntitiesLimitExceededResponse.of(entitiesLimitExceededException.getMessage(), entityType, limit)); } private void handleAccessDeniedException(HttpServletResponse response) throws IOException { diff --git a/application/src/main/java/org/thingsboard/server/service/transport/DefaultTransportApiService.java b/application/src/main/java/org/thingsboard/server/service/transport/DefaultTransportApiService.java index 7fee411704..096c1a814a 100644 --- a/application/src/main/java/org/thingsboard/server/service/transport/DefaultTransportApiService.java +++ b/application/src/main/java/org/thingsboard/server/service/transport/DefaultTransportApiService.java @@ -76,7 +76,7 @@ import org.thingsboard.server.dao.device.provision.ProvisionFailedException; import org.thingsboard.server.dao.device.provision.ProvisionRequest; import org.thingsboard.server.dao.device.provision.ProvisionResponse; import org.thingsboard.server.dao.device.provision.ProvisionResponseStatus; -import org.thingsboard.server.dao.exception.EntitiesLimitException; +import org.thingsboard.server.dao.exception.EntitiesLimitExceededException; import org.thingsboard.server.dao.ota.OtaPackageService; import org.thingsboard.server.dao.queue.QueueService; import org.thingsboard.server.dao.relation.RelationService; @@ -398,7 +398,7 @@ public class DefaultTransportApiService implements TransportApiService { } catch (JsonProcessingException e) { log.warn("[{}] Failed to lookup device by gateway id and name: [{}]", gatewayId, requestMsg.getDeviceName(), e); throw new RuntimeException(e); - } catch (EntitiesLimitException e) { + } catch (EntitiesLimitExceededException e) { log.warn("[{}][{}] API limit exception: [{}]", e.getTenantId(), gatewayId, e.getMessage()); return TransportApiResponseMsg.newBuilder() .setGetOrCreateDeviceResponseMsg( diff --git a/dao/src/main/java/org/thingsboard/server/dao/exception/EntitiesLimitException.java b/dao/src/main/java/org/thingsboard/server/dao/exception/EntitiesLimitExceededException.java similarity index 87% rename from dao/src/main/java/org/thingsboard/server/dao/exception/EntitiesLimitException.java rename to dao/src/main/java/org/thingsboard/server/dao/exception/EntitiesLimitExceededException.java index 559ef94e45..b2907c9104 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/exception/EntitiesLimitException.java +++ b/dao/src/main/java/org/thingsboard/server/dao/exception/EntitiesLimitExceededException.java @@ -19,7 +19,7 @@ import lombok.Getter; import org.thingsboard.server.common.data.EntityType; import org.thingsboard.server.common.data.id.TenantId; -public class EntitiesLimitException extends DataValidationException { +public class EntitiesLimitExceededException extends DataValidationException { private static final long serialVersionUID = -9211462514373279196L; @Getter @@ -30,7 +30,7 @@ public class EntitiesLimitException extends DataValidationException { @Getter private final long limit; - public EntitiesLimitException(TenantId tenantId, EntityType entityType, long limit) { + public EntitiesLimitExceededException(TenantId tenantId, EntityType entityType, long limit) { super(entityType.getNormalName() + "s limit reached"); this.tenantId = tenantId; this.entityType = entityType; diff --git a/dao/src/main/java/org/thingsboard/server/dao/notification/DefaultNotifications.java b/dao/src/main/java/org/thingsboard/server/dao/notification/DefaultNotifications.java index 920fd8c144..e305ca556c 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/notification/DefaultNotifications.java +++ b/dao/src/main/java/org/thingsboard/server/dao/notification/DefaultNotifications.java @@ -113,7 +113,30 @@ public class DefaultNotifications { .button("${increaseLimitActionLabel}").link("${increaseLimitLink}") .emailTemplate(DefaultEmailTemplate.builder() .subject("${entityType} limit increase request") - .body("${userEmail} has reached the maximum number of ${entityType:lowerCase}s allowed and is requesting an increase to the ${entityType:lowerCase} limit.
${increaseLimitActionLabel}") + .body(""" + + + + + + +
+ + + + + + + + + + + + +
${entityType} limit increase request
${userEmail} has reached the maximum number of ${entityType:lowerCase}s allowed and is requesting an increase to the ${entityType:lowerCase} limit.
+ ${increaseLimitActionLabel} +
+
""") .build()) .build(); public static final DefaultNotification apiFeatureWarningForSysadmin = DefaultNotification.builder() diff --git a/dao/src/main/java/org/thingsboard/server/dao/service/DataValidator.java b/dao/src/main/java/org/thingsboard/server/dao/service/DataValidator.java index ffa88fe39c..89ab237ed3 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/service/DataValidator.java +++ b/dao/src/main/java/org/thingsboard/server/dao/service/DataValidator.java @@ -28,7 +28,7 @@ import org.thingsboard.server.common.data.id.EntityId; import org.thingsboard.server.common.data.id.TenantId; import org.thingsboard.server.dao.TenantEntityWithDataDao; import org.thingsboard.server.dao.exception.DataValidationException; -import org.thingsboard.server.dao.exception.EntitiesLimitException; +import org.thingsboard.server.dao.exception.EntitiesLimitExceededException; import org.thingsboard.server.dao.usagerecord.ApiLimitService; import java.util.HashSet; @@ -124,7 +124,7 @@ public abstract class DataValidator> { EntityType entityType) { if (!apiLimitService.checkEntitiesLimit(tenantId, entityType)) { long limit = apiLimitService.getLimit(tenantId, profileConfiguration -> profileConfiguration.getEntitiesLimit(entityType)); - throw new EntitiesLimitException(tenantId, entityType, limit); + throw new EntitiesLimitExceededException(tenantId, entityType, limit); } } diff --git a/ui-ngx/src/app/shared/models/constants.ts b/ui-ngx/src/app/shared/models/constants.ts index 72315e8ed5..2788aa52df 100644 --- a/ui-ngx/src/app/shared/models/constants.ts +++ b/ui-ngx/src/app/shared/models/constants.ts @@ -53,6 +53,7 @@ export const serverErrorCodesTranslations = new Map([ [Constants.serverErrorCode.itemNotFound, 'server-error.item-not-found'], [Constants.serverErrorCode.tooManyRequests, 'server-error.too-many-requests'], [Constants.serverErrorCode.tooManyUpdates, 'server-error.too-many-updates'], + [Constants.serverErrorCode.entitiesLimitExceeded, 'server-error.entities-limit-exceeded'], ]); export const MediaBreakpoints = { 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 7a7b5bff62..27bd757078 100644 --- a/ui-ngx/src/assets/locale/locale.constant-en_US.json +++ b/ui-ngx/src/assets/locale/locale.constant-en_US.json @@ -6140,7 +6140,8 @@ "bad-request-params": "Bad request params", "item-not-found": "Item not found", "too-many-requests": "Too many requests", - "too-many-updates": "Too many updates" + "too-many-updates": "Too many updates", + "entities-limit-exceeded": "Entities limit exceeded" }, "tenant": { "tenant": "Tenant",