diff --git a/application/src/main/data/json/system/widget_bundles/home_page_widgets.json b/application/src/main/data/json/system/widget_bundles/home_page_widgets.json new file mode 100644 index 0000000000..fc5b13154c --- /dev/null +++ b/application/src/main/data/json/system/widget_bundles/home_page_widgets.json @@ -0,0 +1,50 @@ +{ + "widgetsBundle": { + "alias": "home_page_widgets", + "title": "Home page widgets", + "image": null, + "description": null, + "externalId": null, + "name": "Home page widgets" + }, + "widgetTypes": [ + { + "alias": "documentation_links", + "name": "Documentation links", + "image": null, + "description": null, + "descriptor": { + "type": "static", + "sizeX": 7.5, + "sizeY": 3, + "resources": [], + "templateHtml": "\n\n", + "templateCss": "", + "controllerScript": "self.onInit = function() {\n}", + "settingsSchema": "", + "dataKeySettingsSchema": "", + "settingsDirective": "tb-doc-links-widget-settings", + "defaultConfig": "{\"showTitle\":false,\"backgroundColor\":\"rgb(255, 255, 255)\",\"color\":\"rgba(0, 0, 0, 0.87)\",\"padding\":\"8px\",\"settings\":{\"columns\":3},\"title\":\"Documentation links\",\"dropShadow\":true}" + } + }, + { + "alias": "getting_started", + "name": "Getting started", + "image": null, + "description": null, + "descriptor": { + "type": "static", + "sizeX": 7.5, + "sizeY": 6.5, + "resources": [], + "templateHtml": "\n\n", + "templateCss": "", + "controllerScript": "self.onInit = function() {\n}", + "settingsSchema": "", + "dataKeySettingsSchema": "", + "settingsDirective": "", + "defaultConfig": "{\"showTitle\":false,\"backgroundColor\":\"rgb(255, 255, 255)\",\"color\":\"rgba(0, 0, 0, 0.87)\",\"padding\":\"8px\",\"settings\":{},\"title\":\"Getting started\",\"dropShadow\":true}" + } + } + ] +} \ No newline at end of file diff --git a/application/src/main/data/upgrade/3.4.4/schema_update.sql b/application/src/main/data/upgrade/3.4.4/schema_update.sql index 985dea197c..3776eebc12 100644 --- a/application/src/main/data/upgrade/3.4.4/schema_update.sql +++ b/application/src/main/data/upgrade/3.4.4/schema_update.sql @@ -609,4 +609,4 @@ BEGIN END $$; --- TTL DROP PARTITIONS FUNCTIONS UPDATE END \ No newline at end of file +-- TTL DROP PARTITIONS FUNCTIONS UPDATE END 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 04bda8f039..15342d16a3 100644 --- a/application/src/main/java/org/thingsboard/server/controller/BaseController.java +++ b/application/src/main/java/org/thingsboard/server/controller/BaseController.java @@ -102,7 +102,6 @@ import org.thingsboard.server.common.data.rpc.Rpc; 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.common.data.settings.UserDashboardAction; import org.thingsboard.server.common.data.util.ThrowingBiFunction; import org.thingsboard.server.common.data.widget.WidgetTypeDetails; import org.thingsboard.server.common.data.widget.WidgetsBundle; @@ -129,12 +128,12 @@ import org.thingsboard.server.dao.queue.QueueService; import org.thingsboard.server.dao.relation.RelationService; import org.thingsboard.server.dao.rpc.RpcService; import org.thingsboard.server.dao.rule.RuleChainService; +import org.thingsboard.server.dao.service.ConstraintValidator; import org.thingsboard.server.dao.service.Validator; import org.thingsboard.server.dao.tenant.TbTenantProfileCache; import org.thingsboard.server.dao.tenant.TenantProfileService; import org.thingsboard.server.dao.tenant.TenantService; import org.thingsboard.server.dao.user.UserService; -import org.thingsboard.server.dao.user.UserSettingsService; import org.thingsboard.server.dao.widget.WidgetTypeService; import org.thingsboard.server.dao.widget.WidgetsBundleService; import org.thingsboard.server.exception.ThingsboardErrorResponseHandler; @@ -163,7 +162,9 @@ import org.thingsboard.server.service.telemetry.TelemetrySubscriptionService; import javax.mail.MessagingException; import javax.servlet.http.HttpServletResponse; +import javax.validation.ConstraintViolation; import java.util.List; +import java.util.Objects; import java.util.Optional; import java.util.Set; import java.util.UUID; @@ -392,16 +393,19 @@ public abstract class BaseController { * Handles validation error for controller method arguments annotated with @{@link javax.validation.Valid} * */ @ExceptionHandler(MethodArgumentNotValidException.class) - public void handleValidationError(MethodArgumentNotValidException e, HttpServletResponse response) { - String errorMessage = "Validation error: " + e.getFieldErrors().stream() + public void handleValidationError(MethodArgumentNotValidException validationError, HttpServletResponse response) { + List> constraintsViolations = validationError.getFieldErrors().stream() .map(fieldError -> { - String property = fieldError.getField(); - if (property.equals("valid") || StringUtils.endsWith(property, ".valid")) { // when custom @AssertTrue is used - property = ""; + try { + return (ConstraintViolation) fieldError.unwrap(ConstraintViolation.class); + } catch (Exception e) { + log.warn("FieldError source is not of type ConstraintViolation"); + return null; // should not happen } - return (!property.isEmpty() ? (property + " ") : "") + fieldError.getDefaultMessage(); }) - .collect(Collectors.joining(", ")); + .filter(Objects::nonNull) + .collect(Collectors.toList()); + String errorMessage = "Validation error: " + ConstraintValidator.getErrorMessage(constraintsViolations); ThingsboardException thingsboardException = new ThingsboardException(errorMessage, ThingsboardErrorCode.BAD_REQUEST_PARAMS); handleControllerException(thingsboardException, response); } 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 6c27887e1a..23aaba0e48 100644 --- a/application/src/main/java/org/thingsboard/server/controller/NotificationController.java +++ b/application/src/main/java/org/thingsboard/server/controller/NotificationController.java @@ -64,7 +64,9 @@ import org.thingsboard.server.service.security.permission.Operation; import org.thingsboard.server.service.security.permission.Resource; import javax.validation.Valid; +import java.util.Comparator; import java.util.HashMap; +import java.util.LinkedHashMap; import java.util.LinkedHashSet; import java.util.List; import java.util.Map; @@ -201,9 +203,6 @@ public class NotificationController extends BaseController { public NotificationRequestPreview getNotificationRequestPreview(@RequestBody @Valid NotificationRequest request, @RequestParam(defaultValue = "20") int recipientsPreviewSize, @AuthenticationPrincipal SecurityUser user) throws ThingsboardException { - NotificationRequestPreview preview = new NotificationRequestPreview(); - - request.setOriginatorEntityId(user.getId()); NotificationTemplate template; if (request.getTemplateId() != null) { template = checkEntityId(request.getTemplateId(), notificationTemplateService::findNotificationTemplateById, Operation.READ); @@ -213,33 +212,23 @@ public class NotificationController extends BaseController { if (template == null) { throw new IllegalArgumentException("Template is missing"); } - NotificationProcessingContext tmpProcessingCtx = NotificationProcessingContext.builder() - .tenantId(user.getTenantId()) - .request(request) - .template(template) - .settings(null) - .build(); + request.setOriginatorEntityId(user.getId()); + List targets = request.getTargets().stream() + .map(NotificationTargetId::new) + .map(targetId -> notificationTargetService.findNotificationTargetById(user.getTenantId(), targetId)) + .sorted(Comparator.comparing(target -> target.getConfiguration().getType())) + .collect(Collectors.toList()); - Map processedTemplates = tmpProcessingCtx.getDeliveryMethods().stream() - .collect(Collectors.toMap(m -> m, deliveryMethod -> { - NotificationRecipient recipient = null; - if (NotificationTargetType.PLATFORM_USERS.getSupportedDeliveryMethods().contains(deliveryMethod)) { - recipient = userService.findUserById(user.getTenantId(), user.getId()); - } - return tmpProcessingCtx.getProcessedTemplate(deliveryMethod, recipient); - })); - preview.setProcessedTemplates(processedTemplates); + NotificationRequestPreview preview = new NotificationRequestPreview(); - // generic permission Set recipientsPreview = new LinkedHashSet<>(); - Map recipientsCountByTarget = new HashMap<>(); - - List targets = notificationTargetService.findNotificationTargetsByTenantIdAndIds(user.getTenantId(), - request.getTargets().stream().map(NotificationTargetId::new).collect(Collectors.toList())); + Map recipientsCountByTarget = new LinkedHashMap<>(); + Map firstRecipient = new HashMap<>(); for (NotificationTarget target : targets) { int recipientsCount; List recipientsPart; - if (target.getConfiguration().getType() == NotificationTargetType.PLATFORM_USERS) { + NotificationTargetType targetType = target.getConfiguration().getType(); + if (targetType == NotificationTargetType.PLATFORM_USERS) { PageData recipients = notificationTargetService.findRecipientsForNotificationTargetConfig(user.getTenantId(), (PlatformUsersNotificationTargetConfig) target.getConfiguration(), new PageLink(recipientsPreviewSize)); recipientsCount = (int) recipients.getTotalElements(); @@ -248,7 +237,7 @@ public class NotificationController extends BaseController { recipientsCount = 1; recipientsPart = List.of(((SlackNotificationTargetConfig) target.getConfiguration()).getConversation()); } - + firstRecipient.putIfAbsent(targetType, !recipientsPart.isEmpty() ? recipientsPart.get(0) : null); for (NotificationRecipient recipient : recipientsPart) { if (recipientsPreview.size() < recipientsPreviewSize) { recipientsPreview.add(recipient.getTitle()); @@ -258,11 +247,26 @@ public class NotificationController extends BaseController { } recipientsCountByTarget.put(target.getName(), recipientsCount); } - preview.setRecipientsPreview(recipientsPreview); preview.setRecipientsCountByTarget(recipientsCountByTarget); preview.setTotalRecipientsCount(recipientsCountByTarget.values().stream().mapToInt(Integer::intValue).sum()); + Set deliveryMethods = template.getConfiguration().getDeliveryMethodsTemplates().entrySet() + .stream().filter(entry -> entry.getValue().isEnabled()).map(Map.Entry::getKey).collect(Collectors.toSet()); + NotificationProcessingContext ctx = NotificationProcessingContext.builder() + .tenantId(user.getTenantId()) + .request(request) + .deliveryMethods(deliveryMethods) + .template(template) + .settings(null) + .build(); + Map processedTemplates = ctx.getDeliveryMethods().stream() + .collect(Collectors.toMap(m -> m, deliveryMethod -> { + NotificationTargetType targetType = NotificationTargetType.forDeliveryMethod(deliveryMethod); + return ctx.getProcessedTemplate(deliveryMethod, firstRecipient.get(targetType)); + })); + preview.setProcessedTemplates(processedTemplates); + return preview; } diff --git a/application/src/main/java/org/thingsboard/server/controller/NotificationTargetController.java b/application/src/main/java/org/thingsboard/server/controller/NotificationTargetController.java index 4fd6b93022..6374b65710 100644 --- a/application/src/main/java/org/thingsboard/server/controller/NotificationTargetController.java +++ b/application/src/main/java/org/thingsboard/server/controller/NotificationTargetController.java @@ -71,7 +71,7 @@ public class NotificationTargetController extends BaseController { private final NotificationTargetService notificationTargetService; @ApiOperation(value = "Save notification target (saveNotificationTarget)", - notes = "Create or update notification target.\n\n" + + notes = "Create or update notification target." + SYSTEM_OR_TENANT_AUTHORITY_PARAGRAPH) @PostMapping("/target") @PreAuthorize("hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN')") diff --git a/application/src/main/java/org/thingsboard/server/controller/TelemetryController.java b/application/src/main/java/org/thingsboard/server/controller/TelemetryController.java index ce6537fd3f..05f56e93bd 100644 --- a/application/src/main/java/org/thingsboard/server/controller/TelemetryController.java +++ b/application/src/main/java/org/thingsboard/server/controller/TelemetryController.java @@ -75,16 +75,15 @@ import org.thingsboard.server.common.data.kv.StringDataEntry; import org.thingsboard.server.common.data.kv.TsKvEntry; import org.thingsboard.server.common.data.tenant.profile.DefaultTenantProfileConfiguration; import org.thingsboard.server.common.transport.adaptor.JsonConverter; -import org.thingsboard.server.dao.service.ConstraintValidator; import org.thingsboard.server.dao.timeseries.TimeseriesService; +import org.thingsboard.server.exception.InvalidParametersException; +import org.thingsboard.server.exception.UncheckedApiException; import org.thingsboard.server.queue.util.TbCoreComponent; import org.thingsboard.server.service.security.AccessValidator; import org.thingsboard.server.service.security.model.SecurityUser; import org.thingsboard.server.service.security.permission.Operation; import org.thingsboard.server.service.telemetry.AttributeData; import org.thingsboard.server.service.telemetry.TsData; -import org.thingsboard.server.exception.InvalidParametersException; -import org.thingsboard.server.exception.UncheckedApiException; import javax.annotation.Nullable; import javax.annotation.PostConstruct; @@ -624,7 +623,6 @@ public class TelemetryController extends BaseController { } if (json.isObject()) { List attributes = extractRequestAttributes(json); - attributes.forEach(ConstraintValidator::validateFields); if (attributes.isEmpty()) { return getImmediateDeferredResult("No attributes data found in request body!", HttpStatus.BAD_REQUEST); } diff --git a/application/src/main/java/org/thingsboard/server/controller/plugin/TbWebSocketHandler.java b/application/src/main/java/org/thingsboard/server/controller/plugin/TbWebSocketHandler.java index 200a6c71d2..7a396ff795 100644 --- a/application/src/main/java/org/thingsboard/server/controller/plugin/TbWebSocketHandler.java +++ b/application/src/main/java/org/thingsboard/server/controller/plugin/TbWebSocketHandler.java @@ -84,6 +84,8 @@ public class TbWebSocketHandler extends TextWebSocketHandler implements WebSocke private long sendTimeout; @Value("${server.ws.ping_timeout:30000}") private long pingTimeout; + @Value("${server.ws.max_queue_messages_per_session:1000}") + private int wsMaxQueueMessagesPerSession; private final ConcurrentMap blacklistedSessions = new ConcurrentHashMap<>(); private final ConcurrentMap perSessionUpdateLimits = new ConcurrentHashMap<>(); @@ -143,9 +145,11 @@ public class TbWebSocketHandler extends TextWebSocketHandler implements WebSocke return; } var tenantProfileConfiguration = getTenantProfileConfiguration(sessionRef); + int wsTenantProfileQueueLimit = tenantProfileConfiguration != null ? + tenantProfileConfiguration.getWsMsgQueueLimitPerSession() : wsMaxQueueMessagesPerSession; internalSessionMap.put(internalSessionId, new SessionMetaData(session, sessionRef, - tenantProfileConfiguration != null && tenantProfileConfiguration.getWsMsgQueueLimitPerSession() > 0 ? - tenantProfileConfiguration.getWsMsgQueueLimitPerSession() : 500)); + (wsTenantProfileQueueLimit > 0 && wsTenantProfileQueueLimit < wsMaxQueueMessagesPerSession) ? + wsTenantProfileQueueLimit : wsMaxQueueMessagesPerSession)); externalSessionMap.put(externalSessionId, internalSessionId); processInWebSocketService(sessionRef, SessionEvent.onEstablished()); diff --git a/application/src/main/java/org/thingsboard/server/service/apiusage/limits/DefaultRateLimitService.java b/application/src/main/java/org/thingsboard/server/service/apiusage/limits/DefaultRateLimitService.java index e995a8bd1f..9a0282c840 100644 --- a/application/src/main/java/org/thingsboard/server/service/apiusage/limits/DefaultRateLimitService.java +++ b/application/src/main/java/org/thingsboard/server/service/apiusage/limits/DefaultRateLimitService.java @@ -15,54 +15,81 @@ */ package org.thingsboard.server.service.apiusage.limits; +import com.github.benmanes.caffeine.cache.Cache; +import com.github.benmanes.caffeine.cache.Caffeine; +import lombok.Data; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Service; import org.thingsboard.server.common.data.StringUtils; +import org.thingsboard.server.common.data.id.EntityId; import org.thingsboard.server.common.data.id.TenantId; import org.thingsboard.server.common.msg.tools.TbRateLimits; import org.thingsboard.server.dao.tenant.TbTenantProfileCache; -import java.util.Map; -import java.util.concurrent.ConcurrentHashMap; +import javax.annotation.PostConstruct; +import java.util.concurrent.TimeUnit; @Service @RequiredArgsConstructor +@Slf4j public class DefaultRateLimitService implements RateLimitService { private final TbTenantProfileCache tenantProfileCache; + @Value("${cache.rateLimits.timeToLiveInMinutes:60}") + private int rateLimitsTtl; + @Value("${cache.rateLimits.maxSize:100000}") + private int rateLimitsCacheMaxSize; - private final Map> rateLimits = new ConcurrentHashMap<>(); + private Cache rateLimits; + + @PostConstruct + private void init() { + rateLimits = Caffeine.newBuilder() + .expireAfterAccess(rateLimitsTtl, TimeUnit.MINUTES) + .maximumSize(rateLimitsCacheMaxSize) + .build(); + } + + @Override + public boolean checkRateLimit(LimitedApi api, TenantId tenantId) { + return checkRateLimit(api, tenantId, tenantId); + } @Override - public boolean checkRateLimit(TenantId tenantId, LimitedApi api) { + public boolean checkRateLimit(LimitedApi api, TenantId tenantId, EntityId entityId) { if (tenantId.isSysTenantId()) { return true; } + RateLimitKey key = new RateLimitKey(api, entityId); + String rateLimitConfig = tenantProfileCache.get(tenantId).getProfileConfiguration() .map(api::getLimitConfig).orElse(null); - - Map rateLimits = this.rateLimits.get(api); if (StringUtils.isEmpty(rateLimitConfig)) { - if (rateLimits != null) { - rateLimits.remove(tenantId); - if (rateLimits.isEmpty()) { - this.rateLimits.remove(api); - } - } + rateLimits.invalidate(key); return true; } + log.trace("[{}] Checking rate limit for {} ({})", entityId, api, rateLimitConfig); - if (rateLimits == null) { - rateLimits = new ConcurrentHashMap<>(); - this.rateLimits.put(api, rateLimits); - } - TbRateLimits rateLimit = rateLimits.get(tenantId); - if (rateLimit == null || !rateLimit.getConfiguration().equals(rateLimitConfig)) { - rateLimit = new TbRateLimits(rateLimitConfig); - rateLimits.put(tenantId, rateLimit); + TbRateLimits rateLimit = rateLimits.asMap().compute(key, (k, limit) -> { + if (limit == null || !limit.getConfiguration().equals(rateLimitConfig)) { + limit = new TbRateLimits(rateLimitConfig); + log.trace("[{}] Created new rate limit bucket for {} ({})", entityId, api, rateLimitConfig); + } + return limit; + }); + boolean success = rateLimit.tryConsume(); + if (!success) { + log.debug("[{}] Rate limit exceeded for {} ({})", entityId, api, rateLimitConfig); } + return success; + } - return rateLimit.tryConsume(); + @Data(staticConstructor = "of") + private static class RateLimitKey { + private final LimitedApi api; + private final EntityId entityId; } } diff --git a/application/src/main/java/org/thingsboard/server/service/apiusage/limits/LimitedApi.java b/application/src/main/java/org/thingsboard/server/service/apiusage/limits/LimitedApi.java index f69ece6661..4f216ffaca 100644 --- a/application/src/main/java/org/thingsboard/server/service/apiusage/limits/LimitedApi.java +++ b/application/src/main/java/org/thingsboard/server/service/apiusage/limits/LimitedApi.java @@ -25,7 +25,8 @@ public enum LimitedApi { ENTITY_EXPORT(DefaultTenantProfileConfiguration::getTenantEntityExportRateLimit), ENTITY_IMPORT(DefaultTenantProfileConfiguration::getTenantEntityImportRateLimit), - NOTIFICATION_REQUEST(DefaultTenantProfileConfiguration::getTenantNotificationRequestsRateLimit); + NOTIFICATION_REQUESTS(DefaultTenantProfileConfiguration::getTenantNotificationRequestsRateLimit), + NOTIFICATION_REQUESTS_PER_RULE(DefaultTenantProfileConfiguration::getTenantNotificationRequestsPerRuleRateLimit); private final Function configExtractor; diff --git a/application/src/main/java/org/thingsboard/server/service/apiusage/limits/RateLimitService.java b/application/src/main/java/org/thingsboard/server/service/apiusage/limits/RateLimitService.java index c984ec8fa5..3fde98618e 100644 --- a/application/src/main/java/org/thingsboard/server/service/apiusage/limits/RateLimitService.java +++ b/application/src/main/java/org/thingsboard/server/service/apiusage/limits/RateLimitService.java @@ -15,10 +15,13 @@ */ package org.thingsboard.server.service.apiusage.limits; +import org.thingsboard.server.common.data.id.EntityId; import org.thingsboard.server.common.data.id.TenantId; public interface RateLimitService { - boolean checkRateLimit(TenantId tenantId, LimitedApi api); + boolean checkRateLimit(LimitedApi api, TenantId tenantId); + + boolean checkRateLimit(LimitedApi api, TenantId tenantId, EntityId entityId); } diff --git a/application/src/main/java/org/thingsboard/server/service/device/DeviceProvisionServiceImpl.java b/application/src/main/java/org/thingsboard/server/service/device/DeviceProvisionServiceImpl.java index 80b51f7ad5..beecebe2ba 100644 --- a/application/src/main/java/org/thingsboard/server/service/device/DeviceProvisionServiceImpl.java +++ b/application/src/main/java/org/thingsboard/server/service/device/DeviceProvisionServiceImpl.java @@ -20,15 +20,16 @@ import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.node.ObjectNode; import com.google.common.util.concurrent.ListenableFuture; import lombok.extern.slf4j.Slf4j; -import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Service; import org.thingsboard.common.util.JacksonUtil; import org.thingsboard.server.cluster.TbClusterService; import org.thingsboard.server.common.data.DataConstants; import org.thingsboard.server.common.data.Device; import org.thingsboard.server.common.data.DeviceProfile; +import org.thingsboard.server.common.data.DeviceProfileProvisionType; import org.thingsboard.server.common.data.StringUtils; import org.thingsboard.server.common.data.audit.ActionType; +import org.thingsboard.server.common.data.device.profile.X509CertificateChainProvisionConfiguration; import org.thingsboard.server.common.data.id.CustomerId; import org.thingsboard.server.common.data.id.TenantId; import org.thingsboard.server.common.data.id.UserId; @@ -36,15 +37,16 @@ import org.thingsboard.server.common.data.kv.AttributeKvEntry; import org.thingsboard.server.common.data.kv.BaseAttributeKvEntry; import org.thingsboard.server.common.data.kv.StringDataEntry; import org.thingsboard.server.common.data.security.DeviceCredentials; +import org.thingsboard.server.common.data.security.DeviceCredentialsType; import org.thingsboard.server.common.msg.TbMsg; import org.thingsboard.server.common.msg.TbMsgMetaData; import org.thingsboard.server.common.msg.queue.ServiceType; import org.thingsboard.server.common.msg.queue.TopicPartitionInfo; +import org.thingsboard.server.common.transport.util.SslUtil; import org.thingsboard.server.dao.attributes.AttributesService; import org.thingsboard.server.dao.audit.AuditLogService; import org.thingsboard.server.dao.device.DeviceCredentialsService; -import org.thingsboard.server.dao.device.DeviceDao; -import org.thingsboard.server.dao.device.DeviceProfileDao; +import org.thingsboard.server.dao.device.DeviceProfileService; import org.thingsboard.server.dao.device.DeviceProvisionService; import org.thingsboard.server.dao.device.DeviceService; import org.thingsboard.server.dao.device.provision.ProvisionFailedException; @@ -59,12 +61,13 @@ import org.thingsboard.server.queue.common.TbProtoQueueMsg; import org.thingsboard.server.queue.discovery.PartitionService; import org.thingsboard.server.queue.provider.TbQueueProducerProvider; import org.thingsboard.server.queue.util.TbCoreComponent; -import org.thingsboard.server.service.state.DeviceStateService; import java.util.Collections; import java.util.List; import java.util.Optional; import java.util.concurrent.ExecutionException; +import java.util.regex.Matcher; +import java.util.regex.Pattern; @Service @@ -77,35 +80,56 @@ public class DeviceProvisionServiceImpl implements DeviceProvisionService { private static final String DEVICE_PROVISION_STATE = "provisionState"; private static final String PROVISIONED_STATE = "provisioned"; - @Autowired - TbClusterService clusterService; + private final TbClusterService clusterService; + private final DeviceProfileService deviceProfileService; + private final DeviceService deviceService; + private final DeviceCredentialsService deviceCredentialsService; + private final AttributesService attributesService; + private final AuditLogService auditLogService; + private final PartitionService partitionService; - @Autowired - DeviceDao deviceDao; - - @Autowired - DeviceProfileDao deviceProfileDao; - - @Autowired - DeviceService deviceService; - - @Autowired - DeviceCredentialsService deviceCredentialsService; - - @Autowired - AttributesService attributesService; - - @Autowired - DeviceStateService deviceStateService; - - @Autowired - AuditLogService auditLogService; - - @Autowired - PartitionService partitionService; - - public DeviceProvisionServiceImpl(TbQueueProducerProvider producerProvider) { + public DeviceProvisionServiceImpl(TbQueueProducerProvider producerProvider, TbClusterService clusterService, DeviceProfileService deviceProfileService, DeviceService deviceService, DeviceCredentialsService deviceCredentialsService, AttributesService attributesService, AuditLogService auditLogService, PartitionService partitionService) { ruleEngineMsgProducer = producerProvider.getRuleEngineMsgProducer(); + this.clusterService = clusterService; + this.deviceProfileService = deviceProfileService; + this.deviceService = deviceService; + this.deviceCredentialsService = deviceCredentialsService; + this.attributesService = attributesService; + this.auditLogService = auditLogService; + this.partitionService = partitionService; + } + + @Override + public ProvisionResponse provisionDeviceViaX509Chain(DeviceProfile targetProfile, ProvisionRequest provisionRequest) throws ProvisionFailedException { + if (targetProfile == null) { + throw new ProvisionFailedException("Device profile is not specified!"); + } + if (!DeviceProfileProvisionType.X509_CERTIFICATE_CHAIN.equals(targetProfile.getProfileData().getProvisionConfiguration().getType())) { + throw new ProvisionFailedException("Device profile provision strategy is not X509_CERTIFICATE_CHAIN!"); + } + X509CertificateChainProvisionConfiguration configuration = (X509CertificateChainProvisionConfiguration) targetProfile.getProfileData().getProvisionConfiguration(); + String certificateValue = provisionRequest.getCredentialsData().getX509CertHash(); + String certificateRegEx = configuration.getCertificateRegExPattern(); + String commonName = getCNFromX509Certificate(targetProfile, certificateValue); + String deviceName = extractDeviceNameFromCNByRegEx(targetProfile, commonName, certificateRegEx); + provisionRequest.setDeviceName(deviceName); + Device targetDevice = deviceService.findDeviceByTenantIdAndName(targetProfile.getTenantId(), provisionRequest.getDeviceName()); + X509CertificateChainProvisionConfiguration x509Configuration = (X509CertificateChainProvisionConfiguration) targetProfile.getProfileData().getProvisionConfiguration(); + if (targetDevice != null && targetDevice.getDeviceProfileId().equals(targetProfile.getId())) { + DeviceCredentials deviceCredentials = deviceCredentialsService.findDeviceCredentialsByDeviceId(targetDevice.getTenantId(), targetDevice.getId()); + if (DeviceCredentialsType.X509_CERTIFICATE.equals(deviceCredentials.getCredentialsType())) { + String updatedDeviceCertificateValue = provisionRequest.getCredentialsData().getX509CertHash(); + deviceCredentials = updateDeviceCredentials(targetDevice.getTenantId(), deviceCredentials, + updatedDeviceCertificateValue, DeviceCredentialsType.X509_CERTIFICATE); + } + return new ProvisionResponse(deviceCredentials, ProvisionResponseStatus.SUCCESS); + } else if (x509Configuration.isAllowCreateNewDevicesByX509Certificate()) { + return createDevice(provisionRequest, targetProfile); + } else { + log.warn("[{}][{}] Device with name {} doesn't exist and cannot be created due incorrect configuration for X509CertificateChainProvisionConfiguration", + targetProfile.getTenantId(), targetProfile.getId(), provisionRequest.getDeviceName()); + throw new ProvisionFailedException(ProvisionResponseStatus.FAILURE.name()); + } } @Override @@ -124,14 +148,14 @@ public class DeviceProvisionServiceImpl implements DeviceProvisionService { throw new ProvisionFailedException(ProvisionResponseStatus.NOT_FOUND.name()); } - DeviceProfile targetProfile = deviceProfileDao.findByProvisionDeviceKey(provisionRequestKey); + DeviceProfile targetProfile = deviceProfileService.findDeviceProfileByProvisionDeviceKey(provisionRequestKey); if (targetProfile == null || targetProfile.getProfileData().getProvisionConfiguration() == null || targetProfile.getProfileData().getProvisionConfiguration().getProvisionDeviceSecret() == null) { throw new ProvisionFailedException(ProvisionResponseStatus.NOT_FOUND.name()); } - Device targetDevice = deviceDao.findDeviceByTenantIdAndName(targetProfile.getTenantId().getId(), provisionRequest.getDeviceName()).orElse(null); + Device targetDevice = deviceService.findDeviceByTenantIdAndName(targetProfile.getTenantId(), provisionRequest.getDeviceName()); switch (targetProfile.getProvisionType()) { case ALLOW_CREATE_NEW_DEVICES: @@ -155,6 +179,8 @@ public class DeviceProvisionServiceImpl implements DeviceProvisionService { } } break; + case X509_CERTIFICATE_CHAIN: + throw new ProvisionFailedException("Invalid provision strategy type!"); } throw new ProvisionFailedException(ProvisionResponseStatus.NOT_FOUND.name()); } @@ -209,6 +235,14 @@ public class DeviceProvisionServiceImpl implements DeviceProvisionService { } } + private DeviceCredentials updateDeviceCredentials(TenantId tenantId, DeviceCredentials deviceCredentials, String certificateValue, + DeviceCredentialsType credentialsType) { + log.trace("Updating device credentials [{}] with certificate value [{}]", deviceCredentials, certificateValue); + deviceCredentials.setCredentialsValue(certificateValue); + deviceCredentials.setCredentialsType(credentialsType); + return deviceCredentialsService.updateDeviceCredentials(tenantId, deviceCredentials); + } + private ListenableFuture> saveProvisionStateAttribute(Device device) { return attributesService.save(device.getTenantId(), device.getId(), DataConstants.SERVER_SCOPE, Collections.singletonList(new BaseAttributeKvEntry(new StringDataEntry(DEVICE_PROVISION_STATE, PROVISIONED_STATE), @@ -257,4 +291,27 @@ public class DeviceProvisionServiceImpl implements DeviceProvisionService { ActionType actionType = success ? ActionType.PROVISION_SUCCESS : ActionType.PROVISION_FAILURE; auditLogService.logEntityAction(tenantId, customerId, new UserId(UserId.NULL_UUID), device.getName(), device.getId(), device, actionType, null, provisionRequest); } + + private String getCNFromX509Certificate(DeviceProfile profile, String x509Value) { + try { + return SslUtil.parseCommonName(SslUtil.readCertFile(x509Value)); + } catch (Exception e) { + log.trace("[{}][{}] Failed to parse CN from X509 certificate {}", profile.getTenantId(), profile.getId(), x509Value); + return null; + } + } + + public String extractDeviceNameFromCNByRegEx(DeviceProfile profile, String commonName, String regex) throws ProvisionFailedException { + try { + log.trace("Extract device name from CN [{}] by regex pattern [{}]", commonName, regex); + Pattern pattern = Pattern.compile(regex); + Matcher matcher = pattern.matcher(commonName); + if (matcher.find()) { + return matcher.group(1); + } + } catch (Exception ignored) {} + log.trace("[{}][{}] Failed to match device name using [{}] from CN: [{}]", profile.getTenantId(), profile.getId(), regex, commonName); + throw new ProvisionFailedException(ProvisionResponseStatus.FAILURE.name()); + } + } 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 b6ed5eb664..f273517b74 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 @@ -98,11 +98,14 @@ public class EdgeGrpcService extends EdgeRpcServiceGrpc.EdgeRpcServiceImplBase i private String privateKeyResource; @Value("${edges.state.persistToTelemetry:false}") private boolean persistToTelemetry; - @Value("${edges.rpc.client_max_keep_alive_time_sec}") + @Value("${edges.rpc.client_max_keep_alive_time_sec:1}") private int clientMaxKeepAliveTimeSec; @Value("${edges.rpc.max_inbound_message_size:4194304}") private int maxInboundMessageSize; - + @Value("${edges.rpc.keep_alive_time_sec:10}") + private int keepAliveTimeSec; + @Value("${edges.rpc.keep_alive_timeout_sec:5}") + private int keepAliveTimeoutSec; @Value("${edges.scheduler_pool_size}") private int schedulerPoolSize; @@ -131,6 +134,9 @@ public class EdgeGrpcService extends EdgeRpcServiceGrpc.EdgeRpcServiceImplBase i log.info("Initializing Edge RPC service!"); NettyServerBuilder builder = NettyServerBuilder.forPort(rpcPort) .permitKeepAliveTime(clientMaxKeepAliveTimeSec, TimeUnit.SECONDS) + .keepAliveTime(keepAliveTimeSec, TimeUnit.SECONDS) + .keepAliveTimeout(keepAliveTimeoutSec, TimeUnit.SECONDS) + .permitKeepAliveWithoutCalls(true) .maxInboundMessageSize(maxInboundMessageSize) .addService(this); if (sslEnabled) { diff --git a/application/src/main/java/org/thingsboard/server/service/install/DefaultSystemDataLoaderService.java b/application/src/main/java/org/thingsboard/server/service/install/DefaultSystemDataLoaderService.java index 9f6793a123..2feb13bb21 100644 --- a/application/src/main/java/org/thingsboard/server/service/install/DefaultSystemDataLoaderService.java +++ b/application/src/main/java/org/thingsboard/server/service/install/DefaultSystemDataLoaderService.java @@ -507,6 +507,7 @@ public class DefaultSystemDataLoaderService implements SystemDataLoaderService { this.deleteSystemWidgetBundle("entity_admin_widgets"); this.deleteSystemWidgetBundle("navigation_widgets"); this.deleteSystemWidgetBundle("edge_widgets"); + this.deleteSystemWidgetBundle("home_page_widgets"); installScripts.loadSystemWidgets(); } diff --git a/application/src/main/java/org/thingsboard/server/service/install/update/DefaultCacheCleanupService.java b/application/src/main/java/org/thingsboard/server/service/install/update/DefaultCacheCleanupService.java index bd840b2f52..1e54d8d48f 100644 --- a/application/src/main/java/org/thingsboard/server/service/install/update/DefaultCacheCleanupService.java +++ b/application/src/main/java/org/thingsboard/server/service/install/update/DefaultCacheCleanupService.java @@ -82,6 +82,9 @@ public class DefaultCacheCleanupService implements CacheCleanupService { log.info("Clearing cache to upgrade from version 3.4.2 to 3.4.3 ..."); clearCacheByName("repositorySettings"); break; + case "3.4.4": + log.info("Clearing cache to upgrade from version 3.4.4 to 3.5.0"); + clearCacheByName("deviceProfiles"); default: //Do nothing, since cache cleanup is optional. } diff --git a/application/src/main/java/org/thingsboard/server/service/notification/DefaultNotificationCenter.java b/application/src/main/java/org/thingsboard/server/service/notification/DefaultNotificationCenter.java index b4fba04212..91bc0863db 100644 --- a/application/src/main/java/org/thingsboard/server/service/notification/DefaultNotificationCenter.java +++ b/application/src/main/java/org/thingsboard/server/service/notification/DefaultNotificationCenter.java @@ -107,8 +107,10 @@ public class DefaultNotificationCenter extends AbstractSubscriptionService imple @Override public NotificationRequest processNotificationRequest(TenantId tenantId, NotificationRequest request, Consumer callback) { - if (!rateLimitService.checkRateLimit(tenantId, LimitedApi.NOTIFICATION_REQUEST)) { - throw new TbRateLimitsException(EntityType.TENANT); + if (request.getRuleId() == null) { + if (!rateLimitService.checkRateLimit(LimitedApi.NOTIFICATION_REQUESTS, tenantId)) { + throw new TbRateLimitsException(EntityType.TENANT); + } } NotificationTemplate notificationTemplate; @@ -119,21 +121,33 @@ public class DefaultNotificationCenter extends AbstractSubscriptionService imple } if (notificationTemplate == null) throw new IllegalArgumentException("Template is missing"); + Set deliveryMethods = new HashSet<>(); List targets = request.getTargets().stream().map(NotificationTargetId::new) - .map(id -> notificationTargetService.findNotificationTargetById(tenantId, id)).collect(Collectors.toList()); + .map(id -> notificationTargetService.findNotificationTargetById(tenantId, id)) + .collect(Collectors.toList()); NotificationRuleId ruleId = request.getRuleId(); notificationTemplate.getConfiguration().getDeliveryMethodsTemplates().forEach((deliveryMethod, template) -> { if (!template.isEnabled()) return; - if (!channels.get(deliveryMethod).check(tenantId)) { - throw new IllegalArgumentException("Unable to send notification via " + deliveryMethod.getName() + ": not configured or not working"); + try { + channels.get(deliveryMethod).check(tenantId); + } catch (Exception e) { + if (ruleId == null) { + throw new IllegalArgumentException(e.getMessage()); + } else { + return; // if originated by rule - just ignore delivery method + } } if (ruleId == null) { if (targets.stream().noneMatch(target -> target.getConfiguration().getType().getSupportedDeliveryMethods().contains(deliveryMethod))) { - throw new IllegalArgumentException("Target for " + deliveryMethod.getName() + " delivery method is missing"); + throw new IllegalArgumentException("Recipients for " + deliveryMethod.getName() + " delivery method not chosen"); } } + deliveryMethods.add(deliveryMethod); }); + if (deliveryMethods.isEmpty()) { + throw new IllegalArgumentException("No delivery methods to send notification with"); + } if (request.getAdditionalConfig() != null) { NotificationRequestConfig config = request.getAdditionalConfig(); @@ -153,6 +167,7 @@ public class DefaultNotificationCenter extends AbstractSubscriptionService imple NotificationProcessingContext ctx = NotificationProcessingContext.builder() .tenantId(tenantId) .request(request) + .deliveryMethods(deliveryMethods) .template(notificationTemplate) .settings(settings) .build(); @@ -341,14 +356,20 @@ public class DefaultNotificationCenter extends AbstractSubscriptionService imple @Override public Set getAvailableDeliveryMethods(TenantId tenantId) { return channels.values().stream() - .filter(channel -> channel.check(tenantId)) + .filter(channel -> { + try { + channel.check(tenantId); + return true; + } catch (Exception e) { + return false; + } + }) .map(NotificationChannel::getDeliveryMethod) .collect(Collectors.toSet()); } @Override - public boolean check(TenantId tenantId) { - return true; + public void check(TenantId tenantId) throws Exception { } @Override diff --git a/application/src/main/java/org/thingsboard/server/service/notification/NotificationProcessingContext.java b/application/src/main/java/org/thingsboard/server/service/notification/NotificationProcessingContext.java index 8f9b7d048b..25ff1dd50e 100644 --- a/application/src/main/java/org/thingsboard/server/service/notification/NotificationProcessingContext.java +++ b/application/src/main/java/org/thingsboard/server/service/notification/NotificationProcessingContext.java @@ -48,20 +48,21 @@ public class NotificationProcessingContext { private final NotificationSettings settings; @Getter private final NotificationRequest request; - + @Getter + private final Set deliveryMethods; @Getter private final NotificationTemplate notificationTemplate; + private final Map templates; @Getter - private Set deliveryMethods; - @Getter private final NotificationRequestStats stats; - @Builder - public NotificationProcessingContext(TenantId tenantId, NotificationRequest request, NotificationTemplate template, NotificationSettings settings) { + public NotificationProcessingContext(TenantId tenantId, NotificationRequest request, Set deliveryMethods, + NotificationTemplate template, NotificationSettings settings) { this.tenantId = tenantId; this.request = request; + this.deliveryMethods = deliveryMethods; this.settings = settings; this.notificationTemplate = template; this.templates = new EnumMap<>(NotificationDeliveryMethod.class); @@ -77,7 +78,6 @@ public class NotificationProcessingContext { templates.put(deliveryMethod, template); } }); - deliveryMethods = templates.keySet(); } public C getDeliveryMethodConfig(NotificationDeliveryMethod deliveryMethod) { diff --git a/application/src/main/java/org/thingsboard/server/service/notification/channels/EmailNotificationChannel.java b/application/src/main/java/org/thingsboard/server/service/notification/channels/EmailNotificationChannel.java index 61e4c528dc..f5bbc57954 100644 --- a/application/src/main/java/org/thingsboard/server/service/notification/channels/EmailNotificationChannel.java +++ b/application/src/main/java/org/thingsboard/server/service/notification/channels/EmailNotificationChannel.java @@ -48,12 +48,11 @@ public class EmailNotificationChannel implements NotificationChannel sendNotification(R recipient, T processedTemplate, NotificationProcessingContext ctx); - boolean check(TenantId tenantId); + void check(TenantId tenantId) throws Exception; NotificationDeliveryMethod getDeliveryMethod(); diff --git a/application/src/main/java/org/thingsboard/server/service/notification/channels/SlackNotificationChannel.java b/application/src/main/java/org/thingsboard/server/service/notification/channels/SlackNotificationChannel.java index 7ff48d4cb9..46afbd7270 100644 --- a/application/src/main/java/org/thingsboard/server/service/notification/channels/SlackNotificationChannel.java +++ b/application/src/main/java/org/thingsboard/server/service/notification/channels/SlackNotificationChannel.java @@ -22,12 +22,12 @@ import org.thingsboard.rule.engine.api.slack.SlackService; import org.thingsboard.server.common.data.id.TenantId; import org.thingsboard.server.common.data.notification.NotificationDeliveryMethod; import org.thingsboard.server.common.data.notification.settings.NotificationSettings; -import org.thingsboard.server.dao.notification.NotificationSettingsService; -import org.thingsboard.server.service.notification.NotificationProcessingContext; import org.thingsboard.server.common.data.notification.settings.SlackNotificationDeliveryMethodConfig; import org.thingsboard.server.common.data.notification.targets.slack.SlackConversation; import org.thingsboard.server.common.data.notification.template.SlackDeliveryMethodNotificationTemplate; +import org.thingsboard.server.dao.notification.NotificationSettingsService; import org.thingsboard.server.service.executors.ExternalCallExecutorService; +import org.thingsboard.server.service.notification.NotificationProcessingContext; @Component @RequiredArgsConstructor @@ -47,9 +47,11 @@ public class SlackNotificationChannel implements NotificationChannel { submitNotificationRequest(targets, rule, trigger.getOriginatorEntityId(), notificationInfo, delay); diff --git a/application/src/main/java/org/thingsboard/server/service/security/AccessValidator.java b/application/src/main/java/org/thingsboard/server/service/security/AccessValidator.java index 1dae050f58..8e00c837f3 100644 --- a/application/src/main/java/org/thingsboard/server/service/security/AccessValidator.java +++ b/application/src/main/java/org/thingsboard/server/service/security/AccessValidator.java @@ -67,6 +67,7 @@ import org.thingsboard.server.dao.device.DeviceProfileService; import org.thingsboard.server.dao.device.DeviceService; 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.IncorrectParameterException; import org.thingsboard.server.dao.ota.OtaPackageService; import org.thingsboard.server.dao.resource.ResourceService; @@ -575,7 +576,7 @@ public class AccessValidator { ResponseEntity responseEntity; if (e instanceof ToErrorResponseEntity) { responseEntity = ((ToErrorResponseEntity) e).toErrorResponseEntity(); - } else if (e instanceof IllegalArgumentException || e instanceof IncorrectParameterException) { + } else if (e instanceof IllegalArgumentException || e instanceof IncorrectParameterException || e instanceof DataValidationException) { responseEntity = new ResponseEntity<>(e.getMessage(), HttpStatus.BAD_REQUEST); } else { responseEntity = new ResponseEntity<>(defaultErrorStatus); diff --git a/application/src/main/java/org/thingsboard/server/service/security/auth/rest/RestAuthenticationDetails.java b/application/src/main/java/org/thingsboard/server/service/security/auth/rest/RestAuthenticationDetails.java index bc80a22e1b..1eafe66c8f 100644 --- a/application/src/main/java/org/thingsboard/server/service/security/auth/rest/RestAuthenticationDetails.java +++ b/application/src/main/java/org/thingsboard/server/service/security/auth/rest/RestAuthenticationDetails.java @@ -20,7 +20,6 @@ import ua_parser.Client; import ua_parser.Parser; import javax.servlet.http.HttpServletRequest; -import java.io.IOException; import java.io.Serializable; @Data @@ -43,11 +42,7 @@ public class RestAuthenticationDetails implements Serializable { } private static Client getUserAgent(HttpServletRequest request) { - try { - Parser uaParser = new Parser(); - return uaParser.parse(request.getHeader("User-Agent")); - } catch (IOException e) { - return new Client(null, null, null); - } + Parser uaParser = new Parser(); + return uaParser.parse(request.getHeader("User-Agent")); } } diff --git a/application/src/main/java/org/thingsboard/server/service/sync/ie/DefaultEntitiesExportImportService.java b/application/src/main/java/org/thingsboard/server/service/sync/ie/DefaultEntitiesExportImportService.java index b646cb7ef6..793c25d625 100644 --- a/application/src/main/java/org/thingsboard/server/service/sync/ie/DefaultEntitiesExportImportService.java +++ b/application/src/main/java/org/thingsboard/server/service/sync/ie/DefaultEntitiesExportImportService.java @@ -73,7 +73,7 @@ public class DefaultEntitiesExportImportService implements EntitiesExportImportS @Override public , I extends EntityId> EntityExportData exportEntity(EntitiesExportCtx ctx, I entityId) throws ThingsboardException { - if (!rateLimitService.checkRateLimit(ctx.getTenantId(), LimitedApi.ENTITY_EXPORT)) { + if (!rateLimitService.checkRateLimit(LimitedApi.ENTITY_EXPORT, ctx.getTenantId())) { throw new ThingsboardException("Rate limit for entities export is exceeded", ThingsboardErrorCode.TOO_MANY_REQUESTS); } @@ -85,7 +85,7 @@ public class DefaultEntitiesExportImportService implements EntitiesExportImportS @Override public , I extends EntityId> EntityImportResult importEntity(EntitiesImportCtx ctx, EntityExportData exportData) throws ThingsboardException { - if (!rateLimitService.checkRateLimit(ctx.getTenantId(), LimitedApi.ENTITY_IMPORT)) { + if (!rateLimitService.checkRateLimit(LimitedApi.ENTITY_IMPORT, ctx.getTenantId())) { throw new ThingsboardException("Rate limit for entities import is exceeded", ThingsboardErrorCode.TOO_MANY_REQUESTS); } if (exportData.getEntity() == null || exportData.getEntity().getId() == null) { diff --git a/application/src/main/java/org/thingsboard/server/service/system/DefaultSystemInfoService.java b/application/src/main/java/org/thingsboard/server/service/system/DefaultSystemInfoService.java index caf3abefa0..cce38cfd18 100644 --- a/application/src/main/java/org/thingsboard/server/service/system/DefaultSystemInfoService.java +++ b/application/src/main/java/org/thingsboard/server/service/system/DefaultSystemInfoService.java @@ -26,10 +26,12 @@ import org.thingsboard.common.util.ThingsBoardThreadFactory; import org.thingsboard.server.common.data.AdminSettings; import org.thingsboard.server.common.data.ApiUsageState; import org.thingsboard.server.common.data.FeaturesInfo; +import org.thingsboard.server.common.data.StringUtils; import org.thingsboard.server.common.data.SystemInfo; import org.thingsboard.server.common.data.SystemInfoData; import org.thingsboard.server.common.data.id.TenantId; import org.thingsboard.server.common.data.kv.BasicTsKvEntry; +import org.thingsboard.server.common.data.kv.BooleanDataEntry; import org.thingsboard.server.common.data.kv.JsonDataEntry; import org.thingsboard.server.common.data.kv.LongDataEntry; import org.thingsboard.server.common.data.kv.TsKvEntry; @@ -50,6 +52,7 @@ import org.thingsboard.server.service.telemetry.TelemetrySubscriptionService; import javax.annotation.Nullable; import javax.annotation.PreDestroy; import java.util.ArrayList; +import java.util.Arrays; import java.util.Collections; import java.util.List; import java.util.concurrent.Executors; @@ -143,9 +146,7 @@ public class DefaultSystemInfoService extends TbApplicationEventListener".equalsIgnoreCase(mailFrom.asText()); } return false; } @@ -154,13 +155,13 @@ public class DefaultSystemInfoService extends TbApplicationEventListener clusterSystemData = getSystemData(serviceInfoProvider.getServiceInfo()); BasicTsKvEntry clusterDataKv = new BasicTsKvEntry(ts, new JsonDataEntry("clusterSystemData", JacksonUtil.toString(clusterSystemData))); - doSave(Collections.singletonList(clusterDataKv)); + doSave(Arrays.asList(new BasicTsKvEntry(ts, new BooleanDataEntry("clusterMode", true)), clusterDataKv)); } private void saveCurrentMonolithSystemInfo() { long ts = System.currentTimeMillis(); List tsList = new ArrayList<>(); - + tsList.add(new BasicTsKvEntry(ts, new BooleanDataEntry("clusterMode", false))); getCpuUsage().ifPresent(v -> tsList.add(new BasicTsKvEntry(ts, new LongDataEntry("cpuUsage", (long) v)))); getMemoryUsage().ifPresent(v -> tsList.add(new BasicTsKvEntry(ts, new LongDataEntry("memoryUsage", (long) v)))); getDiscSpaceUsage().ifPresent(v -> tsList.add(new BasicTsKvEntry(ts, new LongDataEntry("discUsage", (long) v)))); 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 7a3a098062..d0d996fe1f 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 @@ -32,6 +32,7 @@ import org.thingsboard.server.common.data.ApiUsageState; import org.thingsboard.server.common.data.DataConstants; import org.thingsboard.server.common.data.Device; import org.thingsboard.server.common.data.DeviceProfile; +import org.thingsboard.server.common.data.DeviceProfileProvisionType; import org.thingsboard.server.common.data.DeviceTransportType; import org.thingsboard.server.common.data.EntityType; import org.thingsboard.server.common.data.OtaPackage; @@ -65,11 +66,13 @@ import org.thingsboard.server.common.msg.TbMsg; import org.thingsboard.server.common.msg.TbMsgDataType; import org.thingsboard.server.common.msg.TbMsgMetaData; import org.thingsboard.server.dao.device.DeviceCredentialsService; +import org.thingsboard.server.dao.device.DeviceProfileService; import org.thingsboard.server.dao.device.DeviceProvisionService; import org.thingsboard.server.dao.device.DeviceService; 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.ota.OtaPackageService; import org.thingsboard.server.dao.queue.QueueService; import org.thingsboard.server.dao.relation.RelationService; @@ -107,6 +110,7 @@ import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ConcurrentMap; import java.util.concurrent.locks.Lock; import java.util.concurrent.locks.ReentrantLock; +import java.util.regex.Pattern; import java.util.stream.Collectors; import static org.thingsboard.server.service.transport.BasicCredentialsValidationResult.PASSWORD_MISMATCH; @@ -121,10 +125,13 @@ import static org.thingsboard.server.service.transport.BasicCredentialsValidatio @RequiredArgsConstructor public class DefaultTransportApiService implements TransportApiService { + private static final Pattern X509_CERTIFICATE_TRIM_CHAIN_PATTERN = Pattern.compile("-----BEGIN CERTIFICATE-----\\s*.*?\\s*-----END CERTIFICATE-----"); + private final TbDeviceProfileCache deviceProfileCache; private final TbTenantProfileCache tenantProfileCache; private final TbApiUsageStateService apiUsageStateService; private final DeviceService deviceService; + private final DeviceProfileService deviceProfileService; private final RelationService relationService; private final DeviceCredentialsService deviceCredentialsService; private final DbCallbackExecutorService dbCallbackExecutorService; @@ -156,6 +163,9 @@ public class DefaultTransportApiService implements TransportApiService { } else if (transportApiRequestMsg.hasValidateX509CertRequestMsg()) { ValidateDeviceX509CertRequestMsg msg = transportApiRequestMsg.getValidateX509CertRequestMsg(); result = validateCredentials(msg.getHash(), DeviceCredentialsType.X509_CERTIFICATE); + } else if (transportApiRequestMsg.hasValidateOrCreateX509CertRequestMsg()) { + TransportProtos.ValidateOrCreateDeviceX509CertRequestMsg msg = transportApiRequestMsg.getValidateOrCreateX509CertRequestMsg(); + result = validateOrCreateDeviceX509Certificate(msg.getCertificateChain()); } else if (transportApiRequestMsg.hasGetOrCreateDeviceRequestMsg()) { result = handle(transportApiRequestMsg.getGetOrCreateDeviceRequestMsg()); } else if (transportApiRequestMsg.hasEntityProfileRequestMsg()) { @@ -223,6 +233,35 @@ public class DefaultTransportApiService implements TransportApiService { } } + protected ListenableFuture validateOrCreateDeviceX509Certificate(String certificateChain) { + List chain = X509_CERTIFICATE_TRIM_CHAIN_PATTERN.matcher(certificateChain).results().map(match -> + EncryptionUtil.certTrimNewLines(match.group())).collect(Collectors.toList()); + for (String certificateValue : chain) { + String certificateHash = EncryptionUtil.getSha3Hash(certificateValue); + DeviceCredentials credentials = deviceCredentialsService.findDeviceCredentialsByCredentialsId(certificateHash); + if (credentials != null && DeviceCredentialsType.X509_CERTIFICATE.equals(credentials.getCredentialsType())) { + return getDeviceInfo(credentials); + } + DeviceProfile deviceProfile = deviceProfileService.findDeviceProfileByProvisionDeviceKey(certificateHash); + if (deviceProfile != null && DeviceProfileProvisionType.X509_CERTIFICATE_CHAIN.equals(deviceProfile.getProvisionType())) { + String updatedDeviceProvisionSecret = chain.get(0); + ProvisionRequest provisionRequest = createProvisionRequest(updatedDeviceProvisionSecret); + try { + ProvisionResponse provisionResponse = deviceProvisionService.provisionDeviceViaX509Chain(deviceProfile, provisionRequest); + if (ProvisionResponseStatus.SUCCESS.equals(provisionResponse.getResponseStatus())) { + return getDeviceInfo(provisionResponse.getDeviceCredentials()); + } + } catch (ProvisionFailedException e) { + log.debug("[{}][{}] Failed to provision device with cert chain: {}", deviceProfile.getTenantId(), deviceProfile.getId(), provisionRequest, e); + return getEmptyTransportApiResponseFuture(); + } + } else if (deviceProfile != null) { + log.warn("[{}][{}] Device Profile provision configuration mismatched: expected {}, actual {}", deviceProfile.getTenantId(), deviceProfile.getId(), DeviceProfileProvisionType.X509_CERTIFICATE_CHAIN, deviceProfile.getProvisionType()); + } + } + return getEmptyTransportApiResponseFuture(); + } + private ListenableFuture validateUserNameCredentials(TransportProtos.ValidateBasicMqttCredRequestMsg mqtt) { DeviceCredentials credentials = deviceCredentialsService.findDeviceCredentialsByCredentialsId(mqtt.getUserName()); if (credentials != null) { @@ -662,4 +701,11 @@ public class DefaultTransportApiService implements TransportApiService { private Long checkLong(Long l) { return l != null ? l : 0; } + + private ProvisionRequest createProvisionRequest(String certificateValue) { + return new ProvisionRequest(null, DeviceCredentialsType.X509_CERTIFICATE, + new ProvisionDeviceCredentialsData(null, null, null, null, certificateValue), + null); + } + } diff --git a/application/src/main/java/org/thingsboard/server/service/ttl/EdgeEventsCleanUpService.java b/application/src/main/java/org/thingsboard/server/service/ttl/EdgeEventsCleanUpService.java index 77d59fe069..941416669a 100644 --- a/application/src/main/java/org/thingsboard/server/service/ttl/EdgeEventsCleanUpService.java +++ b/application/src/main/java/org/thingsboard/server/service/ttl/EdgeEventsCleanUpService.java @@ -32,7 +32,7 @@ import static org.thingsboard.server.dao.model.ModelConstants.EDGE_EVENT_COLUMN_ @TbCoreComponent @Slf4j @Service -@ConditionalOnExpression("${sql.ttl.edge_events.enabled:true} && ${sql.ttl.edge_events.edge_event_ttl:0} > 0") +@ConditionalOnExpression("${sql.ttl.edge_events.enabled:true} && ${sql.ttl.edge_events.edge_events_ttl:0} > 0") public class EdgeEventsCleanUpService extends AbstractCleanUpService { public static final String RANDOM_DELAY_INTERVAL_MS_EXPRESSION = diff --git a/application/src/main/java/org/thingsboard/server/service/update/DefaultUpdateService.java b/application/src/main/java/org/thingsboard/server/service/update/DefaultUpdateService.java index acd9c630a7..587d86af32 100644 --- a/application/src/main/java/org/thingsboard/server/service/update/DefaultUpdateService.java +++ b/application/src/main/java/org/thingsboard/server/service/update/DefaultUpdateService.java @@ -79,7 +79,9 @@ public class DefaultUpdateService implements UpdateService { @AfterStartUp(order = AfterStartUp.REGULAR_SERVICE) public void init() { version = buildProperties != null ? buildProperties.getVersion() : "unknown"; - updateMessage = new UpdateMessage(false, version, "", "", "", ""); + updateMessage = new UpdateMessage(false, version, "", "", + "https://thingsboard.io/docs/reference/releases", + "https://thingsboard.io/docs/reference/releases"); if (updatesEnabled) { try { platform = System.getProperty("platform", "unknown"); diff --git a/application/src/main/resources/thingsboard.yml b/application/src/main/resources/thingsboard.yml index d51a2f228f..1c1036dc14 100644 --- a/application/src/main/resources/thingsboard.yml +++ b/application/src/main/resources/thingsboard.yml @@ -65,6 +65,7 @@ server: max_per_user: "${TB_SERVER_WS_DYNAMIC_PAGE_LINK_MAX_PER_USER:10}" max_entities_per_data_subscription: "${TB_SERVER_WS_MAX_ENTITIES_PER_DATA_SUBSCRIPTION:10000}" max_entities_per_alarm_subscription: "${TB_SERVER_WS_MAX_ENTITIES_PER_ALARM_SUBSCRIPTION:10000}" + max_queue_messages_per_session: "${TB_SERVER_WS_DEFAULT_QUEUE_MESSAGES_PER_SESSION:1000}" rest: server_side_rpc: # Minimum value of the server side RPC timeout. May override value provided in the REST API call. @@ -130,6 +131,9 @@ security: loginProcessingUrl: "${SECURITY_OAUTH2_LOGIN_PROCESSING_URL:/login/oauth2/code/}" githubMapper: emailUrl: "${SECURITY_OAUTH2_GITHUB_MAPPER_EMAIL_URL_KEY:https://api.github.com/user/emails}" + java_cacerts: + path: "${SECURITY_JAVA_CACERTS_PATH:${java.home}${file.separator}lib${file.separator}security${file.separator}cacerts}" + password: "${SECURITY_JAVA_CACERTS_PASSWORD:changeit}" # Usage statistics parameters usage: @@ -255,11 +259,13 @@ sql: batch_max_delay: "${SQL_ATTRIBUTES_BATCH_MAX_DELAY_MS:100}" stats_print_interval_ms: "${SQL_ATTRIBUTES_BATCH_STATS_PRINT_MS:10000}" batch_threads: "${SQL_ATTRIBUTES_BATCH_THREADS:3}" # batch thread count have to be a prime number like 3 or 5 to gain perfect hash distribution + noxss_validation_enabled: "${SQL_ATTRIBUTES_NOXSS_VALIDATION_ENABLED:true}" ts: batch_size: "${SQL_TS_BATCH_SIZE:10000}" batch_max_delay: "${SQL_TS_BATCH_MAX_DELAY_MS:100}" stats_print_interval_ms: "${SQL_TS_BATCH_STATS_PRINT_MS:10000}" batch_threads: "${SQL_TS_BATCH_THREADS:3}" # batch thread count have to be a prime number like 3 or 5 to gain perfect hash distribution + noxss_validation_enabled: "${SQL_TS_NOXSS_VALIDATION_ENABLED:true}" ts_latest: batch_size: "${SQL_TS_LATEST_BATCH_SIZE:10000}" batch_max_delay: "${SQL_TS_LATEST_BATCH_MAX_DELAY_MS:100}" @@ -478,10 +484,14 @@ cache: entityCount: timeToLiveInMinutes: "${CACHE_SPECS_ENTITY_COUNT_TTL:1440}" maxSize: "${CACHE_SPECS_ENTITY_COUNT_MAX_SIZE:100000}" + # deliberately placed outside 'specs' group above notificationRules: timeToLiveInMinutes: "${CACHE_SPECS_NOTIFICATION_RULES_TTL:30}" maxSize: "${CACHE_SPECS_NOTIFICATION_RULES_MAX_SIZE:1000}" + rateLimits: + timeToLiveInMinutes: "${CACHE_SPECS_RATE_LIMITS_TTL:60}" + maxSize: "${CACHE_SPECS_RATE_LIMITS_MAX_SIZE:100000}" #Disable this because it is not required. spring.data.redis.repositories.enabled: false @@ -948,7 +958,9 @@ edges: enabled: "${EDGES_ENABLED:true}" rpc: port: "${EDGES_RPC_PORT:7070}" - client_max_keep_alive_time_sec: "${EDGES_RPC_CLIENT_MAX_KEEP_ALIVE_TIME_SEC:300}" + client_max_keep_alive_time_sec: "${EDGES_RPC_CLIENT_MAX_KEEP_ALIVE_TIME_SEC:1}" + keep_alive_time_sec: "${EDGES_RPC_KEEP_ALIVE_TIME_SEC:10}" + keep_alive_timeout_sec: "${EDGES_RPC_KEEP_ALIVE_TIMEOUT_SEC:5}" ssl: # Enable/disable SSL support enabled: "${EDGES_RPC_SSL_ENABLED:false}" diff --git a/application/src/test/java/org/thingsboard/server/controller/BaseDeviceProfileControllerTest.java b/application/src/test/java/org/thingsboard/server/controller/BaseDeviceProfileControllerTest.java index 0df044cb26..fb4f9bd322 100644 --- a/application/src/test/java/org/thingsboard/server/controller/BaseDeviceProfileControllerTest.java +++ b/application/src/test/java/org/thingsboard/server/controller/BaseDeviceProfileControllerTest.java @@ -299,6 +299,28 @@ public abstract class BaseDeviceProfileControllerTest extends AbstractController tenantAdmin.getId(), tenantAdmin.getEmail(), ActionType.ADDED, new DataValidationException(msgError)); } + @Test + public void testSaveDeviceProfileWithSameCertificateHash() throws Exception { + DeviceProfile deviceProfile = this.createDeviceProfile("Device Profile"); + deviceProfile.setProvisionDeviceKey("Certificate hash"); + + doPost("/api/deviceProfile", deviceProfile) + .andExpect(status().isOk()); + + DeviceProfile deviceProfile2 = this.createDeviceProfile("Device Profile 2"); + deviceProfile2.setProvisionDeviceKey("Certificate hash"); + + Mockito.reset(tbClusterService, auditLogService); + + String msgError = "Device profile with such provision device key already exists!"; + doPost("/api/deviceProfile", deviceProfile2) + .andExpect(status().isBadRequest()) + .andExpect(statusReason(containsString(msgError))); + + testNotifyEntityEqualsOneTimeServiceNeverError(deviceProfile, savedTenant.getId(), + tenantAdmin.getId(), tenantAdmin.getEmail(), ActionType.ADDED, new DataValidationException(msgError)); + } + @Test public void testChangeDeviceProfileTypeNull() throws Exception { DeviceProfile deviceProfile = this.createDeviceProfile("Device Profile"); diff --git a/application/src/test/java/org/thingsboard/server/controller/BaseTelemetryControllerTest.java b/application/src/test/java/org/thingsboard/server/controller/BaseTelemetryControllerTest.java new file mode 100644 index 0000000000..af0d486869 --- /dev/null +++ b/application/src/test/java/org/thingsboard/server/controller/BaseTelemetryControllerTest.java @@ -0,0 +1,58 @@ +/** + * Copyright © 2016-2023 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.controller; + +import org.junit.Test; +import org.thingsboard.server.common.data.Device; +import org.thingsboard.server.common.data.SaveDeviceWithCredentialsRequest; +import org.thingsboard.server.common.data.security.DeviceCredentials; +import org.thingsboard.server.common.data.security.DeviceCredentialsType; + +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +public abstract class BaseTelemetryControllerTest extends AbstractControllerTest { + + @Test + public void testConstraintValidator() throws Exception { + loginTenantAdmin(); + Device device = createDevice(); + String correctRequestBody = "{\"data\": \"value\"}"; + doPostAsync("/api/plugins/telemetry/" + device.getId() + "/SHARED_SCOPE", correctRequestBody, String.class, status().isOk()); + doPostAsync("/api/plugins/telemetry/DEVICE/" + device.getId() + "/timeseries/smth", correctRequestBody, String.class, status().isOk()); + String invalidRequestBody = "{\"data\": \"alert(document)\\\">\"}"; + doPostAsync("/api/plugins/telemetry/" + device.getId() + "/SHARED_SCOPE", invalidRequestBody, String.class, status().isBadRequest()); + doPostAsync("/api/plugins/telemetry/DEVICE/" + device.getId() + "/timeseries/smth", invalidRequestBody, String.class, status().isBadRequest()); + invalidRequestBody = "{\"alert(document)\\\">\": \"data\"}"; + doPostAsync("/api/plugins/telemetry/" + device.getId() + "/SHARED_SCOPE", invalidRequestBody, String.class, status().isBadRequest()); + doPostAsync("/api/plugins/telemetry/DEVICE/" + device.getId() + "/timeseries/smth", invalidRequestBody, String.class, status().isBadRequest()); + } + + private Device createDevice() throws Exception { + String testToken = "TEST_TOKEN"; + + Device device = new Device(); + device.setName("My device"); + device.setType("default"); + + DeviceCredentials deviceCredentials = new DeviceCredentials(); + deviceCredentials.setCredentialsType(DeviceCredentialsType.ACCESS_TOKEN); + deviceCredentials.setCredentialsId(testToken); + + SaveDeviceWithCredentialsRequest saveRequest = new SaveDeviceWithCredentialsRequest(device, deviceCredentials); + + return readResponse(doPost("/api/device-with-credentials", saveRequest).andExpect(status().isOk()), Device.class); + } +} diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/notification/template/NotificationText.java b/application/src/test/java/org/thingsboard/server/controller/sql/TelemetryControllerSqlTest.java similarity index 68% rename from common/data/src/main/java/org/thingsboard/server/common/data/notification/template/NotificationText.java rename to application/src/test/java/org/thingsboard/server/controller/sql/TelemetryControllerSqlTest.java index 6cb0e673a5..b256f4fe1e 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/notification/template/NotificationText.java +++ b/application/src/test/java/org/thingsboard/server/controller/sql/TelemetryControllerSqlTest.java @@ -13,18 +13,11 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package org.thingsboard.server.common.data.notification.template; +package org.thingsboard.server.controller.sql; -import lombok.AllArgsConstructor; -import lombok.Data; -import lombok.NoArgsConstructor; - -@Data -@AllArgsConstructor -@NoArgsConstructor -public class NotificationText { - - private String body; - private String subject; +import org.thingsboard.server.controller.BaseTelemetryControllerTest; +import org.thingsboard.server.dao.service.DaoSqlTest; +@DaoSqlTest +public class TelemetryControllerSqlTest extends BaseTelemetryControllerTest { } 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 b618ac7718..8f05e6810f 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 @@ -108,6 +108,7 @@ public class EdgeImitator { updateEdgeClientFields("rpcPort", port); updateEdgeClientFields("timeoutSecs", 3); updateEdgeClientFields("keepAliveTimeSec", 300); + updateEdgeClientFields("keepAliveTimeoutSec", 5); updateEdgeClientFields("maxInboundMessageSize", 4194304); } diff --git a/application/src/test/java/org/thingsboard/server/service/device/provision/DeviceProvisionServiceTest.java b/application/src/test/java/org/thingsboard/server/service/device/provision/DeviceProvisionServiceTest.java new file mode 100644 index 0000000000..388058eef4 --- /dev/null +++ b/application/src/test/java/org/thingsboard/server/service/device/provision/DeviceProvisionServiceTest.java @@ -0,0 +1,266 @@ +/** + * Copyright © 2016-2023 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.device.provision; + + +import lombok.extern.slf4j.Slf4j; +import org.assertj.core.api.Assertions; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.boot.test.mock.mockito.SpyBean; +import org.springframework.test.context.ContextConfiguration; +import org.springframework.test.context.junit4.SpringRunner; +import org.thingsboard.server.cluster.TbClusterService; +import org.thingsboard.server.common.data.Device; +import org.thingsboard.server.common.data.DeviceProfile; +import org.thingsboard.server.common.data.DeviceProfileProvisionType; +import org.thingsboard.server.common.data.Tenant; +import org.thingsboard.server.common.data.device.credentials.ProvisionDeviceCredentialsData; +import org.thingsboard.server.common.data.device.profile.DeviceProfileData; +import org.thingsboard.server.common.data.device.profile.X509CertificateChainProvisionConfiguration; +import org.thingsboard.server.common.data.id.CustomerId; +import org.thingsboard.server.common.data.id.DeviceId; +import org.thingsboard.server.common.data.id.DeviceProfileId; +import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.common.data.security.DeviceCredentials; +import org.thingsboard.server.common.data.security.DeviceCredentialsType; +import org.thingsboard.server.common.msg.EncryptionUtil; +import org.thingsboard.server.common.transport.util.SslUtil; +import org.thingsboard.server.dao.attributes.AttributesService; +import org.thingsboard.server.dao.audit.AuditLogService; +import org.thingsboard.server.dao.device.DeviceCredentialsService; +import org.thingsboard.server.dao.device.DeviceProfileService; +import org.thingsboard.server.dao.device.DeviceService; +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.gen.transport.TransportProtos; +import org.thingsboard.server.queue.TbQueueProducer; +import org.thingsboard.server.queue.common.TbProtoQueueMsg; +import org.thingsboard.server.queue.discovery.PartitionService; +import org.thingsboard.server.queue.provider.TbQueueProducerProvider; +import org.thingsboard.server.service.device.DeviceProvisionServiceImpl;;import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Paths; +import java.util.ArrayList; +import java.util.List; +import java.util.UUID; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@Slf4j +@RunWith(SpringRunner.class) +@ContextConfiguration(classes = DeviceProvisionServiceImpl.class) +public class DeviceProvisionServiceTest { + + @MockBean + protected TbQueueProducerProvider producerProvider; + @MockBean + protected TbQueueProducer> ruleEngineMsgProducer; + @MockBean + protected TbClusterService clusterService; + @MockBean + protected DeviceProfileService deviceProfileService; + @MockBean + protected DeviceService deviceService; + @MockBean + protected DeviceCredentialsService deviceCredentialsService; + @MockBean + protected AttributesService attributesService; + @MockBean + protected AuditLogService auditLogService; + @MockBean + protected PartitionService partitionService; + @SpyBean + DeviceProvisionServiceImpl service; + + private String[] chain; + + @Before + public void setUp() { + String filePath = "src/test/resources/provision/x509ChainProvisionTest.pem"; + try { + String certificateChain = Files.readString(Paths.get(filePath)); + certificateChain = certTrimNewLinesForChainInDeviceProfile(certificateChain); + chain = fetchLeafCertificateFromChain(certificateChain); + } catch (IOException e) { + throw new RuntimeException(e); + } + } + + + @Test + public void provisionDeviceViaX509Certificate() { + var tenant = createTenant(); + var deviceProfile = createDeviceProfile(tenant.getId(), chain[1], true); + + var device = createDevice(tenant.getId(), deviceProfile.getId()); + when(deviceService.findDeviceByTenantIdAndName(any(), any())).thenReturn(device); + + var deviceCredentials = createDeviceCredentials(chain[0], device.getId()); + when(deviceCredentialsService.findDeviceCredentialsByDeviceId(any(), any())).thenReturn(deviceCredentials); + when(deviceCredentialsService.updateDeviceCredentials(any(), any())).thenReturn(deviceCredentials); + + ProvisionResponse response = service.provisionDeviceViaX509Chain(deviceProfile, createProvisionRequest(chain[0])); + + verify(deviceService, times(1)).findDeviceByTenantIdAndName(any(), any()); + verify(deviceCredentialsService, times(1)).findDeviceCredentialsByDeviceId(any(), any()); + verify(deviceCredentialsService, times(1)).updateDeviceCredentials(any(), any()); + + Assertions.assertThat(response.getResponseStatus()).isEqualTo(ProvisionResponseStatus.SUCCESS); + Assertions.assertThat(response.getDeviceCredentials()).isEqualTo(deviceCredentials); + } + + @Test + public void provisionDeviceWithIncorrectConfiguration() { + var tenant = createTenant(); + var deviceProfile = createDeviceProfile(tenant.getId(), chain[1], false); + + Assertions.assertThatThrownBy(() -> + service.provisionDeviceViaX509Chain(deviceProfile, createProvisionRequest(chain[0]))) + .isInstanceOf(ProvisionFailedException.class); + + verify(deviceService, times(1)).findDeviceByTenantIdAndName(any(), any()); + } + + @Test + public void matchDeviceNameFromX509CNCertificateByRegex() { + var tenant = createTenant(); + var deviceProfile = createDeviceProfile(tenant.getId(), chain[1], true); + X509CertificateChainProvisionConfiguration configuration = (X509CertificateChainProvisionConfiguration) deviceProfile.getProfileData().getProvisionConfiguration(); + String CN = getCNFromX509Certificate(chain[0]); + String deviceName = service.extractDeviceNameFromCNByRegEx(deviceProfile, CN, configuration.getCertificateRegExPattern()); + + Assertions.assertThat(deviceName).isNotBlank(); + Assertions.assertThat(deviceName).isEqualTo("deviceCertificate"); + } + + @Test + public void matchDeviceNameFromCNByRegex() { + var CN = "DeviceA.company.com"; + var regex = "(.*)\\.company.com"; + var result = service.extractDeviceNameFromCNByRegEx(null, CN, regex); + Assertions.assertThat(result).isNotBlank(); + Assertions.assertThat(result).isEqualTo("DeviceA"); + + CN = "DeviceA@company.com"; + regex = "(.*)@company.com"; + result = service.extractDeviceNameFromCNByRegEx(null, CN, regex); + Assertions.assertThat(result).isNotBlank(); + Assertions.assertThat(result).isEqualTo("DeviceA"); + + CN = "prefixDeviceAsuffix@company.com"; + regex = "prefix(.*)suffix@company.com"; + result = service.extractDeviceNameFromCNByRegEx(null, CN, regex); + Assertions.assertThat(result).isNotBlank(); + Assertions.assertThat(result).isEqualTo("DeviceA"); + + CN = "prefixDeviceAsufix@company.com"; + regex = "prefix(.*)sufix@company.com"; + result = service.extractDeviceNameFromCNByRegEx(null, CN, regex); + Assertions.assertThat(result).isNotBlank(); + Assertions.assertThat(result).isEqualTo("DeviceA"); + + CN = "region.DeviceA.220423@company.com"; + regex = "\\D+\\.(.*)\\.\\d+@company.com"; + result = service.extractDeviceNameFromCNByRegEx(null, CN, regex); + Assertions.assertThat(result).isNotBlank(); + Assertions.assertThat(result).isEqualTo("DeviceA"); + } + + private DeviceProfile createDeviceProfile(TenantId tenantId, String certificateValue, boolean isAllowToCreateNewDevices) { + X509CertificateChainProvisionConfiguration provision = new X509CertificateChainProvisionConfiguration(); + provision.setProvisionDeviceSecret(certificateValue); + provision.setCertificateRegExPattern("([^@]+)"); + provision.setAllowCreateNewDevicesByX509Certificate(isAllowToCreateNewDevices); + + DeviceProfileData deviceProfileData = new DeviceProfileData(); + deviceProfileData.setProvisionConfiguration(provision); + + DeviceProfile deviceProfile = new DeviceProfile(); + deviceProfile.setId(new DeviceProfileId(UUID.randomUUID())); + deviceProfile.setProfileData(deviceProfileData); + deviceProfile.setProvisionDeviceKey(EncryptionUtil.getSha3Hash(certificateValue)); + deviceProfile.setProvisionType(DeviceProfileProvisionType.X509_CERTIFICATE_CHAIN); + deviceProfile.setTenantId(tenantId); + return deviceProfile; + } + + private Device createDevice(TenantId tenantId, DeviceProfileId deviceProfileId) { + Device device = new Device(); + device.setTenantId(tenantId); + device.setId(new DeviceId(UUID.randomUUID())); + device.setDeviceProfileId(deviceProfileId); + device.setCustomerId(new CustomerId(UUID.randomUUID())); + return device; + } + + private Tenant createTenant() { + Tenant tenant = new Tenant(); + tenant.setId(new TenantId(UUID.randomUUID())); + return tenant; + } + + private DeviceCredentials createDeviceCredentials(String certificateValue, DeviceId deviceId) { + DeviceCredentials deviceCredentials = new DeviceCredentials(); + deviceCredentials.setDeviceId(deviceId); + deviceCredentials.setCredentialsValue(certificateValue); + deviceCredentials.setCredentialsId(EncryptionUtil.getSha3Hash(certificateValue)); + deviceCredentials.setCredentialsType(DeviceCredentialsType.X509_CERTIFICATE); + return deviceCredentials; + } + + private ProvisionRequest createProvisionRequest(String certificateValue) { + return new ProvisionRequest(null, DeviceCredentialsType.X509_CERTIFICATE, + new ProvisionDeviceCredentialsData(null, null, null, null, certificateValue), + null); + } + + public static String certTrimNewLinesForChainInDeviceProfile(String input) { + return input.replaceAll("\n", "") + .replaceAll("\r", "") + .replaceAll("-----BEGIN CERTIFICATE-----", "-----BEGIN CERTIFICATE-----\n") + .replaceAll("-----END CERTIFICATE-----", "\n-----END CERTIFICATE-----\n") + .trim(); + } + + private String[] fetchLeafCertificateFromChain(String value) { + List chain = new ArrayList<>(); + String regex = "-----BEGIN CERTIFICATE-----\\s*.*?\\s*-----END CERTIFICATE-----"; + Pattern pattern = Pattern.compile(regex); + Matcher matcher = pattern.matcher(value); + while (matcher.find()) { + chain.add(matcher.group(0)); + } + return chain.toArray(new String[0]); + } + + private String getCNFromX509Certificate(String x509Value) { + try { + return SslUtil.parseCommonName(SslUtil.readCertFile(x509Value)); + } catch (Exception e) { + return null; + } + } +} diff --git a/application/src/test/java/org/thingsboard/server/service/notification/NotificationApiTest.java b/application/src/test/java/org/thingsboard/server/service/notification/NotificationApiTest.java index 9dd34296ef..f6a296ab7e 100644 --- a/application/src/test/java/org/thingsboard/server/service/notification/NotificationApiTest.java +++ b/application/src/test/java/org/thingsboard/server/service/notification/NotificationApiTest.java @@ -40,6 +40,7 @@ import org.thingsboard.server.common.data.notification.targets.platform.Customer import org.thingsboard.server.common.data.notification.targets.platform.PlatformUsersNotificationTargetConfig; import org.thingsboard.server.common.data.notification.targets.platform.UserListFilter; import org.thingsboard.server.common.data.notification.targets.slack.SlackConversation; +import org.thingsboard.server.common.data.notification.targets.slack.SlackConversationType; import org.thingsboard.server.common.data.notification.targets.slack.SlackNotificationTargetConfig; import org.thingsboard.server.common.data.notification.template.DeliveryMethodNotificationTemplate; import org.thingsboard.server.common.data.notification.template.EmailDeliveryMethodNotificationTemplate; @@ -49,7 +50,6 @@ import org.thingsboard.server.common.data.notification.template.SlackDeliveryMet import org.thingsboard.server.common.data.notification.template.SmsDeliveryMethodNotificationTemplate; import org.thingsboard.server.common.data.notification.template.WebDeliveryMethodNotificationTemplate; import org.thingsboard.server.common.data.security.Authority; -import org.thingsboard.server.dao.DaoUtil; import org.thingsboard.server.dao.notification.NotificationDao; import org.thingsboard.server.dao.service.DaoSqlTest; import org.thingsboard.server.service.executors.DbCallbackExecutorService; @@ -226,13 +226,13 @@ public class NotificationApiTest extends AbstractNotificationApiTest { NotificationRequest notificationRequest = submitNotificationRequest(notificationTarget.getId(), notificationText, 5); assertThat(notificationRequest.getStatus()).isEqualTo(NotificationRequestStatus.SCHEDULED); await().atLeast(4, TimeUnit.SECONDS) - .atMost(6, TimeUnit.SECONDS) + .atMost(15, TimeUnit.SECONDS) .until(() -> wsClient.getLastMsg() != null); Notification delayedNotification = wsClient.getLastDataUpdate().getUpdate(); assertThat(delayedNotification).extracting(Notification::getText).isEqualTo(notificationText); assertThat(delayedNotification.getCreatedTime() - notificationRequest.getCreatedTime()) - .isCloseTo(TimeUnit.SECONDS.toMillis(5), Offset.offset(500L)); + .isCloseTo(TimeUnit.SECONDS.toMillis(5), Offset.offset(10000L)); assertThat(findNotificationRequest(notificationRequest.getId()).getStatus()).isEqualTo(NotificationRequestStatus.SENT); } @@ -324,16 +324,17 @@ public class NotificationApiTest extends AbstractNotificationApiTest { @Test public void testNotificationRequestPreview() throws Exception { - NotificationTarget target1 = new NotificationTarget(); - target1.setName("Me"); - PlatformUsersNotificationTargetConfig target1Config = new PlatformUsersNotificationTargetConfig(); + NotificationTarget tenantAdminTarget = new NotificationTarget(); + tenantAdminTarget.setName("Me"); + PlatformUsersNotificationTargetConfig tenantAdminTargetConfig = new PlatformUsersNotificationTargetConfig(); UserListFilter userListFilter = new UserListFilter(); - userListFilter.setUsersIds(DaoUtil.toUUIDs(List.of(tenantAdminUserId))); - target1Config.setUsersFilter(userListFilter); - target1.setConfiguration(target1Config); - target1 = saveNotificationTarget(target1); + userListFilter.setUsersIds(List.of(tenantAdminUserId.getId())); + tenantAdminTargetConfig.setUsersFilter(userListFilter); + tenantAdminTarget.setConfiguration(tenantAdminTargetConfig); + tenantAdminTarget = saveNotificationTarget(tenantAdminTarget); List recipients = new ArrayList<>(); recipients.add(TENANT_ADMIN_EMAIL); + String firstRecipientEmail = TENANT_ADMIN_EMAIL; createDifferentCustomer(); loginTenantAdmin(); @@ -347,21 +348,32 @@ public class NotificationApiTest extends AbstractNotificationApiTest { customerUser = createUser(customerUser, "12345678"); recipients.add(customerUser.getEmail()); } - NotificationTarget target2 = new NotificationTarget(); - target2.setName("Other customer users"); - PlatformUsersNotificationTargetConfig target2Config = new PlatformUsersNotificationTargetConfig(); + NotificationTarget customerUsersTarget = new NotificationTarget(); + customerUsersTarget.setName("Other customer users"); + PlatformUsersNotificationTargetConfig customerUsersTargetConfig = new PlatformUsersNotificationTargetConfig(); CustomerUsersFilter customerUsersFilter = new CustomerUsersFilter(); customerUsersFilter.setCustomerId(differentCustomerId.getId()); - target2Config.setUsersFilter(customerUsersFilter); - target2.setConfiguration(target2Config); - target2 = saveNotificationTarget(target2); - + customerUsersTargetConfig.setUsersFilter(customerUsersFilter); + customerUsersTarget.setConfiguration(customerUsersTargetConfig); + customerUsersTarget = saveNotificationTarget(customerUsersTarget); + + NotificationTarget slackTarget = new NotificationTarget(); + slackTarget.setName("Slack user"); + SlackNotificationTargetConfig slackTargetConfig = new SlackNotificationTargetConfig(); + slackTargetConfig.setConversationType(SlackConversationType.DIRECT); + SlackConversation slackConversation = new SlackConversation(); + slackConversation.setId("U1234567"); + slackConversation.setTitle("@jdoe (John Doe)"); + slackConversation.setWholeName("John Doe"); + slackTargetConfig.setConversation(slackConversation); + slackTarget.setConfiguration(slackTargetConfig); + slackTarget = saveNotificationTarget(slackTarget); + recipients.add(slackConversation.getTitle()); NotificationTemplate notificationTemplate = new NotificationTemplate(); notificationTemplate.setNotificationType(NotificationType.GENERAL); notificationTemplate.setName("Test template"); - String requestorEmail = TENANT_ADMIN_EMAIL; NotificationTemplateConfig templateConfig = new NotificationTemplateConfig(); HashMap templates = new HashMap<>(); templateConfig.setDeliveryMethodsTemplates(templates); @@ -369,69 +381,59 @@ public class NotificationApiTest extends AbstractNotificationApiTest { WebDeliveryMethodNotificationTemplate webNotificationTemplate = new WebDeliveryMethodNotificationTemplate(); webNotificationTemplate.setEnabled(true); - webNotificationTemplate.setBody("Message for WEB: ${recipientEmail} ${unknownParam}"); - webNotificationTemplate.setSubject("Subject for WEB: ${recipientEmail}"); + webNotificationTemplate.setSubject("WEB SUBJECT: ${recipientEmail}"); + webNotificationTemplate.setBody("WEB: ${recipientEmail} ${unknownParam}"); templates.put(NotificationDeliveryMethod.WEB, webNotificationTemplate); SmsDeliveryMethodNotificationTemplate smsNotificationTemplate = new SmsDeliveryMethodNotificationTemplate(); smsNotificationTemplate.setEnabled(true); - smsNotificationTemplate.setBody("Message for SMS: ${recipientEmail}"); + smsNotificationTemplate.setBody("SMS: ${recipientEmail}"); templates.put(NotificationDeliveryMethod.SMS, smsNotificationTemplate); EmailDeliveryMethodNotificationTemplate emailNotificationTemplate = new EmailDeliveryMethodNotificationTemplate(); emailNotificationTemplate.setEnabled(true); - emailNotificationTemplate.setSubject("Subject for EMAIL: ${recipientEmail}"); - emailNotificationTemplate.setBody("Message for EMAIL: ${recipientEmail}"); + emailNotificationTemplate.setSubject("EMAIL SUBJECT: ${recipientEmail}"); + emailNotificationTemplate.setBody("EMAIL: ${recipientEmail}"); templates.put(NotificationDeliveryMethod.EMAIL, emailNotificationTemplate); SlackDeliveryMethodNotificationTemplate slackNotificationTemplate = new SlackDeliveryMethodNotificationTemplate(); slackNotificationTemplate.setEnabled(true); - slackNotificationTemplate.setBody("Message for SLACK: ${recipientEmail}"); + slackNotificationTemplate.setBody("SLACK: ${recipientFirstName} ${recipientLastName}"); templates.put(NotificationDeliveryMethod.SLACK, slackNotificationTemplate); notificationTemplate = saveNotificationTemplate(notificationTemplate); - NotificationRequest notificationRequest = new NotificationRequest(); - notificationRequest.setTargets(List.of(target1.getUuidId(), target2.getUuidId())); + notificationRequest.setTargets(List.of(tenantAdminTarget.getUuidId(), customerUsersTarget.getUuidId(), slackTarget.getUuidId())); notificationRequest.setTemplateId(notificationTemplate.getId()); notificationRequest.setAdditionalConfig(new NotificationRequestConfig()); NotificationRequestPreview preview = doPost("/api/notification/request/preview", notificationRequest, NotificationRequestPreview.class); - assertThat(preview.getRecipientsCountByTarget().get(target1.getName())).isEqualTo(1); - assertThat(preview.getRecipientsCountByTarget().get(target2.getName())).isEqualTo(customerUsersCount); - assertThat(preview.getTotalRecipientsCount()).isEqualTo(1 + customerUsersCount); + assertThat(preview.getRecipientsCountByTarget().get(tenantAdminTarget.getName())).isEqualTo(1); + assertThat(preview.getRecipientsCountByTarget().get(customerUsersTarget.getName())).isEqualTo(customerUsersCount); + assertThat(preview.getRecipientsCountByTarget().get(slackTarget.getName())).isEqualTo(1); + + assertThat(preview.getTotalRecipientsCount()).isEqualTo(2 + customerUsersCount); assertThat(preview.getRecipientsPreview()).containsAll(recipients); Map processedTemplates = preview.getProcessedTemplates(); assertThat(processedTemplates.get(NotificationDeliveryMethod.WEB)).asInstanceOf(type(WebDeliveryMethodNotificationTemplate.class)) .satisfies(template -> { - assertThat(template.getBody()) - .startsWith("Message for WEB") - .endsWith(requestorEmail + " ${unknownParam}"); - assertThat(template.getSubject()) - .startsWith("Subject for WEB") - .endsWith(requestorEmail); + assertThat(template.getSubject()).isEqualTo("WEB SUBJECT: " + firstRecipientEmail); + assertThat(template.getBody()).isEqualTo("WEB: " + firstRecipientEmail + " ${unknownParam}"); }); assertThat(processedTemplates.get(NotificationDeliveryMethod.SMS)).asInstanceOf(type(SmsDeliveryMethodNotificationTemplate.class)) .satisfies(template -> { - assertThat(template.getBody()) - .startsWith("Message for SMS") - .endsWith(requestorEmail); + assertThat(template.getBody()).isEqualTo("SMS: " + firstRecipientEmail); }); assertThat(processedTemplates.get(NotificationDeliveryMethod.EMAIL)).asInstanceOf(type(EmailDeliveryMethodNotificationTemplate.class)) .satisfies(template -> { - assertThat(template.getBody()) - .startsWith("Message for EMAIL") - .endsWith(requestorEmail); - assertThat(template.getSubject()) - .startsWith("Subject for EMAIL") - .endsWith(requestorEmail); + assertThat(template.getSubject()).isEqualTo("EMAIL SUBJECT: " + firstRecipientEmail); + assertThat(template.getBody()).isEqualTo("EMAIL: " + firstRecipientEmail); }); assertThat(processedTemplates.get(NotificationDeliveryMethod.SLACK)).asInstanceOf(type(SlackDeliveryMethodNotificationTemplate.class)) .satisfies(template -> { - assertThat(template.getBody()) - .isEqualTo("Message for SLACK: ${recipientEmail}"); // ${recipientEmail} should not be processed + assertThat(template.getBody()).isEqualTo("SLACK: John Doe"); }); } diff --git a/application/src/test/java/org/thingsboard/server/service/notification/NotificationRuleApiTest.java b/application/src/test/java/org/thingsboard/server/service/notification/NotificationRuleApiTest.java index 5d36050391..8fec807d45 100644 --- a/application/src/test/java/org/thingsboard/server/service/notification/NotificationRuleApiTest.java +++ b/application/src/test/java/org/thingsboard/server/service/notification/NotificationRuleApiTest.java @@ -23,14 +23,12 @@ import org.junit.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.mock.mockito.SpyBean; import org.springframework.data.util.Pair; -import org.springframework.test.context.TestPropertySource; import org.thingsboard.common.util.JacksonUtil; -import org.thingsboard.rule.engine.debug.TbMsgGeneratorNode; -import org.thingsboard.rule.engine.debug.TbMsgGeneratorNodeConfiguration; import org.thingsboard.server.common.data.DataConstants; 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.TenantProfile; import org.thingsboard.server.common.data.User; import org.thingsboard.server.common.data.alarm.Alarm; import org.thingsboard.server.common.data.alarm.AlarmSearchStatus; @@ -43,8 +41,7 @@ import org.thingsboard.server.common.data.device.profile.AlarmConditionKeyType; import org.thingsboard.server.common.data.device.profile.AlarmRule; import org.thingsboard.server.common.data.device.profile.DeviceProfileAlarm; import org.thingsboard.server.common.data.device.profile.SimpleAlarmConditionSpec; -import org.thingsboard.server.common.data.id.NotificationRuleId; -import org.thingsboard.server.common.data.id.RuleChainId; +import org.thingsboard.server.common.data.id.TenantId; import org.thingsboard.server.common.data.notification.Notification; import org.thingsboard.server.common.data.notification.NotificationDeliveryMethod; import org.thingsboard.server.common.data.notification.NotificationRequest; @@ -59,25 +56,21 @@ import org.thingsboard.server.common.data.notification.rule.trigger.AlarmNotific import org.thingsboard.server.common.data.notification.rule.trigger.AlarmNotificationRuleTriggerConfig.AlarmAction; import org.thingsboard.server.common.data.notification.rule.trigger.EntityActionNotificationRuleTriggerConfig; import org.thingsboard.server.common.data.notification.rule.trigger.NotificationRuleTriggerType; -import org.thingsboard.server.common.data.notification.rule.trigger.RuleEngineComponentLifecycleEventNotificationRuleTriggerConfig; import org.thingsboard.server.common.data.notification.targets.NotificationTarget; import org.thingsboard.server.common.data.notification.template.NotificationTemplate; import org.thingsboard.server.common.data.page.PageData; import org.thingsboard.server.common.data.page.PageLink; -import org.thingsboard.server.common.data.plugin.ComponentLifecycleEvent; import org.thingsboard.server.common.data.query.BooleanFilterPredicate; import org.thingsboard.server.common.data.query.EntityKeyValueType; import org.thingsboard.server.common.data.query.FilterPredicateValue; -import org.thingsboard.server.common.data.rule.RuleChain; -import org.thingsboard.server.common.data.rule.RuleChainMetaData; -import org.thingsboard.server.common.data.rule.RuleNode; -import org.thingsboard.server.common.data.script.ScriptLanguage; import org.thingsboard.server.common.data.security.Authority; -import org.thingsboard.server.dao.alarm.AlarmService; +import org.thingsboard.server.common.data.tenant.profile.DefaultTenantProfileConfiguration; +import org.thingsboard.server.common.data.tenant.profile.TenantProfileData; import org.thingsboard.server.dao.notification.NotificationRequestService; -import org.thingsboard.server.dao.notification.NotificationRuleService; -import org.thingsboard.server.dao.notification.NotificationTemplateService; import org.thingsboard.server.dao.service.DaoSqlTest; +import org.thingsboard.server.dao.tenant.TenantProfileService; +import org.thingsboard.server.service.apiusage.limits.LimitedApi; +import org.thingsboard.server.service.apiusage.limits.RateLimitService; import org.thingsboard.server.service.telemetry.AlarmSubscriptionService; import java.util.ArrayList; @@ -96,9 +89,6 @@ import static org.awaitility.Awaitility.await; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; @DaoSqlTest -@TestPropertySource(properties = { - "js.evaluator=local" -}) public class NotificationRuleApiTest extends AbstractNotificationApiTest { @SpyBean @@ -106,18 +96,13 @@ public class NotificationRuleApiTest extends AbstractNotificationApiTest { @Autowired private NotificationRequestService notificationRequestService; @Autowired - private NotificationRuleService notificationRuleService; + private TenantProfileService tenantProfileService; @Autowired - private NotificationTemplateService notificationTemplateService; - - @SpyBean - private AlarmService alarmService; + private RateLimitService rateLimitService; @Before public void beforeEach() throws Exception { loginTenantAdmin(); - notificationRuleService.deleteNotificationRulesByTenantId(tenantId); - notificationTemplateService.deleteNotificationTemplatesByTenantId(tenantId); } @Test @@ -208,7 +193,7 @@ public class NotificationRuleApiTest extends AbstractNotificationApiTest { String alarmType = "myBoolIsTrue"; - DeviceProfile deviceProfile = createDeviceProfileWithAlarmRules(notificationRule.getId(), alarmType); + DeviceProfile deviceProfile = createDeviceProfileWithAlarmRules(alarmType); Device device = createDevice("Device 1", deviceProfile.getName(), "1234"); clients.values().forEach(wsClient -> { @@ -272,7 +257,7 @@ public class NotificationRuleApiTest extends AbstractNotificationApiTest { notificationRule.setTriggerType(NotificationRuleTriggerType.ALARM); String alarmType = "myBoolIsTrue"; - DeviceProfile deviceProfile = createDeviceProfileWithAlarmRules(notificationRule.getId(), alarmType); + DeviceProfile deviceProfile = createDeviceProfileWithAlarmRules(alarmType); Device device = createDevice("Device 1", deviceProfile.getName(), "1234"); AlarmNotificationRuleTriggerConfig triggerConfig = new AlarmNotificationRuleTriggerConfig(); @@ -352,7 +337,56 @@ public class NotificationRuleApiTest extends AbstractNotificationApiTest { assertThat(ruleInfo.getDeliveryMethods()).containsOnly(deliveryMethods); } - private DeviceProfile createDeviceProfileWithAlarmRules(NotificationRuleId notificationRuleId, String alarmType) { + @Test + public void testNotificationRequestsPerRuleRateLimits() throws Exception { + int notificationRequestsLimit = 10; + TenantProfile tenantProfile = tenantProfileService.findDefaultTenantProfile(TenantId.SYS_TENANT_ID); + TenantProfileData profileData = tenantProfile.getProfileData(); + DefaultTenantProfileConfiguration profileConfiguration = (DefaultTenantProfileConfiguration) profileData.getConfiguration(); + profileConfiguration.setTenantNotificationRequestsPerRuleRateLimit(notificationRequestsLimit + ":300"); + tenantProfile.setProfileData(profileData); + loginSysAdmin(); + doPost("/api/tenantProfile", tenantProfile).andExpect(status().isOk()); + loginTenantAdmin(); + + NotificationRule rule = new NotificationRule(); + rule.setName("Device created"); + rule.setTriggerType(NotificationRuleTriggerType.ENTITY_ACTION); + NotificationTemplate template = createNotificationTemplate(NotificationType.ENTITY_ACTION, "Device created", "Device created", + NotificationDeliveryMethod.WEB, NotificationDeliveryMethod.SMS); + rule.setTemplateId(template.getId()); + EntityActionNotificationRuleTriggerConfig triggerConfig = new EntityActionNotificationRuleTriggerConfig(); + triggerConfig.setEntityTypes(Set.of(EntityType.DEVICE)); + triggerConfig.setCreated(true); + rule.setTriggerConfig(triggerConfig); + NotificationTarget target = createNotificationTarget(tenantAdminUserId); + DefaultNotificationRuleRecipientsConfig recipientsConfig = new DefaultNotificationRuleRecipientsConfig(); + recipientsConfig.setTriggerType(NotificationRuleTriggerType.ENTITY_ACTION); + recipientsConfig.setTargets(List.of(target.getUuidId())); + rule.setRecipientsConfig(recipientsConfig); + rule = saveNotificationRule(rule); + + for (int i = 0; i < notificationRequestsLimit; i++) { + String name = "device " + i; + createDevice(name, name); + } + await().atMost(5, TimeUnit.SECONDS) + .untilAsserted(() -> { + assertThat(getMyNotifications(false, 100)).size().isEqualTo(notificationRequestsLimit); + }); + for (int i = 0; i < 5; i++) { + String name = "device " + (notificationRequestsLimit + i); + createDevice(name, name); + } + + boolean rateLimitExceeded = !rateLimitService.checkRateLimit(LimitedApi.NOTIFICATION_REQUESTS_PER_RULE, tenantId, rule.getId()); + assertThat(rateLimitExceeded).isTrue(); + + TimeUnit.SECONDS.sleep(3); + assertThat(getMyNotifications(false, 100)).size().isEqualTo(notificationRequestsLimit); + } + + private DeviceProfile createDeviceProfileWithAlarmRules(String alarmType) { DeviceProfile deviceProfile = createDeviceProfile("For notification rule test"); deviceProfile.setTenantId(tenantId); @@ -387,41 +421,6 @@ public class NotificationRuleApiTest extends AbstractNotificationApiTest { return deviceProfile; } - private RuleChain createEmptyRuleChain(String name) { - RuleChain ruleChain = new RuleChain(); - ruleChain.setName(name); - ruleChain.setTenantId(tenantId); - ruleChain.setRoot(false); - ruleChain.setDebugMode(false); - ruleChain = doPost("/api/ruleChain", ruleChain, RuleChain.class); - - RuleChainMetaData metaData = new RuleChainMetaData(); - metaData.setRuleChainId(ruleChain.getId()); - metaData.setNodes(List.of()); - metaData = doPost("/api/ruleChain/metadata", metaData, RuleChainMetaData.class); - return ruleChain; - } - - private RuleNode addRuleNodeWithError(RuleChainId ruleChainId, String name) { - RuleChainMetaData metaData = new RuleChainMetaData(); - metaData.setRuleChainId(ruleChainId); - - RuleNode generatorNodeWithError = new RuleNode(); - generatorNodeWithError.setName(name); - generatorNodeWithError.setType(TbMsgGeneratorNode.class.getName()); - TbMsgGeneratorNodeConfiguration generatorNodeConfiguration = new TbMsgGeneratorNodeConfiguration(); - generatorNodeConfiguration.setScriptLang(ScriptLanguage.JS); - generatorNodeConfiguration.setPeriodInSeconds(1000); - generatorNodeConfiguration.setMsgCount(1); - generatorNodeConfiguration.setJsScript("[return"); - generatorNodeWithError.setConfiguration(JacksonUtil.valueToTree(generatorNodeConfiguration)); - - metaData.setNodes(List.of(generatorNodeWithError)); - metaData.setFirstNodeIndex(0); - metaData = doPost("/api/ruleChain/metadata", metaData, RuleChainMetaData.class); - return metaData.getNodes().get(0); - } - private NotificationRule saveNotificationRule(NotificationRule notificationRule) { return doPost("/api/notification/rule", notificationRule, NotificationRule.class); } diff --git a/application/src/test/java/org/thingsboard/server/service/notification/NotificationTemplateApiTest.java b/application/src/test/java/org/thingsboard/server/service/notification/NotificationTemplateApiTest.java index 1b843d61b4..ce42298d9f 100644 --- a/application/src/test/java/org/thingsboard/server/service/notification/NotificationTemplateApiTest.java +++ b/application/src/test/java/org/thingsboard/server/service/notification/NotificationTemplateApiTest.java @@ -19,7 +19,6 @@ import com.fasterxml.jackson.core.type.TypeReference; import org.apache.commons.lang3.StringUtils; import org.junit.Before; import org.junit.Test; -import org.springframework.beans.factory.annotation.Autowired; import org.springframework.test.web.servlet.ResultActions; import org.springframework.test.web.servlet.ResultMatcher; import org.thingsboard.server.common.data.id.IdBased; @@ -30,8 +29,6 @@ import org.thingsboard.server.common.data.notification.template.NotificationTemp import org.thingsboard.server.common.data.notification.template.NotificationTemplateConfig; import org.thingsboard.server.common.data.page.PageData; import org.thingsboard.server.common.data.page.PageLink; -import org.thingsboard.server.dao.notification.NotificationRuleService; -import org.thingsboard.server.dao.notification.NotificationTemplateService; import org.thingsboard.server.dao.service.DaoSqlTest; import java.util.List; @@ -43,16 +40,9 @@ import static org.springframework.test.web.servlet.result.MockMvcResultMatchers. @DaoSqlTest public class NotificationTemplateApiTest extends AbstractNotificationApiTest { - @Autowired - private NotificationTemplateService templateService; - @Autowired - private NotificationRuleService notificationRuleService; - @Before public void beforeEach() throws Exception { loginTenantAdmin(); - notificationRuleService.deleteNotificationRulesByTenantId(tenantId); - templateService.deleteNotificationTemplatesByTenantId(tenantId); } @Test diff --git a/application/src/test/java/org/thingsboard/server/service/transport/DefaultTransportApiServiceTest.java b/application/src/test/java/org/thingsboard/server/service/transport/DefaultTransportApiServiceTest.java new file mode 100644 index 0000000000..9687225bd1 --- /dev/null +++ b/application/src/test/java/org/thingsboard/server/service/transport/DefaultTransportApiServiceTest.java @@ -0,0 +1,211 @@ +/** + * Copyright © 2016-2023 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.transport; + + +import com.google.common.util.concurrent.Futures; +import lombok.extern.slf4j.Slf4j; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.boot.test.mock.mockito.SpyBean; +import org.springframework.test.context.ContextConfiguration; +import org.springframework.test.context.junit4.SpringRunner; +import org.thingsboard.server.cache.ota.OtaPackageDataCache; +import org.thingsboard.server.cluster.TbClusterService; +import org.thingsboard.server.common.data.Device; +import org.thingsboard.server.common.data.DeviceProfile; +import org.thingsboard.server.common.data.DeviceProfileProvisionType; +import org.thingsboard.server.common.data.device.profile.DeviceProfileData; +import org.thingsboard.server.common.data.device.profile.X509CertificateChainProvisionConfiguration; +import org.thingsboard.server.common.data.id.DeviceId; +import org.thingsboard.server.common.data.security.DeviceCredentials; +import org.thingsboard.server.common.data.security.DeviceCredentialsType; +import org.thingsboard.server.common.msg.EncryptionUtil; +import org.thingsboard.server.dao.device.DeviceCredentialsService; +import org.thingsboard.server.dao.device.DeviceProfileService; +import org.thingsboard.server.dao.device.DeviceProvisionService; +import org.thingsboard.server.dao.device.DeviceService; +import org.thingsboard.server.dao.device.provision.ProvisionResponse; +import org.thingsboard.server.dao.device.provision.ProvisionResponseStatus; +import org.thingsboard.server.dao.ota.OtaPackageService; +import org.thingsboard.server.dao.queue.QueueService; +import org.thingsboard.server.dao.relation.RelationService; +import org.thingsboard.server.dao.tenant.TbTenantProfileCache; +import org.thingsboard.server.queue.util.DataDecodingEncodingService; +import org.thingsboard.server.service.apiusage.TbApiUsageStateService; +import org.thingsboard.server.service.executors.DbCallbackExecutorService; +import org.thingsboard.server.service.profile.TbDeviceProfileCache; +import org.thingsboard.server.service.resource.TbResourceService; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Paths; +import java.util.ArrayList; +import java.util.List; +import java.util.UUID; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@Slf4j +@RunWith(SpringRunner.class) +@ContextConfiguration(classes = DefaultTransportApiService.class) +public class DefaultTransportApiServiceTest { + + @MockBean + protected TbDeviceProfileCache deviceProfileCache; + @MockBean + protected TbTenantProfileCache tenantProfileCache; + @MockBean + protected TbApiUsageStateService apiUsageStateService; + @MockBean + protected DeviceService deviceService; + @MockBean + protected DeviceProfileService deviceProfileService; + @MockBean + protected RelationService relationService; + @MockBean + protected DeviceCredentialsService deviceCredentialsService; + @MockBean + protected DbCallbackExecutorService dbCallbackExecutorService; + @MockBean + protected TbClusterService tbClusterService; + @MockBean + protected DataDecodingEncodingService dataDecodingEncodingService; + @MockBean + protected DeviceProvisionService deviceProvisionService; + @MockBean + protected TbResourceService resourceService; + @MockBean + protected OtaPackageService otaPackageService; + @MockBean + protected OtaPackageDataCache otaPackageDataCache; + @MockBean + protected QueueService queueService; + @SpyBean + DefaultTransportApiService service; + + private String certificateChain; + private String[] chain; + + @Before + public void setUp() { + + String filePath = "src/test/resources/provision/x509ChainProvisionTest.pem"; + try { + certificateChain = Files.readString(Paths.get(filePath)); + certificateChain = certTrimNewLinesForChainInDeviceProfile(certificateChain); + chain = fetchLeafCertificateFromChain(certificateChain); + } catch (IOException e) { + throw new RuntimeException(e); + } + } + + @Test + public void validateExistingDeviceByX509CertificateStrategy() { + var device = createDevice(); + when(deviceService.findDeviceByIdAsync(any(), any())).thenReturn(Futures.immediateFuture(device)); + + var deviceCredentials = createDeviceCredentials(chain[0], device.getId()); + when(deviceCredentialsService.findDeviceCredentialsByCredentialsId(any())).thenReturn(deviceCredentials); + + service.validateOrCreateDeviceX509Certificate(certificateChain); + verify(deviceCredentialsService, times(1)).findDeviceCredentialsByCredentialsId(any()); + } + + @Test + public void provisionDeviceX509Certificate() { + var deviceProfile = createDeviceProfile(chain[1]); + when(deviceProfileService.findDeviceProfileByProvisionDeviceKey(any())).thenReturn(deviceProfile); + + var device = createDevice(); + when(deviceService.findDeviceByTenantIdAndName(any(), any())).thenReturn(device); + when(deviceService.findDeviceByIdAsync(any(), any())).thenReturn(Futures.immediateFuture(device)); + + var deviceCredentials = createDeviceCredentials(chain[0], device.getId()); + when(deviceCredentialsService.findDeviceCredentialsByCredentialsId(any())).thenReturn(null); + when(deviceCredentialsService.updateDeviceCredentials(any(), any())).thenReturn(deviceCredentials); + + var provisionResponse = createProvisionResponse(deviceCredentials); + when(deviceProvisionService.provisionDeviceViaX509Chain(any(), any())).thenReturn(provisionResponse); + + service.validateOrCreateDeviceX509Certificate(certificateChain); + verify(deviceProfileService, times(1)).findDeviceProfileByProvisionDeviceKey(any()); + verify(deviceService, times(1)).findDeviceByIdAsync(any(), any()); + verify(deviceCredentialsService, times(1)).findDeviceCredentialsByCredentialsId(any()); + verify(deviceProvisionService, times(1)).provisionDeviceViaX509Chain(any(), any()); + } + + private DeviceProfile createDeviceProfile(String certificateValue) { + X509CertificateChainProvisionConfiguration provision = new X509CertificateChainProvisionConfiguration(); + provision.setProvisionDeviceSecret(certificateValue); + provision.setCertificateRegExPattern("([^@]+)"); + provision.setAllowCreateNewDevicesByX509Certificate(true); + + DeviceProfileData deviceProfileData = new DeviceProfileData(); + deviceProfileData.setProvisionConfiguration(provision); + + DeviceProfile deviceProfile = new DeviceProfile(); + deviceProfile.setProfileData(deviceProfileData); + deviceProfile.setProvisionDeviceKey(EncryptionUtil.getSha3Hash(certificateValue)); + deviceProfile.setProvisionType(DeviceProfileProvisionType.X509_CERTIFICATE_CHAIN); + return deviceProfile; + } + + private DeviceCredentials createDeviceCredentials(String certificateValue, DeviceId deviceId) { + DeviceCredentials deviceCredentials = new DeviceCredentials(); + deviceCredentials.setDeviceId(deviceId); + deviceCredentials.setCredentialsValue(certificateValue); + deviceCredentials.setCredentialsId(EncryptionUtil.getSha3Hash(certificateValue)); + deviceCredentials.setCredentialsType(DeviceCredentialsType.X509_CERTIFICATE); + return deviceCredentials; + } + + private Device createDevice() { + Device device = new Device(); + device.setId(new DeviceId(UUID.randomUUID())); + return device; + } + + private ProvisionResponse createProvisionResponse(DeviceCredentials deviceCredentials) { + return new ProvisionResponse(deviceCredentials, ProvisionResponseStatus.SUCCESS); + } + + public static String certTrimNewLinesForChainInDeviceProfile(String input) { + return input.replaceAll("\n", "") + .replaceAll("\r", "") + .replaceAll("-----BEGIN CERTIFICATE-----", "-----BEGIN CERTIFICATE-----\n") + .replaceAll("-----END CERTIFICATE-----", "\n-----END CERTIFICATE-----\n") + .trim(); + } + + private String[] fetchLeafCertificateFromChain(String value) { + List chain = new ArrayList<>(); + String regex = "-----BEGIN CERTIFICATE-----\\s*.*?\\s*-----END CERTIFICATE-----"; + Pattern pattern = Pattern.compile(regex); + Matcher matcher = pattern.matcher(value); + while (matcher.find()) { + chain.add(matcher.group(0)); + } + return chain.toArray(new String[0]); + } +} diff --git a/application/src/test/resources/provision/x509ChainProvisionTest.pem b/application/src/test/resources/provision/x509ChainProvisionTest.pem new file mode 100644 index 0000000000..b2ec300f78 --- /dev/null +++ b/application/src/test/resources/provision/x509ChainProvisionTest.pem @@ -0,0 +1,28 @@ +-----BEGIN CERTIFICATE----- +MIICMTCCAdegAwIBAgIUI9dBuwN6pTtK6uZ03rkiCwV4wEYwCgYIKoZIzj0EAwIw +bjELMAkGA1UEBhMCVVMxETAPBgNVBAgMCE5ldyBZb3JrMRowGAYDVQQKDBFUaGlu +Z3NCb2FyZCwgSW5jLjEwMC4GA1UEAwwnZGV2aWNlQ2VydGlmaWNhdGVAWDUwOVBy +b3Zpc2lvblN0cmF0ZWd5MB4XDTIzMDMyOTE0NTYxN1oXDTI0MDMyODE0NTYxN1ow +bjELMAkGA1UEBhMCVVMxETAPBgNVBAgMCE5ldyBZb3JrMRowGAYDVQQKDBFUaGlu +Z3NCb2FyZCwgSW5jLjEwMC4GA1UEAwwnZGV2aWNlQ2VydGlmaWNhdGVAWDUwOVBy +b3Zpc2lvblN0cmF0ZWd5MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAE9Zo791qK +QiGNBm11r4ZGxh+w+ossZL3xc46ufq5QckQHP7zkD2XDAcmP5GvdkM1sBFN9AWaC +kQfNnWmfERsOOKNTMFEwHQYDVR0OBBYEFFFc5uyCyglQoZiKhzXzMcQ3BKORMB8G +A1UdIwQYMBaAFFFc5uyCyglQoZiKhzXzMcQ3BKORMA8GA1UdEwEB/wQFMAMBAf8w +CgYIKoZIzj0EAwIDSAAwRQIhANbA9CuhoOifZMMmqkpuld+65CR+ItKdXeRAhLMZ +uccuAiB0FSQB34zMutXrZj1g8Gl5OkE7YryFHbei1z0SveHR8g== +-----END CERTIFICATE----- +-----BEGIN CERTIFICATE----- +MIICMTCCAdegAwIBAgIUUEKxS9hTz4l+oLUMF0LV6TC/gCIwCgYIKoZIzj0EAwIw +bjELMAkGA1UEBhMCVVMxETAPBgNVBAgMCE5ldyBZb3JrMRowGAYDVQQKDBFUaGlu +Z3NCb2FyZCwgSW5jLjEwMC4GA1UEAwwnZGV2aWNlUHJvZmlsZUNlcnRAWDUwOVBy +b3Zpc2lvblN0cmF0ZWd5MB4XDTIzMDMyOTE0NTczNloXDTI0MDMyODE0NTczNlow +bjELMAkGA1UEBhMCVVMxETAPBgNVBAgMCE5ldyBZb3JrMRowGAYDVQQKDBFUaGlu +Z3NCb2FyZCwgSW5jLjEwMC4GA1UEAwwnZGV2aWNlUHJvZmlsZUNlcnRAWDUwOVBy +b3Zpc2lvblN0cmF0ZWd5MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAECMlWO72k +rDoUL9FQjUmSCetkhaEGJUfQkdSfkLSNa0GyAEIMbfmzI4zITeapunu4rGet3EMy +LydQzuQanBicp6NTMFEwHQYDVR0OBBYEFHpZ78tPnztNii4Da/yCw6mhEIL3MB8G +A1UdIwQYMBaAFHpZ78tPnztNii4Da/yCw6mhEIL3MA8GA1UdEwEB/wQFMAMBAf8w +CgYIKoZIzj0EAwIDSAAwRQIgJ7qyMFqNcwSYkH6o+UlQXzLWfwZbNjVk+aR7foAZ +NGsCIQDsd7v3WQIGHiArfZeDs1DLEDuV/2h6L+ZNoGNhEKL+1A== +-----END CERTIFICATE----- diff --git a/common/cluster-api/src/main/proto/queue.proto b/common/cluster-api/src/main/proto/queue.proto index 65a57a5cc5..5198c6fc8d 100644 --- a/common/cluster-api/src/main/proto/queue.proto +++ b/common/cluster-api/src/main/proto/queue.proto @@ -179,6 +179,10 @@ message ValidateDeviceX509CertRequestMsg { string hash = 1; } +message ValidateOrCreateDeviceX509CertRequestMsg { + string certificateChain = 1; +} + message ValidateBasicMqttCredRequestMsg { string clientId = 1; string userName = 2; @@ -942,6 +946,7 @@ message TransportApiRequestMsg { GetDeviceRequestMsg deviceRequestMsg = 12; GetDeviceCredentialsRequestMsg deviceCredentialsRequestMsg = 13; GetAllQueueRoutingInfoRequestMsg getAllQueueRoutingInfoRequestMsg = 14; + ValidateOrCreateDeviceX509CertRequestMsg validateOrCreateX509CertRequestMsg = 15; } /* Response from ThingsBoard Core Service to Transport Service */ diff --git a/common/dao-api/src/main/java/org/thingsboard/server/dao/device/DeviceCredentialsService.java b/common/dao-api/src/main/java/org/thingsboard/server/dao/device/DeviceCredentialsService.java index 8d17e84b01..c57b316a44 100644 --- a/common/dao-api/src/main/java/org/thingsboard/server/dao/device/DeviceCredentialsService.java +++ b/common/dao-api/src/main/java/org/thingsboard/server/dao/device/DeviceCredentialsService.java @@ -15,10 +15,10 @@ */ package org.thingsboard.server.dao.device; +import com.fasterxml.jackson.databind.JsonNode; import org.thingsboard.server.common.data.id.DeviceId; import org.thingsboard.server.common.data.id.TenantId; import org.thingsboard.server.common.data.security.DeviceCredentials; -import com.fasterxml.jackson.databind.JsonNode; public interface DeviceCredentialsService { diff --git a/common/dao-api/src/main/java/org/thingsboard/server/dao/device/DeviceProfileService.java b/common/dao-api/src/main/java/org/thingsboard/server/dao/device/DeviceProfileService.java index 5fc45afb57..e765cc5030 100644 --- a/common/dao-api/src/main/java/org/thingsboard/server/dao/device/DeviceProfileService.java +++ b/common/dao-api/src/main/java/org/thingsboard/server/dao/device/DeviceProfileService.java @@ -39,6 +39,8 @@ public interface DeviceProfileService extends EntityDaoService { PageData findDeviceProfileInfos(TenantId tenantId, PageLink pageLink, String transportType); + DeviceProfile findDeviceProfileByProvisionDeviceKey(String provisionDeviceKey); + DeviceProfile findOrCreateDeviceProfile(TenantId tenantId, String profileName); DeviceProfile createDefaultDeviceProfile(TenantId tenantId); diff --git a/common/dao-api/src/main/java/org/thingsboard/server/dao/device/DeviceProvisionService.java b/common/dao-api/src/main/java/org/thingsboard/server/dao/device/DeviceProvisionService.java index 77a8d3edbc..5fb6462c80 100644 --- a/common/dao-api/src/main/java/org/thingsboard/server/dao/device/DeviceProvisionService.java +++ b/common/dao-api/src/main/java/org/thingsboard/server/dao/device/DeviceProvisionService.java @@ -15,6 +15,7 @@ */ package org.thingsboard.server.dao.device; +import org.thingsboard.server.common.data.DeviceProfile; import org.thingsboard.server.dao.device.provision.ProvisionFailedException; import org.thingsboard.server.dao.device.provision.ProvisionRequest; import org.thingsboard.server.dao.device.provision.ProvisionResponse; @@ -22,4 +23,6 @@ import org.thingsboard.server.dao.device.provision.ProvisionResponse; public interface DeviceProvisionService { ProvisionResponse provisionDevice(ProvisionRequest provisionRequest) throws ProvisionFailedException; + + ProvisionResponse provisionDeviceViaX509Chain(DeviceProfile deviceProfile, ProvisionRequest provisionRequest) throws ProvisionFailedException; } diff --git a/common/dao-api/src/main/java/org/thingsboard/server/dao/device/provision/ProvisionFailedException.java b/common/dao-api/src/main/java/org/thingsboard/server/dao/device/provision/ProvisionFailedException.java index 2579b1f9cf..f3fc6f97fc 100644 --- a/common/dao-api/src/main/java/org/thingsboard/server/dao/device/provision/ProvisionFailedException.java +++ b/common/dao-api/src/main/java/org/thingsboard/server/dao/device/provision/ProvisionFailedException.java @@ -16,7 +16,11 @@ package org.thingsboard.server.dao.device.provision; public class ProvisionFailedException extends RuntimeException { + + private static final long serialVersionUID = 1673991117668477401L; + public ProvisionFailedException(String errorMsg) { super(errorMsg); } + } diff --git a/common/data/pom.xml b/common/data/pom.xml index e50ccd9dd2..d81203cd4d 100644 --- a/common/data/pom.xml +++ b/common/data/pom.xml @@ -100,6 +100,10 @@ org.apache.commons commons-lang3 + + commons-codec + commons-codec + io.swagger swagger-annotations diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/DeviceProfileProvisionType.java b/common/data/src/main/java/org/thingsboard/server/common/data/DeviceProfileProvisionType.java index 7af4361698..f5ef1319a8 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/DeviceProfileProvisionType.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/DeviceProfileProvisionType.java @@ -18,5 +18,6 @@ package org.thingsboard.server.common.data; public enum DeviceProfileProvisionType { DISABLED, ALLOW_CREATE_NEW_DEVICES, - CHECK_PRE_PROVISIONED_DEVICES + CHECK_PRE_PROVISIONED_DEVICES, + X509_CERTIFICATE_CHAIN } diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/UpdateMessage.java b/common/data/src/main/java/org/thingsboard/server/common/data/UpdateMessage.java index 287d3f3b71..43210de9d0 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/UpdateMessage.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/UpdateMessage.java @@ -38,5 +38,4 @@ public class UpdateMessage implements Serializable { @ApiModelProperty(position = 6, value = "Latest ThingsBoard version release notes URL.") private final String latestVersionReleaseNotesUrl; - } diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/device/profile/DeviceProfileProvisionConfiguration.java b/common/data/src/main/java/org/thingsboard/server/common/data/device/profile/DeviceProfileProvisionConfiguration.java index 4a825ab96f..c5882dc187 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/device/profile/DeviceProfileProvisionConfiguration.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/device/profile/DeviceProfileProvisionConfiguration.java @@ -31,7 +31,8 @@ import java.io.Serializable; @JsonSubTypes({ @JsonSubTypes.Type(value = DisabledDeviceProfileProvisionConfiguration.class, name = "DISABLED"), @JsonSubTypes.Type(value = AllowCreateNewDevicesDeviceProfileProvisionConfiguration.class, name = "ALLOW_CREATE_NEW_DEVICES"), - @JsonSubTypes.Type(value = CheckPreProvisionedDevicesDeviceProfileProvisionConfiguration.class, name = "CHECK_PRE_PROVISIONED_DEVICES")}) + @JsonSubTypes.Type(value = CheckPreProvisionedDevicesDeviceProfileProvisionConfiguration.class, name = "CHECK_PRE_PROVISIONED_DEVICES"), + @JsonSubTypes.Type(value = X509CertificateChainProvisionConfiguration.class, name = "X509_CERTIFICATE_CHAIN")}) public interface DeviceProfileProvisionConfiguration extends Serializable { String getProvisionDeviceSecret(); diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/device/profile/X509CertificateChainProvisionConfiguration.java b/common/data/src/main/java/org/thingsboard/server/common/data/device/profile/X509CertificateChainProvisionConfiguration.java new file mode 100644 index 0000000000..d5edafca69 --- /dev/null +++ b/common/data/src/main/java/org/thingsboard/server/common/data/device/profile/X509CertificateChainProvisionConfiguration.java @@ -0,0 +1,36 @@ +/** + * Copyright © 2016-2023 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.device.profile; + +import lombok.Data; +import lombok.NoArgsConstructor; +import org.thingsboard.server.common.data.DeviceProfileProvisionType; + +@Data +@NoArgsConstructor +public class X509CertificateChainProvisionConfiguration implements DeviceProfileProvisionConfiguration { + + private String provisionDeviceSecret; + private String certificateRegExPattern; + private boolean allowCreateNewDevicesByX509Certificate; + + @Override + public DeviceProfileProvisionType getType() { + return DeviceProfileProvisionType.X509_CERTIFICATE_CHAIN; + } + +} diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/kv/BasicKvEntry.java b/common/data/src/main/java/org/thingsboard/server/common/data/kv/BasicKvEntry.java index bdb8c69621..0f80f5ef89 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/kv/BasicKvEntry.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/kv/BasicKvEntry.java @@ -16,6 +16,7 @@ package org.thingsboard.server.common.data.kv; import org.thingsboard.server.common.data.validation.Length; +import org.thingsboard.server.common.data.validation.NoXss; import java.util.Objects; import java.util.Optional; @@ -23,6 +24,7 @@ import java.util.Optional; public abstract class BasicKvEntry implements KvEntry { @Length(fieldName = "attribute key") + @NoXss private final String key; protected BasicKvEntry(String key) { diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/kv/BasicTsKvEntry.java b/common/data/src/main/java/org/thingsboard/server/common/data/kv/BasicTsKvEntry.java index fc64eed1ff..3c612d3b78 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/kv/BasicTsKvEntry.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/kv/BasicTsKvEntry.java @@ -15,12 +15,14 @@ */ package org.thingsboard.server.common.data.kv; +import javax.validation.Valid; import java.util.Objects; import java.util.Optional; public class BasicTsKvEntry implements TsKvEntry { private static final int MAX_CHARS_PER_DATA_POINT = 512; protected final long ts; + @Valid private final KvEntry kv; public BasicTsKvEntry(long ts, KvEntry kv) { diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/kv/JsonDataEntry.java b/common/data/src/main/java/org/thingsboard/server/common/data/kv/JsonDataEntry.java index 95712bdd70..4260c512f8 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/kv/JsonDataEntry.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/kv/JsonDataEntry.java @@ -15,10 +15,14 @@ */ package org.thingsboard.server.common.data.kv; +import org.thingsboard.server.common.data.validation.NoXss; + import java.util.Objects; import java.util.Optional; public class JsonDataEntry extends BasicKvEntry { + + @NoXss private final String value; public JsonDataEntry(String key, String value) { diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/kv/StringDataEntry.java b/common/data/src/main/java/org/thingsboard/server/common/data/kv/StringDataEntry.java index 06a2b5cac6..9251cc80b3 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/kv/StringDataEntry.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/kv/StringDataEntry.java @@ -15,6 +15,8 @@ */ package org.thingsboard.server.common.data.kv; +import org.thingsboard.server.common.data.validation.NoXss; + import java.util.Objects; import java.util.Optional; @@ -22,6 +24,7 @@ public class StringDataEntry extends BasicKvEntry { private static final long serialVersionUID = 1L; + @NoXss private final String value; public StringDataEntry(String key, String value) { diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/notification/rule/NotificationRule.java b/common/data/src/main/java/org/thingsboard/server/common/data/notification/rule/NotificationRule.java index 529bc9a3ab..450fbdb23f 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/notification/rule/NotificationRule.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/notification/rule/NotificationRule.java @@ -27,6 +27,7 @@ import org.thingsboard.server.common.data.id.NotificationTemplateId; import org.thingsboard.server.common.data.id.TenantId; import org.thingsboard.server.common.data.notification.rule.trigger.NotificationRuleTriggerConfig; import org.thingsboard.server.common.data.notification.rule.trigger.NotificationRuleTriggerType; +import org.thingsboard.server.common.data.validation.Length; import org.thingsboard.server.common.data.validation.NoXss; import javax.validation.Valid; @@ -43,6 +44,7 @@ public class NotificationRule extends BaseData implements Ha private TenantId tenantId; @NotBlank @NoXss + @Length(max = 255, message = "cannot be longer than 255 chars") private String name; @NotNull private NotificationTemplateId templateId; diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/notification/targets/NotificationTarget.java b/common/data/src/main/java/org/thingsboard/server/common/data/notification/targets/NotificationTarget.java index 9f267a514d..9a2f9a5306 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/notification/targets/NotificationTarget.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/notification/targets/NotificationTarget.java @@ -22,6 +22,7 @@ import org.thingsboard.server.common.data.HasName; import org.thingsboard.server.common.data.HasTenantId; import org.thingsboard.server.common.data.id.NotificationTargetId; import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.common.data.validation.Length; import org.thingsboard.server.common.data.validation.NoXss; import javax.validation.Valid; @@ -35,6 +36,7 @@ public class NotificationTarget extends BaseData implement private TenantId tenantId; @NotBlank @NoXss + @Length(max = 255, message = "cannot be longer than 255 chars") private String name; @NotNull @Valid diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/notification/targets/NotificationTargetConfig.java b/common/data/src/main/java/org/thingsboard/server/common/data/notification/targets/NotificationTargetConfig.java index 2333062f65..231e419fb0 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/notification/targets/NotificationTargetConfig.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/notification/targets/NotificationTargetConfig.java @@ -23,6 +23,7 @@ import com.fasterxml.jackson.annotation.JsonTypeInfo; import lombok.Data; import org.thingsboard.server.common.data.notification.targets.platform.PlatformUsersNotificationTargetConfig; import org.thingsboard.server.common.data.notification.targets.slack.SlackNotificationTargetConfig; +import org.thingsboard.server.common.data.validation.Length; import org.thingsboard.server.common.data.validation.NoXss; @JsonIgnoreProperties(ignoreUnknown = true) @@ -35,6 +36,7 @@ import org.thingsboard.server.common.data.validation.NoXss; public abstract class NotificationTargetConfig { @NoXss + @Length(max = 500, message = "cannot be longer than 500 chars") private String description; @JsonIgnore diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/notification/targets/NotificationTargetType.java b/common/data/src/main/java/org/thingsboard/server/common/data/notification/targets/NotificationTargetType.java index 0c2441e37d..1254654ecf 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/notification/targets/NotificationTargetType.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/notification/targets/NotificationTargetType.java @@ -19,6 +19,7 @@ import lombok.Getter; import lombok.RequiredArgsConstructor; import org.thingsboard.server.common.data.notification.NotificationDeliveryMethod; +import java.util.Arrays; import java.util.Set; @RequiredArgsConstructor @@ -30,4 +31,10 @@ public enum NotificationTargetType { @Getter private final Set supportedDeliveryMethods; + public static NotificationTargetType forDeliveryMethod(NotificationDeliveryMethod deliveryMethod) { + return Arrays.stream(values()) + .filter(targetType -> targetType.getSupportedDeliveryMethods().contains(deliveryMethod)) + .findFirst().orElse(null); + } + } diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/notification/template/EmailDeliveryMethodNotificationTemplate.java b/common/data/src/main/java/org/thingsboard/server/common/data/notification/template/EmailDeliveryMethodNotificationTemplate.java index bf8aca7913..fe909a104e 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/notification/template/EmailDeliveryMethodNotificationTemplate.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/notification/template/EmailDeliveryMethodNotificationTemplate.java @@ -21,6 +21,8 @@ import lombok.NoArgsConstructor; import lombok.ToString; import org.apache.commons.lang3.StringUtils; import org.thingsboard.server.common.data.notification.NotificationDeliveryMethod; +import org.thingsboard.server.common.data.validation.Length; +import org.thingsboard.server.common.data.validation.NoXss; import javax.validation.constraints.NotEmpty; @@ -30,6 +32,8 @@ import javax.validation.constraints.NotEmpty; @ToString(callSuper = true) public class EmailDeliveryMethodNotificationTemplate extends DeliveryMethodNotificationTemplate implements HasSubject { + @NoXss(fieldName = "email subject") + @Length(fieldName = "email subject", max = 250, message = "cannot be longer than 250 chars") @NotEmpty private String subject; diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/notification/template/NotificationTemplate.java b/common/data/src/main/java/org/thingsboard/server/common/data/notification/template/NotificationTemplate.java index 2eaff12a9b..71da2f862b 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/notification/template/NotificationTemplate.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/notification/template/NotificationTemplate.java @@ -23,6 +23,7 @@ import org.thingsboard.server.common.data.HasTenantId; import org.thingsboard.server.common.data.id.NotificationTemplateId; import org.thingsboard.server.common.data.id.TenantId; import org.thingsboard.server.common.data.notification.NotificationType; +import org.thingsboard.server.common.data.validation.Length; import org.thingsboard.server.common.data.validation.NoXss; import javax.validation.Valid; @@ -36,6 +37,7 @@ public class NotificationTemplate extends BaseData imple private TenantId tenantId; @NoXss @NotEmpty + @Length(max = 255, message = "cannot be longer than 255 chars") private String name; @NoXss @NotNull diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/notification/template/SlackDeliveryMethodNotificationTemplate.java b/common/data/src/main/java/org/thingsboard/server/common/data/notification/template/SlackDeliveryMethodNotificationTemplate.java index 735eb3660e..8457677308 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/notification/template/SlackDeliveryMethodNotificationTemplate.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/notification/template/SlackDeliveryMethodNotificationTemplate.java @@ -20,6 +20,7 @@ import lombok.EqualsAndHashCode; import lombok.NoArgsConstructor; import lombok.ToString; import org.thingsboard.server.common.data.notification.NotificationDeliveryMethod; +import org.thingsboard.server.common.data.validation.NoXss; @Data @NoArgsConstructor @@ -31,6 +32,12 @@ public class SlackDeliveryMethodNotificationTemplate extends DeliveryMethodNotif super(other); } + @NoXss(fieldName = "Slack message") + @Override + public String getBody() { + return super.getBody(); + } + @Override public NotificationDeliveryMethod getMethod() { return NotificationDeliveryMethod.SLACK; diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/notification/template/SmsDeliveryMethodNotificationTemplate.java b/common/data/src/main/java/org/thingsboard/server/common/data/notification/template/SmsDeliveryMethodNotificationTemplate.java index 55d2582284..7dc2e494f3 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/notification/template/SmsDeliveryMethodNotificationTemplate.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/notification/template/SmsDeliveryMethodNotificationTemplate.java @@ -20,6 +20,8 @@ import lombok.EqualsAndHashCode; import lombok.NoArgsConstructor; import lombok.ToString; import org.thingsboard.server.common.data.notification.NotificationDeliveryMethod; +import org.thingsboard.server.common.data.validation.Length; +import org.thingsboard.server.common.data.validation.NoXss; @Data @NoArgsConstructor @@ -31,6 +33,13 @@ public class SmsDeliveryMethodNotificationTemplate extends DeliveryMethodNotific super(other); } + @NoXss(fieldName = "SMS message") + @Length(fieldName = "SMS message", max = 320, message = "cannot be longer than 320 chars") + @Override + public String getBody() { + return super.getBody(); + } + @Override public NotificationDeliveryMethod getMethod() { return NotificationDeliveryMethod.SMS; diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/notification/template/WebDeliveryMethodNotificationTemplate.java b/common/data/src/main/java/org/thingsboard/server/common/data/notification/template/WebDeliveryMethodNotificationTemplate.java index ac1e0cfd8c..f26ed00568 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/notification/template/WebDeliveryMethodNotificationTemplate.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/notification/template/WebDeliveryMethodNotificationTemplate.java @@ -25,6 +25,8 @@ import lombok.NoArgsConstructor; import lombok.ToString; import org.apache.commons.lang3.StringUtils; import org.thingsboard.server.common.data.notification.NotificationDeliveryMethod; +import org.thingsboard.server.common.data.validation.Length; +import org.thingsboard.server.common.data.validation.NoXss; import javax.validation.constraints.NotEmpty; import java.util.Optional; @@ -35,6 +37,8 @@ import java.util.Optional; @ToString(callSuper = true) public class WebDeliveryMethodNotificationTemplate extends DeliveryMethodNotificationTemplate implements HasSubject { + @NoXss(fieldName = "web notification subject") + @Length(fieldName = "web notification subject", max = 150, message = "cannot be longer than 150 chars") @NotEmpty private String subject; private JsonNode additionalConfig; @@ -45,6 +49,15 @@ public class WebDeliveryMethodNotificationTemplate extends DeliveryMethodNotific this.additionalConfig = other.additionalConfig != null ? other.additionalConfig.deepCopy() : null; } + @NoXss(fieldName = "web notification message") + @Length(fieldName = "web notification message", max = 250, message = "cannot be longer than 250 chars") + @Override + public String getBody() { + return super.getBody(); + } + + @NoXss(fieldName = "web notification button text") + @Length(fieldName = "web notification button text", max = 50, message = "cannot be longer than 50 chars") @JsonIgnore public String getButtonText() { return getButtonConfigProperty("text"); @@ -57,6 +70,8 @@ public class WebDeliveryMethodNotificationTemplate extends DeliveryMethodNotific }); } + @NoXss(fieldName = "web notification button link") + @Length(fieldName = "web notification button link", max = 300, message = "cannot be longer than 300 chars") @JsonIgnore public String getButtonLink() { return getButtonConfigProperty("link"); diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/settings/UserSettingsType.java b/common/data/src/main/java/org/thingsboard/server/common/data/settings/UserSettingsType.java index 97ef85a5f2..b19dbbbaee 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/settings/UserSettingsType.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/settings/UserSettingsType.java @@ -19,7 +19,7 @@ import lombok.Getter; public enum UserSettingsType { - GENERAL, VISITED_DASHBOARDS(true), QUICK_LINKS, DOC_LINKS, DASHBOARDS; + GENERAL, VISITED_DASHBOARDS(true), QUICK_LINKS, DOC_LINKS, DASHBOARDS, GETTING_STARTED; @Getter private final boolean reserved; 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 030587f50f..66fba5f832 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 @@ -48,6 +48,7 @@ public class DefaultTenantProfileConfiguration implements TenantProfileConfigura private String tenantEntityExportRateLimit; private String tenantEntityImportRateLimit; private String tenantNotificationRequestsRateLimit; + private String tenantNotificationRequestsPerRuleRateLimit; private long maxTransportMessages; private long maxTransportDataPoints; diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/validation/Length.java b/common/data/src/main/java/org/thingsboard/server/common/data/validation/Length.java index 2b412d0ad7..cb7b9b1693 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/validation/Length.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/validation/Length.java @@ -23,12 +23,12 @@ import java.lang.annotation.RetentionPolicy; import java.lang.annotation.Target; @Retention(RetentionPolicy.RUNTIME) -@Target(ElementType.FIELD) +@Target({ElementType.FIELD, ElementType.METHOD}) @Constraint(validatedBy = {}) public @interface Length { String message() default "length must be equal or less than {max}"; - String fieldName(); + String fieldName() default ""; int max() default 255; diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/validation/NoXss.java b/common/data/src/main/java/org/thingsboard/server/common/data/validation/NoXss.java index c99502ed7b..fb7b048f7e 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/validation/NoXss.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/validation/NoXss.java @@ -23,12 +23,16 @@ import java.lang.annotation.RetentionPolicy; import java.lang.annotation.Target; @Retention(RetentionPolicy.RUNTIME) -@Target(ElementType.FIELD) +@Target({ElementType.FIELD, ElementType.METHOD}) @Constraint(validatedBy = {}) public @interface NoXss { + String message() default "is malformed"; + String fieldName() default ""; + Class[] groups() default {}; Class[] payload() default {}; + } 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 2acbe49f98..4e8548aa33 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 @@ -57,8 +57,10 @@ public class EdgeGrpcClient implements EdgeRpcClient { private int rpcPort; @Value("${cloud.rpc.timeout}") private int timeoutSecs; - @Value("${cloud.rpc.keep_alive_time_sec}") + @Value("${cloud.rpc.keep_alive_time_sec:10}") private int keepAliveTimeSec; + @Value("${cloud.rpc.keep_alive_timeout_sec:5}") + private int keepAliveTimeoutSec; @Value("${cloud.rpc.ssl.enabled}") private boolean sslEnabled; @Value("${cloud.rpc.ssl.cert:}") @@ -83,7 +85,9 @@ public class EdgeGrpcClient implements EdgeRpcClient { Consumer onError) { NettyChannelBuilder builder = NettyChannelBuilder.forAddress(rpcHost, rpcPort) .maxInboundMessageSize(maxInboundMessageSize) - .keepAliveTime(keepAliveTimeSec, TimeUnit.SECONDS); + .keepAliveTime(keepAliveTimeSec, TimeUnit.SECONDS) + .keepAliveTimeout(keepAliveTimeoutSec, TimeUnit.SECONDS) + .keepAliveWithoutCalls(true); if (sslEnabled) { try { SslContextBuilder sslContextBuilder = GrpcSslContexts.forClient(); diff --git a/common/message/src/main/java/org/thingsboard/server/common/msg/EncryptionUtil.java b/common/message/src/main/java/org/thingsboard/server/common/msg/EncryptionUtil.java index 2c01e11712..e944a8dd92 100644 --- a/common/message/src/main/java/org/thingsboard/server/common/msg/EncryptionUtil.java +++ b/common/message/src/main/java/org/thingsboard/server/common/msg/EncryptionUtil.java @@ -35,6 +35,14 @@ public class EncryptionUtil { .replaceAll("-----END CERTIFICATE-----", ""); } + public static String certTrimNewLinesForChainInDeviceProfile(String input) { + return input.replaceAll("\n", "") + .replaceAll("\r", "") + .replaceAll("-----BEGIN CERTIFICATE-----", "-----BEGIN CERTIFICATE-----\n") + .replaceAll("-----END CERTIFICATE-----", "\n-----END CERTIFICATE-----\n") + .trim(); + } + public static String pubkTrimNewLines(String input) { return input.replaceAll("-----BEGIN PUBLIC KEY-----", "") .replaceAll("\n", "") diff --git a/common/script/remote-js-client/src/main/java/org/thingsboard/server/service/script/RemoteJsInvokeService.java b/common/script/remote-js-client/src/main/java/org/thingsboard/server/service/script/RemoteJsInvokeService.java index 8fff7fc0fe..dc4068edcc 100644 --- a/common/script/remote-js-client/src/main/java/org/thingsboard/server/service/script/RemoteJsInvokeService.java +++ b/common/script/remote-js-client/src/main/java/org/thingsboard/server/service/script/RemoteJsInvokeService.java @@ -20,7 +20,6 @@ import com.google.common.util.concurrent.ListenableFuture; import com.google.common.util.concurrent.MoreExecutors; import lombok.Getter; import lombok.extern.slf4j.Slf4j; -import org.jetbrains.annotations.NotNull; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Value; import org.springframework.boot.autoconfigure.condition.ConditionalOnExpression; @@ -191,7 +190,6 @@ public class RemoteJsInvokeService extends AbstractJsInvokeService { }, callbackExecutor); } - @NotNull private JsInvokeProtos.RemoteJsRequest buildJsInvokeRequest(JsScriptInfo jsInfo, Object[] args, boolean includeScriptBody, String scriptBody) { JsInvokeProtos.JsInvokeRequest.Builder jsRequestBuilder = JsInvokeProtos.JsInvokeRequest.newBuilder() .setScriptHash(jsInfo.getHash()) diff --git a/common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/server/store/TbRedisLwM2MClientStore.java b/common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/server/store/TbRedisLwM2MClientStore.java index 9e29177dcd..fb9cc870ad 100644 --- a/common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/server/store/TbRedisLwM2MClientStore.java +++ b/common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/server/store/TbRedisLwM2MClientStore.java @@ -16,6 +16,7 @@ package org.thingsboard.server.transport.lwm2m.server.store; import lombok.extern.slf4j.Slf4j; +import org.apache.commons.codec.binary.Hex; import org.springframework.data.redis.connection.RedisClusterConnection; import org.springframework.data.redis.connection.RedisConnectionFactory; import org.springframework.data.redis.core.Cursor; @@ -48,7 +49,12 @@ public class TbRedisLwM2MClientStore implements TbLwM2MClientStore { if (data == null) { return null; } else { - return deserialize(data); + try { + return deserialize(data); + } catch (Exception e) { + log.warn("[{}] Failed to deserialize client from data: {}", endpoint, Hex.encodeHexString(data), e); + return null; + } } } } @@ -70,7 +76,13 @@ public class TbRedisLwM2MClientStore implements TbLwM2MClientStore { scans.forEach(scan -> { scan.forEachRemaining(key -> { byte[] element = connection.get(key); - clients.add(deserialize(element)); + if (element != null) { + try { + clients.add(deserialize(element)); + } catch (Exception e) { + log.warn("[{}] Failed to deserialize client from data: {}", Hex.encodeHexString(key), Hex.encodeHexString(element), e); + } + } }); }); return clients; diff --git a/common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/MqttSslHandlerProvider.java b/common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/MqttSslHandlerProvider.java index 4b425e8446..bfa80ba615 100644 --- a/common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/MqttSslHandlerProvider.java +++ b/common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/MqttSslHandlerProvider.java @@ -24,9 +24,8 @@ import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.boot.context.properties.ConfigurationProperties; import org.springframework.context.annotation.Bean; import org.springframework.stereotype.Component; -import org.thingsboard.server.common.data.StringUtils; import org.thingsboard.server.common.data.DeviceTransportType; -import org.thingsboard.server.common.msg.EncryptionUtil; +import org.thingsboard.server.common.data.StringUtils; import org.thingsboard.server.common.transport.TransportService; import org.thingsboard.server.common.transport.TransportServiceCallback; import org.thingsboard.server.common.transport.auth.ValidateDeviceCredentialsResponse; @@ -42,7 +41,6 @@ import javax.net.ssl.SSLEngine; import javax.net.ssl.TrustManager; import javax.net.ssl.TrustManagerFactory; import javax.net.ssl.X509TrustManager; -import java.security.cert.CertificateEncodingException; import java.security.cert.CertificateException; import java.security.cert.X509Certificate; import java.util.concurrent.CountDownLatch; @@ -123,7 +121,7 @@ public class MqttSslHandlerProvider { static class ThingsboardMqttX509TrustManager implements X509TrustManager { private final X509TrustManager trustManager; - private TransportService transportService; + private final TransportService transportService; ThingsboardMqttX509TrustManager(X509TrustManager trustManager, TransportService transportService) { this.trustManager = trustManager; @@ -142,43 +140,59 @@ public class MqttSslHandlerProvider { } @Override - public void checkClientTrusted(X509Certificate[] chain, - String authType) throws CertificateException { - String credentialsBody = null; - for (X509Certificate cert : chain) { - try { - String strCert = SslUtil.getCertificateString(cert); - String sha3Hash = EncryptionUtil.getSha3Hash(strCert); - final String[] credentialsBodyHolder = new String[1]; - CountDownLatch latch = new CountDownLatch(1); - transportService.process(DeviceTransportType.MQTT, TransportProtos.ValidateDeviceX509CertRequestMsg.newBuilder().setHash(sha3Hash).build(), - new TransportServiceCallback() { - @Override - public void onSuccess(ValidateDeviceCredentialsResponse msg) { - if (!StringUtils.isEmpty(msg.getCredentials())) { - credentialsBodyHolder[0] = msg.getCredentials(); - } - latch.countDown(); - } - - @Override - public void onError(Throwable e) { - log.error(e.getMessage(), e); - latch.countDown(); + public void checkClientTrusted(X509Certificate[] chain, String authType) throws CertificateException { + if (!validateCertificateChain(chain)) { + throw new CertificateException("Invalid Chain of X509 Certificates. "); + } + String clientDeviceCertValue = SslUtil.getCertificateString(chain[0]); + final String[] credentialsBodyHolder = new String[1]; + CountDownLatch latch = new CountDownLatch(1); + try { + String certificateChain = SslUtil.getCertificateChainString(chain); + transportService.process(DeviceTransportType.MQTT, TransportProtos.ValidateOrCreateDeviceX509CertRequestMsg + .newBuilder().setCertificateChain(certificateChain).build(), + new TransportServiceCallback<>() { + @Override + public void onSuccess(ValidateDeviceCredentialsResponse msg) { + if (!StringUtils.isEmpty(msg.getCredentials())) { + credentialsBodyHolder[0] = msg.getCredentials(); } - }); - latch.await(10, TimeUnit.SECONDS); - if (strCert.equals(credentialsBodyHolder[0])) { - credentialsBody = credentialsBodyHolder[0]; - break; + latch.countDown(); + } + + @Override + public void onError(Throwable e) { + log.trace("Failed to process certificate chain: {}", certificateChain, e); + latch.countDown(); + } + }); + latch.await(10, TimeUnit.SECONDS); + if (!clientDeviceCertValue.equals(credentialsBodyHolder[0])) { + log.debug("Failed to find credentials for device certificate chain: {}", chain); + if (chain.length == 1) { + throw new CertificateException("Invalid Device Certificate"); + } else { + throw new CertificateException("Invalid Chain of X509 Certificates"); } - } catch (InterruptedException | CertificateEncodingException e) { - log.error(e.getMessage(), e); } + } catch (Exception e) { + log.error(e.getMessage(), e); } - if (credentialsBody == null) { - log.debug("Failed to find credentials for device certificate chain: {}", chain); - throw new CertificateException("Invalid Device Certificate"); + } + + private boolean validateCertificateChain(X509Certificate[] chain) { + try { + if (chain.length > 1) { + X509Certificate leafCert = chain[0]; + for (int i = 1; i < chain.length; i++) { + X509Certificate intermediateCert = chain[i]; + leafCert.verify(intermediateCert.getPublicKey()); + leafCert = intermediateCert; + } + } + return true; + } catch (Exception e) { + return false; } } } diff --git a/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/TransportService.java b/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/TransportService.java index 9440ff8e79..abcd35ee23 100644 --- a/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/TransportService.java +++ b/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/TransportService.java @@ -54,8 +54,9 @@ import org.thingsboard.server.gen.transport.TransportProtos.ToServerRpcRequestMs import org.thingsboard.server.gen.transport.TransportProtos.TransportToDeviceActorMsg; import org.thingsboard.server.gen.transport.TransportProtos.ValidateBasicMqttCredRequestMsg; import org.thingsboard.server.gen.transport.TransportProtos.ValidateDeviceLwM2MCredentialsRequestMsg; -import org.thingsboard.server.gen.transport.TransportProtos.ValidateDeviceTokenRequestMsg; import org.thingsboard.server.gen.transport.TransportProtos.ValidateDeviceX509CertRequestMsg; +import org.thingsboard.server.gen.transport.TransportProtos.ValidateDeviceTokenRequestMsg; +import org.thingsboard.server.gen.transport.TransportProtos.ValidateOrCreateDeviceX509CertRequestMsg; import java.util.List; import java.util.concurrent.ExecutorService; @@ -87,6 +88,9 @@ public interface TransportService { void process(DeviceTransportType transportType, ValidateDeviceX509CertRequestMsg msg, TransportServiceCallback callback); + void process(DeviceTransportType transportType, ValidateOrCreateDeviceX509CertRequestMsg msg, + TransportServiceCallback callback); + void process(ValidateDeviceLwM2MCredentialsRequestMsg msg, TransportServiceCallback callback); diff --git a/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/service/DefaultTransportService.java b/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/service/DefaultTransportService.java index 1bc6687465..883522881f 100644 --- a/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/service/DefaultTransportService.java +++ b/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/service/DefaultTransportService.java @@ -72,7 +72,6 @@ import org.thingsboard.server.common.transport.auth.GetOrCreateDeviceFromGateway import org.thingsboard.server.common.transport.auth.TransportDeviceInfo; import org.thingsboard.server.common.transport.auth.ValidateDeviceCredentialsResponse; import org.thingsboard.server.common.transport.limits.TransportRateLimitService; -import org.thingsboard.server.queue.util.DataDecodingEncodingService; import org.thingsboard.server.common.transport.util.JsonUtils; import org.thingsboard.server.gen.transport.TransportProtos; import org.thingsboard.server.gen.transport.TransportProtos.ProvisionDeviceRequestMsg; @@ -97,6 +96,7 @@ import org.thingsboard.server.queue.provider.TbQueueProducerProvider; import org.thingsboard.server.queue.provider.TbTransportQueueFactory; import org.thingsboard.server.queue.scheduler.SchedulerComponent; import org.thingsboard.server.queue.util.AfterStartUp; +import org.thingsboard.server.queue.util.DataDecodingEncodingService; import org.thingsboard.server.queue.util.TbTransportComponent; import javax.annotation.PostConstruct; @@ -434,6 +434,13 @@ public class DefaultTransportService implements TransportService { doProcess(transportType, protoMsg, callback); } + @Override + public void process(DeviceTransportType transportType, TransportProtos.ValidateOrCreateDeviceX509CertRequestMsg msg, TransportServiceCallback callback) { + log.trace("Processing msg: {}", msg); + TbProtoQueueMsg protoMsg = new TbProtoQueueMsg<>(UUID.randomUUID(), TransportApiRequestMsg.newBuilder().setValidateOrCreateX509CertRequestMsg(msg).build()); + doProcess(transportType, protoMsg, callback); + } + private void doProcess(DeviceTransportType transportType, TbProtoQueueMsg protoMsg, TransportServiceCallback callback) { ListenableFuture response = Futures.transform(transportApiRequestTemplate.send(protoMsg), tmp -> { diff --git a/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/util/SslUtil.java b/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/util/SslUtil.java index 0431868965..089718ec05 100644 --- a/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/util/SslUtil.java +++ b/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/util/SslUtil.java @@ -16,11 +16,21 @@ package org.thingsboard.server.common.transport.util; import lombok.extern.slf4j.Slf4j; +import org.apache.commons.codec.binary.Base64; +import org.bouncycastle.asn1.x500.RDN; +import org.bouncycastle.asn1.x500.X500Name; +import org.bouncycastle.asn1.x500.style.BCStyle; +import org.bouncycastle.asn1.x500.style.IETFUtils; +import org.bouncycastle.cert.jcajce.JcaX509CertificateHolder; import org.springframework.util.Base64Utils; import org.thingsboard.server.common.msg.EncryptionUtil; +import java.io.ByteArrayInputStream; +import java.io.InputStream; import java.security.cert.Certificate; import java.security.cert.CertificateEncodingException; +import java.security.cert.CertificateFactory; +import java.security.cert.X509Certificate; /** * @author Valerii Sosliuk @@ -35,4 +45,44 @@ public class SslUtil { throws CertificateEncodingException { return EncryptionUtil.certTrimNewLines(Base64Utils.encodeToString(cert.getEncoded())); } + + public static String getCertificateChainString(Certificate[] chain) + throws CertificateEncodingException { + String begin = "-----BEGIN CERTIFICATE-----"; + String end = "-----END CERTIFICATE-----"; + StringBuilder stringBuilder = new StringBuilder(); + for (Certificate cert: chain) { + stringBuilder.append(begin).append(EncryptionUtil.certTrimNewLines(Base64Utils.encodeToString(cert.getEncoded()))).append(end).append("\n"); + } + return stringBuilder.toString(); + } + + public static X509Certificate readCertFile(String fileContent) { + X509Certificate certificate = null; + try { + if (fileContent != null && !fileContent.trim().isEmpty()) { + fileContent = fileContent.replace("-----BEGIN CERTIFICATE-----", "") + .replace("-----END CERTIFICATE-----", "") + .replaceAll("\\s", ""); + byte[] decoded = Base64.decodeBase64(fileContent); + CertificateFactory certFactory = CertificateFactory.getInstance("X.509"); + try (InputStream inStream = new ByteArrayInputStream(decoded)) { + certificate = (X509Certificate) certFactory.generateCertificate(inStream); + } + } + } catch (Exception ignored) {} + return certificate; + } + + public static String parseCommonName(X509Certificate certificate) { + X500Name x500name; + try { + x500name = new JcaX509CertificateHolder(certificate).getSubject(); + } catch (CertificateEncodingException e) { + log.warn("Cannot parse CN from device certificate"); + throw new RuntimeException(e); + } + RDN cn = x500name.getRDNs(BCStyle.CN)[0]; + return IETFUtils.valueToString(cn.getFirst().getValue()); + } } diff --git a/dao/src/main/java/org/thingsboard/server/dao/attributes/AttributeUtils.java b/dao/src/main/java/org/thingsboard/server/dao/attributes/AttributeUtils.java index 168782c0fa..980057a231 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/attributes/AttributeUtils.java +++ b/dao/src/main/java/org/thingsboard/server/dao/attributes/AttributeUtils.java @@ -19,6 +19,9 @@ import org.thingsboard.server.common.data.id.EntityId; import org.thingsboard.server.common.data.kv.AttributeKvEntry; import org.thingsboard.server.dao.exception.IncorrectParameterException; import org.thingsboard.server.dao.service.Validator; +import org.thingsboard.server.dao.util.KvUtils; + +import java.util.List; public class AttributeUtils { @@ -27,10 +30,13 @@ public class AttributeUtils { Validator.validateString(scope, "Incorrect scope " + scope); } - public static void validate(AttributeKvEntry kvEntry) { - if (kvEntry == null) { - throw new IncorrectParameterException("Key value entry can't be null"); - } else if (kvEntry.getDataType() == null) { + public static void validate(List kvEntries, boolean validateNoxss) { + kvEntries.forEach(kv -> validate(kv, validateNoxss)); + } + + public static void validate(AttributeKvEntry kvEntry, boolean validateNoxss) { + KvUtils.validate(kvEntry, validateNoxss); + if (kvEntry.getDataType() == null) { throw new IncorrectParameterException("Incorrect kvEntry. Data type can't be null"); } else { Validator.validateString(kvEntry.getKey(), "Incorrect kvEntry. Key can't be empty"); diff --git a/dao/src/main/java/org/thingsboard/server/dao/attributes/BaseAttributesService.java b/dao/src/main/java/org/thingsboard/server/dao/attributes/BaseAttributesService.java index ef38de352a..08cb8beeae 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/attributes/BaseAttributesService.java +++ b/dao/src/main/java/org/thingsboard/server/dao/attributes/BaseAttributesService.java @@ -18,6 +18,7 @@ package org.thingsboard.server.dao.attributes; import com.google.common.util.concurrent.Futures; import com.google.common.util.concurrent.ListenableFuture; import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.context.annotation.Primary; import org.springframework.stereotype.Service; @@ -45,6 +46,9 @@ import static org.thingsboard.server.dao.attributes.AttributeUtils.validate; public class BaseAttributesService implements AttributesService { private final AttributesDao attributesDao; + @Value("${sql.attributes.noxss_validation_enabled:true}") + private boolean noxssValidationEnabled; + public BaseAttributesService(AttributesDao attributesDao) { this.attributesDao = attributesDao; } @@ -82,14 +86,14 @@ public class BaseAttributesService implements AttributesService { @Override public ListenableFuture save(TenantId tenantId, EntityId entityId, String scope, AttributeKvEntry attribute) { validate(entityId, scope); - AttributeUtils.validate(attribute); + AttributeUtils.validate(attribute, noxssValidationEnabled); return attributesDao.save(tenantId, entityId, scope, attribute); } @Override public ListenableFuture> save(TenantId tenantId, EntityId entityId, String scope, List attributes) { validate(entityId, scope); - attributes.forEach(AttributeUtils::validate); + AttributeUtils.validate(attributes, noxssValidationEnabled); List> saveFutures = attributes.stream().map(attribute -> attributesDao.save(tenantId, entityId, scope, attribute)).collect(Collectors.toList()); return Futures.allAsList(saveFutures); } diff --git a/dao/src/main/java/org/thingsboard/server/dao/attributes/CachedAttributesService.java b/dao/src/main/java/org/thingsboard/server/dao/attributes/CachedAttributesService.java index 01a6d1201c..ef81ed695b 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/attributes/CachedAttributesService.java +++ b/dao/src/main/java/org/thingsboard/server/dao/attributes/CachedAttributesService.java @@ -70,6 +70,9 @@ public class CachedAttributesService implements AttributesService { @Value("${cache.type:caffeine}") private String cacheType; + @Value("${sql.attributes.noxss_validation_enabled:true}") + private boolean noxssValidationEnabled; + public CachedAttributesService(AttributesDao attributesDao, StatsFactory statsFactory, CacheExecutorService cacheExecutorService, @@ -134,7 +137,7 @@ public class CachedAttributesService implements AttributesService { @Override public ListenableFuture> find(TenantId tenantId, EntityId entityId, String scope, Collection attributeKeys) { validate(entityId, scope); - attributeKeys = new LinkedHashSet<>(attributeKeys); // deduplicate the attributes + attributeKeys = new LinkedHashSet<>(attributeKeys); // deduplicate the attributes attributeKeys.forEach(attributeKey -> Validator.validateString(attributeKey, "Incorrect attribute key " + attributeKey)); Map> wrappedCachedAttributes = findCachedAttributes(entityId, scope, attributeKeys); @@ -212,7 +215,7 @@ public class CachedAttributesService implements AttributesService { @Override public ListenableFuture save(TenantId tenantId, EntityId entityId, String scope, AttributeKvEntry attribute) { validate(entityId, scope); - AttributeUtils.validate(attribute); + AttributeUtils.validate(attribute, noxssValidationEnabled); ListenableFuture future = attributesDao.save(tenantId, entityId, scope, attribute); return Futures.transform(future, key -> evict(entityId, scope, attribute, key), cacheExecutor); } @@ -220,7 +223,7 @@ public class CachedAttributesService implements AttributesService { @Override public ListenableFuture> save(TenantId tenantId, EntityId entityId, String scope, List attributes) { validate(entityId, scope); - attributes.forEach(AttributeUtils::validate); + AttributeUtils.validate(attributes, noxssValidationEnabled); List> futures = new ArrayList<>(attributes.size()); for (var attribute : attributes) { diff --git a/dao/src/main/java/org/thingsboard/server/dao/device/DeviceCredentialsServiceImpl.java b/dao/src/main/java/org/thingsboard/server/dao/device/DeviceCredentialsServiceImpl.java index 360085be20..6e79b953ff 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/device/DeviceCredentialsServiceImpl.java +++ b/dao/src/main/java/org/thingsboard/server/dao/device/DeviceCredentialsServiceImpl.java @@ -16,15 +16,12 @@ package org.thingsboard.server.dao.device; import com.fasterxml.jackson.databind.JsonNode; -import com.fasterxml.jackson.databind.node.ObjectNode; import lombok.extern.slf4j.Slf4j; import org.eclipse.leshan.core.SecurityMode; import org.eclipse.leshan.core.util.SecurityUtil; import org.hibernate.exception.ConstraintViolationException; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Propagation; -import org.springframework.transaction.annotation.Transactional; import org.springframework.transaction.event.TransactionalEventListener; import org.thingsboard.common.util.JacksonUtil; import org.thingsboard.server.common.data.StringUtils; diff --git a/dao/src/main/java/org/thingsboard/server/dao/device/DeviceProfileCacheKey.java b/dao/src/main/java/org/thingsboard/server/dao/device/DeviceProfileCacheKey.java index 07428064a8..d032282da9 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/device/DeviceProfileCacheKey.java +++ b/dao/src/main/java/org/thingsboard/server/dao/device/DeviceProfileCacheKey.java @@ -16,6 +16,7 @@ package org.thingsboard.server.dao.device; import lombok.Data; +import org.thingsboard.server.common.data.StringUtils; import org.thingsboard.server.common.data.id.DeviceProfileId; import org.thingsboard.server.common.data.id.TenantId; @@ -30,34 +31,44 @@ public class DeviceProfileCacheKey implements Serializable { private final String name; private final DeviceProfileId deviceProfileId; private final boolean defaultProfile; + private final String provisionDeviceKey; - private DeviceProfileCacheKey(TenantId tenantId, String name, DeviceProfileId deviceProfileId, boolean defaultProfile) { + private DeviceProfileCacheKey(TenantId tenantId, String name, DeviceProfileId deviceProfileId, boolean defaultProfile, String provisionDeviceKey) { this.tenantId = tenantId; this.name = name; this.deviceProfileId = deviceProfileId; this.defaultProfile = defaultProfile; + this.provisionDeviceKey = provisionDeviceKey; } public static DeviceProfileCacheKey fromName(TenantId tenantId, String name) { - return new DeviceProfileCacheKey(tenantId, name, null, false); + return new DeviceProfileCacheKey(tenantId, name, null, false, null); } public static DeviceProfileCacheKey fromId(DeviceProfileId id) { - return new DeviceProfileCacheKey(null, null, id, false); + return new DeviceProfileCacheKey(null, null, id, false, null); } public static DeviceProfileCacheKey defaultProfile(TenantId tenantId) { - return new DeviceProfileCacheKey(tenantId, null, null, true); + return new DeviceProfileCacheKey(tenantId, null, null, true, null); } + public static DeviceProfileCacheKey fromProvisionDeviceKey(String provisionDeviceKey) { + return new DeviceProfileCacheKey(null, null, null, false, provisionDeviceKey); + } + + /** + * IMPORTANT: Method toString() has to return unique value, if you add additional field to this class, please also refactor toString(). + */ @Override public String toString() { if (deviceProfileId != null) { return deviceProfileId.toString(); } else if (defaultProfile) { return tenantId.toString(); - } else { - return tenantId + "_" + name; + } else if (StringUtils.isNotEmpty(provisionDeviceKey)) { + return provisionDeviceKey; } + return tenantId + "_" + name; } } diff --git a/dao/src/main/java/org/thingsboard/server/dao/device/DeviceProfileEvictEvent.java b/dao/src/main/java/org/thingsboard/server/dao/device/DeviceProfileEvictEvent.java index c5a699b035..6470bfc584 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/device/DeviceProfileEvictEvent.java +++ b/dao/src/main/java/org/thingsboard/server/dao/device/DeviceProfileEvictEvent.java @@ -27,5 +27,6 @@ public class DeviceProfileEvictEvent { private final String oldName; private final DeviceProfileId deviceProfileId; private final boolean defaultProfile; + private final String provisionDeviceKey; } diff --git a/dao/src/main/java/org/thingsboard/server/dao/device/DeviceProfileServiceImpl.java b/dao/src/main/java/org/thingsboard/server/dao/device/DeviceProfileServiceImpl.java index 98ca4fde7b..a5b3e56341 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/device/DeviceProfileServiceImpl.java +++ b/dao/src/main/java/org/thingsboard/server/dao/device/DeviceProfileServiceImpl.java @@ -34,12 +34,14 @@ import org.thingsboard.server.common.data.device.profile.DefaultDeviceProfileCon import org.thingsboard.server.common.data.device.profile.DefaultDeviceProfileTransportConfiguration; import org.thingsboard.server.common.data.device.profile.DeviceProfileData; import org.thingsboard.server.common.data.device.profile.DisabledDeviceProfileProvisionConfiguration; +import org.thingsboard.server.common.data.device.profile.X509CertificateChainProvisionConfiguration; import org.thingsboard.server.common.data.id.DeviceProfileId; 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.common.msg.EncryptionUtil; import org.thingsboard.server.dao.entity.AbstractCachedEntityService; import org.thingsboard.server.dao.exception.DataValidationException; import org.thingsboard.server.dao.queue.QueueService; @@ -47,12 +49,19 @@ import org.thingsboard.server.dao.service.DataValidator; import org.thingsboard.server.dao.service.PaginatedRemover; import org.thingsboard.server.dao.service.Validator; +import java.io.ByteArrayInputStream; +import java.security.cert.Certificate; +import java.security.cert.CertificateException; +import java.security.cert.CertificateFactory; import java.util.ArrayList; import java.util.List; import java.util.Map; import java.util.Optional; +import java.util.regex.Matcher; +import java.util.regex.Pattern; import static org.thingsboard.server.dao.service.Validator.validateId; +import static org.thingsboard.server.dao.service.Validator.validateString; @Service("DeviceProfileDaoService") @Slf4j @@ -61,6 +70,7 @@ public class DeviceProfileServiceImpl extends AbstractCachedEntityService deviceProfileDao.findById(tenantId, deviceProfileId.getId()), true); } @@ -107,30 +120,46 @@ public class DeviceProfileServiceImpl extends AbstractCachedEntityService deviceProfileDao.findByName(tenantId, profileName), true); } + @Override + public DeviceProfile findDeviceProfileByProvisionDeviceKey(String provisionDeviceKey) { + log.trace("Executing findDeviceProfileByProvisionDeviceKey provisionKey [{}]", provisionDeviceKey); + validateString(provisionDeviceKey, INCORRECT_PROVISION_DEVICE_KEY + provisionDeviceKey); + return cache.getAndPutInTransaction(DeviceProfileCacheKey.fromProvisionDeviceKey(provisionDeviceKey), + () -> deviceProfileDao.findByProvisionDeviceKey(provisionDeviceKey), false); + } + @Override public DeviceProfileInfo findDeviceProfileInfoById(TenantId tenantId, DeviceProfileId deviceProfileId) { log.trace("Executing findDeviceProfileById [{}]", deviceProfileId); - Validator.validateId(deviceProfileId, INCORRECT_DEVICE_PROFILE_ID + deviceProfileId); + validateId(deviceProfileId, INCORRECT_DEVICE_PROFILE_ID + deviceProfileId); return toDeviceProfileInfo(findDeviceProfileById(tenantId, deviceProfileId)); } @Override public DeviceProfile saveDeviceProfile(DeviceProfile deviceProfile) { log.trace("Executing saveDeviceProfile [{}]", deviceProfile); + if (deviceProfile.getProfileData() != null && deviceProfile.getProfileData().getProvisionConfiguration() instanceof X509CertificateChainProvisionConfiguration) { + X509CertificateChainProvisionConfiguration x509Configuration = (X509CertificateChainProvisionConfiguration) deviceProfile.getProfileData().getProvisionConfiguration(); + if (x509Configuration.getProvisionDeviceSecret() != null) { + formatDeviceProfileCertificate(deviceProfile, x509Configuration); + } + } DeviceProfile oldDeviceProfile = deviceProfileValidator.validate(deviceProfile, DeviceProfile::getTenantId); DeviceProfile savedDeviceProfile; try { savedDeviceProfile = deviceProfileDao.saveAndFlush(deviceProfile.getTenantId(), deviceProfile); publishEvictEvent(new DeviceProfileEvictEvent(savedDeviceProfile.getTenantId(), savedDeviceProfile.getName(), - oldDeviceProfile != null ? oldDeviceProfile.getName() : null, savedDeviceProfile.getId(), savedDeviceProfile.isDefault())); + oldDeviceProfile != null ? oldDeviceProfile.getName() : null, savedDeviceProfile.getId(), savedDeviceProfile.isDefault(), + oldDeviceProfile != null ? oldDeviceProfile.getProvisionDeviceKey() : null)); } catch (Exception t) { handleEvictEvent(new DeviceProfileEvictEvent(deviceProfile.getTenantId(), deviceProfile.getName(), - oldDeviceProfile != null ? oldDeviceProfile.getName() : null, null, deviceProfile.isDefault())); + oldDeviceProfile != null ? oldDeviceProfile.getName() : null, null, deviceProfile.isDefault(), + oldDeviceProfile != null ? oldDeviceProfile.getProvisionDeviceKey() : null)); checkConstraintViolation(t, Map.of("device_profile_name_unq_key", DEVICE_PROFILE_WITH_SUCH_NAME_ALREADY_EXISTS, "device_provision_key_unq_key", "Device profile with such provision device key already exists!", @@ -156,7 +185,7 @@ public class DeviceProfileServiceImpl extends AbstractCachedEntityService 1) { + return EncryptionUtil.certTrimNewLinesForChainInDeviceProfile(certificateValue); + } + } catch (CertificateException ignored) {} + return EncryptionUtil.certTrimNewLines(certificateValue); + } + } 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 9438c79b62..8319468c04 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 @@ -170,8 +170,7 @@ public class DefaultNotificationSettingsService implements NotificationSettingsS newAlarmRuleTriggerConfig.setAlarmSeverities(null); newAlarmRuleTriggerConfig.setNotifyOn(Set.of(AlarmAction.CREATED)); createRule(tenantId, "New alarm", newAlarmNotificationTemplate.getId(), newAlarmRuleTriggerConfig, - List.of(tenantAdmins.getId(), originatorEntityOwnerUsers.getId()), "Send notification to tenant admins and alarm's customer users " + - "when an alarm is created"); + List.of(tenantAdmins.getId()), "Send notification to tenant admins when an alarm is created"); NotificationTemplate alarmUpdateNotificationTemplate = createTemplate(tenantId, "Alarm update notification", NotificationType.ALARM, "Alarm '${alarmType}' - ${action}", @@ -182,8 +181,7 @@ public class DefaultNotificationSettingsService implements NotificationSettingsS alarmRuleTriggerConfig.setAlarmSeverities(null); alarmRuleTriggerConfig.setNotifyOn(Set.of(AlarmAction.SEVERITY_CHANGED, AlarmAction.ACKNOWLEDGED, AlarmAction.CLEARED)); createRule(tenantId, "Alarm update", alarmUpdateNotificationTemplate.getId(), alarmRuleTriggerConfig, - List.of(tenantAdmins.getId(), originatorEntityOwnerUsers.getId()), "Send notification to tenant admins and alarm's customer users " + - "when any alarm is updated or cleared"); + List.of(tenantAdmins.getId()), "Send notification to tenant admins when any alarm is updated or cleared"); NotificationTemplate deviceActionNotificationTemplate = createTemplate(tenantId, "Device action notification", NotificationType.ENTITY_ACTION, "${entityType} was ${actionType}", @@ -195,8 +193,7 @@ public class DefaultNotificationSettingsService implements NotificationSettingsS deviceActionRuleTriggerConfig.setUpdated(false); deviceActionRuleTriggerConfig.setDeleted(false); createRule(tenantId, "Device created", deviceActionNotificationTemplate.getId(), deviceActionRuleTriggerConfig, - List.of(originatorEntityOwnerUsers.getId()), "Send notification to tenant admins or customer users " + - "when device is created"); + List.of(tenantAdmins.getId()), "Send notification to tenant admins when device is created"); NotificationTemplate deviceActivityNotificationTemplate = createTemplate(tenantId, "Device activity notification", NotificationType.DEVICE_ACTIVITY, "Device '${deviceName}' became ${eventType}", @@ -207,8 +204,7 @@ public class DefaultNotificationSettingsService implements NotificationSettingsS deviceActivityRuleTriggerConfig.setDeviceProfiles(null); deviceActivityRuleTriggerConfig.setNotifyOn(Set.of(DeviceEvent.ACTIVE, DeviceEvent.INACTIVE)); createRule(tenantId, "Device activity status change", deviceActivityNotificationTemplate.getId(), deviceActivityRuleTriggerConfig, - List.of(originatorEntityOwnerUsers.getId()), "Send notification to tenant admins or customer users " + - "when any device changes its activity state"); + List.of(tenantAdmins.getId()), "Send notification to tenant admins when any device changes its activity state"); NotificationTemplate alarmCommentNotificationTemplate = createTemplate(tenantId, "Alarm comment notification", NotificationType.ALARM_COMMENT, "Comment on '${alarmType}' alarm", @@ -221,8 +217,7 @@ public class DefaultNotificationSettingsService implements NotificationSettingsS alarmCommentRuleTriggerConfig.setOnlyUserComments(true); alarmCommentRuleTriggerConfig.setNotifyOnCommentUpdate(false); createRule(tenantId, "Comment on active alarm", alarmCommentNotificationTemplate.getId(), alarmCommentRuleTriggerConfig, - List.of(originatorEntityOwnerUsers.getId()), "Send notification to tenant admins or customer users " + - "when comment is added by user on active alarm"); + List.of(tenantAdmins.getId()), "Send notification to tenant admins when comment is added by user on active alarm"); NotificationTemplate alarmAssignedNotificationTemplate = createTemplate(tenantId, "Alarm assigned notification", NotificationType.ALARM_ASSIGNMENT, "Alarm '${alarmType}' (${alarmSeverity}) was assigned to user", diff --git a/dao/src/main/java/org/thingsboard/server/dao/service/ConstraintValidator.java b/dao/src/main/java/org/thingsboard/server/dao/service/ConstraintValidator.java index 41a618959a..b884de0094 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/service/ConstraintValidator.java +++ b/dao/src/main/java/org/thingsboard/server/dao/service/ConstraintValidator.java @@ -17,6 +17,7 @@ package org.thingsboard.server.dao.service; import com.google.common.collect.Iterators; import lombok.extern.slf4j.Slf4j; +import org.apache.commons.lang3.StringUtils; import org.hibernate.validator.HibernateValidator; import org.hibernate.validator.HibernateValidatorConfiguration; import org.hibernate.validator.cfg.ConstraintMapping; @@ -29,11 +30,13 @@ import org.thingsboard.server.common.data.validation.Length; import org.thingsboard.server.common.data.validation.NoXss; import org.thingsboard.server.dao.exception.DataValidationException; -import javax.validation.Path; +import javax.validation.ConstraintViolation; import javax.validation.Validation; import javax.validation.Validator; import javax.validation.constraints.AssertTrue; -import java.util.List; +import javax.validation.metadata.ConstraintDescriptor; +import java.util.Collection; +import java.util.Set; import java.util.stream.Collectors; @Slf4j @@ -51,26 +54,31 @@ public class ConstraintValidator { } public static void validateFields(Object data, String errorPrefix) { - List constraintsViolations = getConstraintsViolations(data); + Set> constraintsViolations = fieldsValidator.validate(data); if (!constraintsViolations.isEmpty()) { - throw new DataValidationException(errorPrefix + String.join(", ", constraintsViolations)); + throw new DataValidationException(errorPrefix + getErrorMessage(constraintsViolations)); } } - public static List getConstraintsViolations(Object data) { - return fieldsValidator.validate(data).stream() - .map(constraintViolation -> { - String property; - if (constraintViolation.getConstraintDescriptor().getAttributes().containsKey("fieldName")) { - property = constraintViolation.getConstraintDescriptor().getAttributes().get("fieldName").toString(); - } else { - Path propertyPath = constraintViolation.getPropertyPath(); - property = Iterators.getLast(propertyPath.iterator()).toString(); - } - return property + " " + constraintViolation.getMessage(); - }) - .distinct() - .collect(Collectors.toList()); + public static String getErrorMessage(Collection> constraintsViolations) { + return constraintsViolations.stream() + .map(ConstraintValidator::getErrorMessage) + .distinct().sorted().collect(Collectors.joining(", ")); + } + + public static String getErrorMessage(ConstraintViolation constraintViolation) { + ConstraintDescriptor constraintDescriptor = constraintViolation.getConstraintDescriptor(); + String property = (String) constraintDescriptor.getAttributes().get("fieldName"); + if (StringUtils.isEmpty(property) && !(constraintDescriptor.getAnnotation() instanceof AssertTrue)) { + property = Iterators.getLast(constraintViolation.getPropertyPath().iterator()).toString(); + } + + String error = ""; + if (StringUtils.isNotEmpty(property)) { + error += property + " "; + } + error += constraintViolation.getMessage(); + return error; } private static void initializeValidators() { diff --git a/dao/src/main/java/org/thingsboard/server/dao/service/validator/DeviceProfileDataValidator.java b/dao/src/main/java/org/thingsboard/server/dao/service/validator/DeviceProfileDataValidator.java index 400cc0e561..24e5ceb1b2 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/service/validator/DeviceProfileDataValidator.java +++ b/dao/src/main/java/org/thingsboard/server/dao/service/validator/DeviceProfileDataValidator.java @@ -17,10 +17,13 @@ package org.thingsboard.server.dao.service.validator; import com.google.protobuf.Descriptors; import com.google.protobuf.DynamicMessage; +import lombok.extern.slf4j.Slf4j; import org.eclipse.leshan.core.util.SecurityUtil; import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; import org.springframework.context.annotation.Lazy; import org.springframework.stereotype.Component; +import org.springframework.util.Base64Utils; import org.springframework.util.CollectionUtils; import org.thingsboard.server.common.data.DashboardInfo; import org.thingsboard.server.common.data.DeviceProfile; @@ -55,10 +58,22 @@ import org.thingsboard.server.dao.queue.QueueService; import org.thingsboard.server.dao.rule.RuleChainService; import org.thingsboard.server.dao.tenant.TenantService; +import java.io.FileInputStream; +import java.io.IOException; +import java.security.InvalidAlgorithmParameterException; +import java.security.KeyStore; +import java.security.KeyStoreException; +import java.security.NoSuchAlgorithmException; +import java.security.cert.CertificateEncodingException; +import java.security.cert.CertificateException; +import java.security.cert.PKIXParameters; +import java.security.cert.TrustAnchor; +import java.security.cert.X509Certificate; import java.util.HashSet; import java.util.List; import java.util.Set; +@Slf4j @Component public class DeviceProfileDataValidator extends AbstractHasOtaPackageValidator { @@ -85,6 +100,12 @@ public class DeviceProfileDataValidator extends AbstractHasOtaPackageValidator implements A return alarmQueryRepository.countAlarmsByQuery(tenantId, customerId, query); } - @NotNull private static String getPropagationTypes(AlarmPropagationInfo ap) { String propagateRelationTypes; if (!CollectionUtils.isEmpty(ap.getPropagateRelationTypes())) { diff --git a/dao/src/main/java/org/thingsboard/server/dao/sql/edge/JpaBaseEdgeEventDao.java b/dao/src/main/java/org/thingsboard/server/dao/sql/edge/JpaBaseEdgeEventDao.java index 88fa72992b..3b126bf4d1 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/sql/edge/JpaBaseEdgeEventDao.java +++ b/dao/src/main/java/org/thingsboard/server/dao/sql/edge/JpaBaseEdgeEventDao.java @@ -80,11 +80,11 @@ public class JpaBaseEdgeEventDao extends JpaAbstractSearchTextDao 0 ? System.currentTimeMillis() - TimeUnit.SECONDS.toMillis(edge_events_ttl) : 1629158400000L; + long startTime = edgeEventsTtl > 0 ? System.currentTimeMillis() - TimeUnit.SECONDS.toMillis(edgeEventsTtl) : 1629158400000L; long currentTime = System.currentTimeMillis(); var partitionStepInMs = TimeUnit.HOURS.toMillis(partitionSizeInHours); diff --git a/dao/src/main/java/org/thingsboard/server/dao/timeseries/BaseTimeseriesService.java b/dao/src/main/java/org/thingsboard/server/dao/timeseries/BaseTimeseriesService.java index c1e3ca85ce..1f5f21acc8 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/timeseries/BaseTimeseriesService.java +++ b/dao/src/main/java/org/thingsboard/server/dao/timeseries/BaseTimeseriesService.java @@ -42,6 +42,7 @@ import org.thingsboard.server.common.data.kv.TsKvLatestRemovingResult; import org.thingsboard.server.dao.entityview.EntityViewService; import org.thingsboard.server.dao.exception.IncorrectParameterException; import org.thingsboard.server.dao.service.Validator; +import org.thingsboard.server.dao.util.KvUtils; import java.util.Collection; import java.util.Collections; @@ -80,6 +81,9 @@ public class BaseTimeseriesService implements TimeseriesService { @Value("${database.ts_max_intervals}") private long maxTsIntervals; + @Value("${sql.ts.noxss_validation_enabled:true}") + private boolean noxssValidationEnabled; + @Autowired private TimeseriesDao timeseriesDao; @@ -155,10 +159,8 @@ public class BaseTimeseriesService implements TimeseriesService { @Override public ListenableFuture save(TenantId tenantId, EntityId entityId, TsKvEntry tsKvEntry) { + KvUtils.validate(tsKvEntry, noxssValidationEnabled); validate(entityId); - if (tsKvEntry == null) { - throw new IncorrectParameterException("Key value entry can't be null"); - } List> futures = Lists.newArrayListWithExpectedSize(INSERTS_PER_ENTRY); saveAndRegisterFutures(tenantId, futures, entityId, tsKvEntry, 0L); return Futures.transform(Futures.allAsList(futures), SUM_ALL_INTEGERS, MoreExecutors.directExecutor()); @@ -175,12 +177,10 @@ public class BaseTimeseriesService implements TimeseriesService { } private ListenableFuture doSave(TenantId tenantId, EntityId entityId, List tsKvEntries, long ttl, boolean saveLatest) { + KvUtils.validate(tsKvEntries, noxssValidationEnabled); int inserts = saveLatest ? INSERTS_PER_ENTRY : INSERTS_PER_ENTRY_WITHOUT_LATEST; List> futures = Lists.newArrayListWithExpectedSize(tsKvEntries.size() * inserts); for (TsKvEntry tsKvEntry : tsKvEntries) { - if (tsKvEntry == null) { - throw new IncorrectParameterException("Key value entry can't be null"); - } if (saveLatest) { saveAndRegisterFutures(tenantId, futures, entityId, tsKvEntry, ttl); } else { @@ -192,11 +192,9 @@ public class BaseTimeseriesService implements TimeseriesService { @Override public ListenableFuture> saveLatest(TenantId tenantId, EntityId entityId, List tsKvEntries) { + KvUtils.validate(tsKvEntries, noxssValidationEnabled); List> futures = Lists.newArrayListWithExpectedSize(tsKvEntries.size()); for (TsKvEntry tsKvEntry : tsKvEntries) { - if (tsKvEntry == null) { - throw new IncorrectParameterException("Key value entry can't be null"); - } futures.add(timeseriesLatestDao.saveLatest(tenantId, entityId, tsKvEntry)); } return Futures.allAsList(futures); diff --git a/dao/src/main/java/org/thingsboard/server/dao/util/KvUtils.java b/dao/src/main/java/org/thingsboard/server/dao/util/KvUtils.java new file mode 100644 index 0000000000..133fb4c85e --- /dev/null +++ b/dao/src/main/java/org/thingsboard/server/dao/util/KvUtils.java @@ -0,0 +1,37 @@ +/** + * Copyright © 2016-2023 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.util; + +import org.thingsboard.server.common.data.kv.KvEntry; +import org.thingsboard.server.dao.exception.IncorrectParameterException; +import org.thingsboard.server.dao.service.ConstraintValidator; + +import java.util.List; + +public class KvUtils { + public static void validate(List tsKvEntries, boolean validateNoxss) { + tsKvEntries.forEach(kvEntry -> validate(kvEntry, validateNoxss)); + } + + public static void validate(KvEntry tsKvEntry, boolean validateNoxss) { + if (tsKvEntry == null) { + throw new IncorrectParameterException("Key value entry can't be null"); + } + if (validateNoxss) { + ConstraintValidator.validateFields(tsKvEntry); + } + } +} diff --git a/dao/src/test/java/org/thingsboard/server/dao/service/ConstraintValidatorTest.java b/dao/src/test/java/org/thingsboard/server/dao/service/ConstraintValidatorTest.java new file mode 100644 index 0000000000..fa3d39df30 --- /dev/null +++ b/dao/src/test/java/org/thingsboard/server/dao/service/ConstraintValidatorTest.java @@ -0,0 +1,57 @@ +/** + * Copyright © 2016-2023 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.dao.service; + +import org.junit.Assert; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; +import org.thingsboard.server.common.data.kv.JsonDataEntry; +import org.thingsboard.server.common.data.kv.StringDataEntry; +import org.thingsboard.server.dao.exception.DataValidationException; + +class ConstraintValidatorTest { + + private static final int MIN_IN_MS = 60000; + private static final int _1M = 1_000_000; + + @Test + void validateFields() { + StringDataEntry stringDataEntryValid = new StringDataEntry("key", "value"); + + StringDataEntry stringDataEntryInvalid1 = new StringDataEntry("", "value"); + StringDataEntry stringDataEntryInvalid2 = new StringDataEntry("key", ""); + + JsonDataEntry jsonDataEntryInvalid = new JsonDataEntry("key", "{\"value\": }"); + + Assert.assertThrows(DataValidationException.class, () -> ConstraintValidator.validateFields(stringDataEntryInvalid1)); + Assert.assertThrows(DataValidationException.class, () -> ConstraintValidator.validateFields(stringDataEntryInvalid2)); + Assert.assertThrows(DataValidationException.class, () -> ConstraintValidator.validateFields(jsonDataEntryInvalid)); + ConstraintValidator.validateFields(stringDataEntryValid); + } + + @Test + void validatePerMinute() { + StringDataEntry stringDataEntryValid = new StringDataEntry("key", "value"); + + long start = System.currentTimeMillis(); + for (int i = 0; i < _1M; i++) { + ConstraintValidator.validateFields(stringDataEntryValid); + } + long end = System.currentTimeMillis(); + + Assertions.assertTrue(MIN_IN_MS > end - start); + } +} \ No newline at end of file diff --git a/dao/src/test/resources/application-test.properties b/dao/src/test/resources/application-test.properties index b55fba1f3b..d89211cb2f 100644 --- a/dao/src/test/resources/application-test.properties +++ b/dao/src/test/resources/application-test.properties @@ -82,6 +82,8 @@ redis.connection.password= security.user_login_case_sensitive=true security.claim.allowClaimingByDefault=true security.claim.duration=60000 +security.java_cacerts.path=/path/to/cacerts/file +security.java_cacerts.password=myPassword database.ts_max_intervals=700 diff --git a/msa/black-box-tests/README.md b/msa/black-box-tests/README.md index 5b63ba712a..4a88d5e8cd 100644 --- a/msa/black-box-tests/README.md +++ b/msa/black-box-tests/README.md @@ -54,6 +54,7 @@ As result, in REPOSITORY column, next images should be present: mvn clean install -DblackBoxTests.skip=false -Dsuite=all - - - +### To run a separate test manually on a built UI: +1. Add the black-box-tests module in the [pom.xml](../pom.xml) or add as a Maven project +2. Add Vm Option "*-DrunLocal=true -Dtb.baseUiUrl=http://localhost:4200/*" in "Run" -> "Edit Configuration" -> "Edit Configuration Templates" -> "TestNG" +3. To run a specific test, go to the test class in the [UI tests package](../black-box-tests/src/test/java/org/thingsboard/server/msa/ui/tests) and run the test. Alternatively, go to the [resources](../black-box-tests/src/test/resources) in the black-box-tests module and run the test suite that you need. \ No newline at end of file diff --git a/msa/black-box-tests/src/test/java/org/thingsboard/server/msa/TestProperties.java b/msa/black-box-tests/src/test/java/org/thingsboard/server/msa/TestProperties.java index be9a5c422a..c9fed6a670 100644 --- a/msa/black-box-tests/src/test/java/org/thingsboard/server/msa/TestProperties.java +++ b/msa/black-box-tests/src/test/java/org/thingsboard/server/msa/TestProperties.java @@ -18,10 +18,6 @@ package org.thingsboard.server.msa; import lombok.extern.slf4j.Slf4j; import org.testcontainers.DockerClientFactory; -import java.io.IOException; -import java.io.InputStream; -import java.util.Properties; - @Slf4j public class TestProperties { @@ -31,41 +27,26 @@ public class TestProperties { private static final ContainerTestSuite instance = ContainerTestSuite.getInstance(); - private static Properties properties; - public static String getBaseUrl() { if (instance.isActive()) { return HTTPS_URL; } - return getProperties().getProperty("tb.baseUrl"); + return System.getProperty("tb.baseUrl", "http://localhost:8080"); } public static String getBaseUiUrl() { if (instance.isActive()) { - //return "https://host.docker.internal" // this alternative requires docker-selenium.yml extra_hosts: - "host.docker.internal:host-gateway" + //return "https://host.docker.internal"; // this alternative requires docker-selenium.yml extra_hosts: - "host.docker.internal:host-gateway" //return "https://" + DockerClientFactory.instance().dockerHostIpAddress(); //this alternative will get Docker IP from testcontainers return "https://haproxy"; //communicate inside current docker-compose network to the load balancer container } - return getProperties().getProperty("tb.baseUiUrl"); + return System.getProperty("tb.baseUiUrl", "http://localhost:8080"); } public static String getWebSocketUrl() { if (instance.isActive()) { return WSS_URL; } - return getProperties().getProperty("tb.wsUrl"); - } - - private static Properties getProperties() { - if (properties == null) { - try (InputStream input = TestProperties.class.getClassLoader().getResourceAsStream("config.properties")) { - properties = new Properties(); - properties.load(input); - } catch (IOException ex) { - log.error("Exception while reading test properties " + ex.getMessage()); - } - } - return properties; + return System.getProperty("tb.wsUrl", "ws://localhost:8080"); } - } diff --git a/msa/black-box-tests/src/test/java/org/thingsboard/server/msa/ui/tests/ruleChainsSmoke/RuleChainEditMenuTest.java b/msa/black-box-tests/src/test/java/org/thingsboard/server/msa/ui/tests/ruleChainsSmoke/RuleChainEditMenuTest.java index 5b380221b6..3c65bc8f0a 100644 --- a/msa/black-box-tests/src/test/java/org/thingsboard/server/msa/ui/tests/ruleChainsSmoke/RuleChainEditMenuTest.java +++ b/msa/black-box-tests/src/test/java/org/thingsboard/server/msa/ui/tests/ruleChainsSmoke/RuleChainEditMenuTest.java @@ -151,12 +151,12 @@ public class RuleChainEditMenuTest extends AbstractDriverBaseTest { ruleChainsPage.editPencilBtn().click(); ruleChainsPage.debugCheckboxEdit().click(); ruleChainsPage.doneBtnEditView().click(); - boolean debugMode = Boolean.parseBoolean(ruleChainsPage.debugCheckboxView().getAttribute("aria-checked")); + boolean debugMode = ruleChainsPage.debugCheckboxView().getAttribute("class").contains("selected"); ruleChainsPage.editPencilBtn().click(); ruleChainsPage.debugCheckboxEdit().click(); ruleChainsPage.doneBtnEditView().click(); - Assert.assertFalse(Boolean.parseBoolean(ruleChainsPage.debugCheckboxView().getAttribute("aria-checked"))); - Assert.assertTrue(debugMode); + Assert.assertFalse(ruleChainsPage.debugCheckboxView().getAttribute("class").contains("selected"), "Debug mode disable"); + Assert.assertTrue(debugMode, "Debug mode enable"); } } \ No newline at end of file diff --git a/msa/black-box-tests/src/test/resources/config.properties b/msa/black-box-tests/src/test/resources/config.properties deleted file mode 100644 index 4d8a0880a9..0000000000 --- a/msa/black-box-tests/src/test/resources/config.properties +++ /dev/null @@ -1,3 +0,0 @@ -tb.baseUrl=http://localhost:8080 -tb.baseUiUrl=http://localhost:8080 -tb.wsUrl=ws://localhost:8080 \ No newline at end of file diff --git a/pom.xml b/pom.xml index 2a2b291d80..f78a4826bc 100755 --- a/pom.xml +++ b/pom.xml @@ -39,13 +39,13 @@ 1.3.2 2.3.2 2.3.2 - 2.7.0 - 2.7.0 - 5.3.20 - 5.5.12 - 5.7.1 - 2.7.0 - 3.7.1 + 2.7.10 + 2.7.10 + 5.3.26 + 5.5.17 + 5.7.7 + 2.7.10 + 3.8.0 0.7.0 1.7.32 2.17.1 @@ -75,14 +75,14 @@ 2.3.30 1.6.2 4.2.0 - 3.5.5 + 3.8.1 3.21.9 1.42.1 1.0.6 1.18.18 1.2.4 1.2.5 - 4.1.75.Final + 4.1.91.Final 2.0.51.Final 1.7.0 4.8.0 @@ -107,13 +107,13 @@ 4.1.1 2.57 2.7.7 - 1.27 + 2.0 1.11.747 1.105.0 2.1.0 3.2.0 1.5.0 - 1.4.3 + 1.5.4 1.9.4 3.2.2 1.9.0 @@ -123,7 +123,7 @@ 6.0.20.Final 3.0.0 2.0.1.Final - 1.6.8 + 1.7.2 2.8.5 4.1.0 diff --git a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/action/TbCopyAttributesToEntityViewNode.java b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/action/TbCopyAttributesToEntityViewNode.java index 029531494e..65b7800fe3 100644 --- a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/action/TbCopyAttributesToEntityViewNode.java +++ b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/action/TbCopyAttributesToEntityViewNode.java @@ -21,7 +21,6 @@ import com.google.gson.JsonElement; import com.google.gson.JsonParser; import com.google.gson.JsonPrimitive; import lombok.extern.slf4j.Slf4j; -import org.jetbrains.annotations.NotNull; import org.thingsboard.server.common.data.util.CollectionsUtil; import org.thingsboard.common.util.DonAsynchron; import org.thingsboard.rule.engine.api.EmptyNodeConfiguration; @@ -127,7 +126,6 @@ public class TbCopyAttributesToEntityViewNode implements TbNode { } } - @NotNull private FutureCallback getFutureCallback(TbContext ctx, TbMsg msg, EntityView entityView) { return new FutureCallback() { @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 3f3b04dd89..a920af5066 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 @@ -29,7 +29,7 @@ import org.thingsboard.server.common.data.plugin.ComponentType; type = ComponentType.FILTER, name = "entity type switch", configClazz = EmptyNodeConfiguration.class, - relationTypes = {"Device", "Asset", "Alarm", "Entity View", "Tenant", "Customer", "User", "Dashboard", "Rule chain", "Rule node"}, + relationTypes = {"Device", "Asset", "Alarm", "Entity View", "Tenant", "Customer", "User", "Dashboard", "Rule chain", "Rule node", "Edge"}, nodeDescription = "Route incoming messages by Message Originator Type", nodeDetails = "Routes messages to chain according to the entity type ('Device', 'Asset', etc.).", uiResources = {"static/rulenode/rulenode-core-config.js"}, diff --git a/transport/mqtt/src/main/resources/tb-mqtt-transport.yml b/transport/mqtt/src/main/resources/tb-mqtt-transport.yml index 89074fd649..ec60357764 100644 --- a/transport/mqtt/src/main/resources/tb-mqtt-transport.yml +++ b/transport/mqtt/src/main/resources/tb-mqtt-transport.yml @@ -114,7 +114,7 @@ transport: # Server SSL credentials credentials: # Server credentials type (PEM - pem certificate file; KEYSTORE - java keystore) - type: "${MQTT_SSL_CREDENTIALS_TYPE:PEM}" + type: "${MQTT_SSL_CREDENTIALS_TYPE:PEM}" # PEM server credentials pem: # Path to the server certificate file (holds server certificate or certificate chain, may include server private key) diff --git a/ui-ngx/package.json b/ui-ngx/package.json index de5e97f79f..f23e3f0b9a 100644 --- a/ui-ngx/package.json +++ b/ui-ngx/package.json @@ -97,7 +97,7 @@ "screenfull": "^6.0.2", "split.js": "^1.6.5", "systemjs": "6.11.0", - "tinycolor2": "~1.4.2", + "tinycolor2": "^1.6.0", "tinymce": "~5.10.7", "tooltipster": "^4.2.8", "ts-transformer-keys": "^0.4.4", diff --git a/ui-ngx/src/app/core/auth/auth.service.ts b/ui-ngx/src/app/core/auth/auth.service.ts index 311de3d7a0..d1be94fdb6 100644 --- a/ui-ngx/src/app/core/auth/auth.service.ts +++ b/ui-ngx/src/app/core/auth/auth.service.ts @@ -297,16 +297,6 @@ export class AuthService { } else if (authState.authUser.isPublic) { result = this.router.parseUrl(`dashboard/${authState.lastPublicDashboardId}`); } - } else if (authState.authUser.authority === Authority.SYS_ADMIN) { - this.adminService.checkUpdates().subscribe((updateMessage) => { - if (updateMessage && updateMessage.updateAvailable) { - this.store.dispatch(new ActionNotificationShow( - {message: updateMessage.message, - type: 'info', - verticalPosition: 'bottom', - horizontalPosition: 'right'})); - } - }); } } } else { diff --git a/ui-ngx/src/app/core/http/admin.service.ts b/ui-ngx/src/app/core/http/admin.service.ts index 6874d21d74..cd83c3c8c9 100644 --- a/ui-ngx/src/app/core/http/admin.service.ts +++ b/ui-ngx/src/app/core/http/admin.service.ts @@ -21,6 +21,7 @@ import { HttpClient } from '@angular/common/http'; import { AdminSettings, AutoCommitSettings, + FeaturesInfo, JwtSettings, MailServerSettings, RepositorySettings, @@ -131,4 +132,8 @@ export class AdminService { public checkUpdates(config?: RequestConfig): Observable { return this.http.get(`/api/admin/updates`, defaultHttpOptionsFromConfig(config)); } + + public getFeaturesInfo(config?: RequestConfig): Observable { + return this.http.get('/api/admin/featuresInfo', defaultHttpOptionsFromConfig(config)); + } } diff --git a/ui-ngx/src/app/core/http/user-settings.service.ts b/ui-ngx/src/app/core/http/user-settings.service.ts index 8aed9d1345..55ce524750 100644 --- a/ui-ngx/src/app/core/http/user-settings.service.ts +++ b/ui-ngx/src/app/core/http/user-settings.service.ts @@ -16,10 +16,16 @@ import { Injectable } from '@angular/core'; import { Observable } from 'rxjs'; -import { initialUserSettings, UserSettings } from '@shared/models/user-settings.models'; +import { + DocumentationLink, DocumentationLinks, + GettingStarted, + initialUserSettings, + UserSettings, + UserSettingsType +} from '@shared/models/user-settings.models'; import { map } from 'rxjs/operators'; import { HttpClient } from '@angular/common/http'; -import { defaultHttpOptionsFromConfig } from '@core/http/http-utils'; +import { defaultHttpOptionsFromConfig, RequestConfig } from '@core/http/http-utils'; @Injectable({ providedIn: 'root' @@ -51,4 +57,24 @@ export class UserSettingsService { defaultHttpOptionsFromConfig({ignoreLoading: true, ignoreErrors: true})); } + public getDocumentationLinks(config?: RequestConfig): Observable { + return this.http.get(`/api/user/settings/${UserSettingsType.DOC_LINKS}`, + defaultHttpOptionsFromConfig(config)); + } + + public updateDocumentationLinks(documentationLinks: DocumentationLinks, config?: RequestConfig): Observable { + return this.http.put(`/api/user/settings/${UserSettingsType.DOC_LINKS}`, documentationLinks, + defaultHttpOptionsFromConfig(config)); + } + + public getGettingStarted(config?: RequestConfig): Observable { + return this.http.get(`/api/user/settings/${UserSettingsType.GETTING_STARTED}`, + defaultHttpOptionsFromConfig(config)); + } + + public updateGettingStarted(gettingStarted: GettingStarted, config?: RequestConfig): Observable { + return this.http.put(`/api/user/settings/${UserSettingsType.GETTING_STARTED}`, gettingStarted, + defaultHttpOptionsFromConfig(config)); + } + } diff --git a/ui-ngx/src/app/core/services/dashboard-utils.service.ts b/ui-ngx/src/app/core/services/dashboard-utils.service.ts index 2d4e6af94b..a56b8f1708 100644 --- a/ui-ngx/src/app/core/services/dashboard-utils.service.ts +++ b/ui-ngx/src/app/core/services/dashboard-utils.service.ts @@ -238,6 +238,7 @@ export class DashboardUtilsService { backgroundColor: '#eeeeee', columns: 24, margin: 10, + outerMargin: true, backgroundSizeMode: '100%' }; } @@ -282,6 +283,7 @@ export class DashboardUtilsService { layout.gridSettings.margin = layout.gridSettings.margins[0]; delete layout.gridSettings.margins; } + layout.gridSettings.outerMargin = isDefined(layout.gridSettings.outerMargin) ? layout.gridSettings.outerMargin : true; layout.gridSettings.margin = isDefined(layout.gridSettings.margin) ? layout.gridSettings.margin : 10; } diff --git a/ui-ngx/src/app/modules/home/components/alarm/alarm-assignee-panel.component.html b/ui-ngx/src/app/modules/home/components/alarm/alarm-assignee-panel.component.html index f01c550444..fb84ca4481 100644 --- a/ui-ngx/src/app/modules/home/components/alarm/alarm-assignee-panel.component.html +++ b/ui-ngx/src/app/modules/home/components/alarm/alarm-assignee-panel.component.html @@ -22,7 +22,7 @@ (focusin)="onFocus()" [matAutocomplete]="userAutocomplete"> search - @@ -41,10 +41,12 @@ - - - {{ translate.get('user.no-users-matching', {entity: searchText}) | async }} - + +
+ + {{ translate.get('user.no-users-matching', {entity: searchText}) | async }} + +
diff --git a/ui-ngx/src/app/modules/home/components/alarm/alarm-comment-dialog.component.html b/ui-ngx/src/app/modules/home/components/alarm/alarm-comment-dialog.component.html index d7e3366a35..8618592ae0 100644 --- a/ui-ngx/src/app/modules/home/components/alarm/alarm-comment-dialog.component.html +++ b/ui-ngx/src/app/modules/home/components/alarm/alarm-comment-dialog.component.html @@ -15,7 +15,7 @@ limitations under the License. --> -
+
- + diff --git a/ui-ngx/src/app/modules/home/components/alarm/alarm-details-dialog.component.html b/ui-ngx/src/app/modules/home/components/alarm/alarm-details-dialog.component.html index 85bdf96ea3..0db5bd9584 100644 --- a/ui-ngx/src/app/modules/home/components/alarm/alarm-details-dialog.component.html +++ b/ui-ngx/src/app/modules/home/components/alarm/alarm-details-dialog.component.html @@ -15,7 +15,7 @@ limitations under the License. --> -
+

{{ 'alarm.alarm-details' | translate }}

diff --git a/ui-ngx/src/app/modules/home/components/dashboard-page/dashboard-settings-dialog.component.html b/ui-ngx/src/app/modules/home/components/dashboard-page/dashboard-settings-dialog.component.html index 9c1732791c..2090e5f42c 100644 --- a/ui-ngx/src/app/modules/home/components/dashboard-page/dashboard-settings-dialog.component.html +++ b/ui-ngx/src/app/modules/home/components/dashboard-page/dashboard-settings-dialog.component.html @@ -132,6 +132,9 @@ {{ 'dashboard.max-margin-message' | translate }} + + {{ 'dashboard.apply-outer-margin' | translate }} + {{ 'dashboard.autofill-height' | translate }} diff --git a/ui-ngx/src/app/modules/home/components/dashboard-page/dashboard-settings-dialog.component.ts b/ui-ngx/src/app/modules/home/components/dashboard-page/dashboard-settings-dialog.component.ts index f1407d6aaf..4844211355 100644 --- a/ui-ngx/src/app/modules/home/components/dashboard-page/dashboard-settings-dialog.component.ts +++ b/ui-ngx/src/app/modules/home/components/dashboard-page/dashboard-settings-dialog.component.ts @@ -156,6 +156,7 @@ export class DashboardSettingsDialogComponent extends DialogComponent
{ + this.addEntity(null); + }, 0); + } + } } ngAfterViewInit() { diff --git a/ui-ngx/src/app/modules/home/components/filter/filters-dialog.component.html b/ui-ngx/src/app/modules/home/components/filter/filters-dialog.component.html index 2d24bc479f..69f72c8aaf 100644 --- a/ui-ngx/src/app/modules/home/components/filter/filters-dialog.component.html +++ b/ui-ngx/src/app/modules/home/components/filter/filters-dialog.component.html @@ -69,7 +69,7 @@ style="min-width: 48px;" type="button" (click)="duplicateFilter($index)" - matTooltip="{{ 'filter.duplicate-filter' | translate }}" + matTooltip="{{ 'filter.duplicate-filter-action' | translate }}" matTooltipPosition="above"> content_copy diff --git a/ui-ngx/src/app/modules/home/components/profile/device-profile-provision-configuration.component.html b/ui-ngx/src/app/modules/home/components/profile/device-profile-provision-configuration.component.html index cd711f9e12..afbaa924ac 100644 --- a/ui-ngx/src/app/modules/home/components/profile/device-profile-provision-configuration.component.html +++ b/ui-ngx/src/app/modules/home/components/profile/device-profile-provision-configuration.component.html @@ -27,36 +27,80 @@ {{ 'device-profile.provision-strategy-required' | translate }} -
- - device-profile.provision-device-key - - - - {{ 'device-profile.provision-device-key-required' | translate }} - - - - device-profile.provision-device-secret - - - - {{ 'device-profile.provision-device-secret-required' | translate }} - - -
+
+ + + + + + + +
+ + +
+ + {{ 'device-profile.provision-strategy-x509.allow-create-new-devices' | translate }} + +
+ + device-profile.provision-strategy-x509.certificate-value + + + {{ 'device-profile.provision-strategy-x509.certificate-value-required' | translate }} + + + + device-profile.provision-strategy-x509.cn-regex-variable + + + + {{ 'device-profile.provision-strategy-x509.cn-regex-variable-required' | translate }} + + device-profile.provision-strategy-x509.cn-regex-variable-hint + +
+ +
+ + device-profile.provision-device-key + + + + {{ 'device-profile.provision-device-key-required' | translate }} + + + + device-profile.provision-device-secret + + + + {{ 'device-profile.provision-device-secret-required' | translate }} + + +
+
+
diff --git a/ui-ngx/src/app/modules/home/components/profile/device-profile-provision-configuration.component.ts b/ui-ngx/src/app/modules/home/components/profile/device-profile-provision-configuration.component.ts index 872571dc20..54a9b98aa5 100644 --- a/ui-ngx/src/app/modules/home/components/profile/device-profile-provision-configuration.component.ts +++ b/ui-ngx/src/app/modules/home/components/profile/device-profile-provision-configuration.component.ts @@ -32,7 +32,7 @@ import { DeviceProvisionType, deviceProvisionTypeTranslationMap } from '@shared/models/device.models'; -import { generateSecret, isDefinedAndNotNull } from '@core/utils'; +import { generateSecret, isBoolean, isDefinedAndNotNull } from '@core/utils'; import { ActionNotificationShow } from '@core/notification/notification.actions'; import { Store } from '@ngrx/store'; import { AppState } from '@core/core.state'; @@ -86,14 +86,36 @@ export class DeviceProfileProvisionConfigurationComponent implements ControlValu this.provisionConfigurationFormGroup = this.fb.group({ type: [DeviceProvisionType.DISABLED, Validators.required], provisionDeviceSecret: [{value: null, disabled: true}, Validators.required], - provisionDeviceKey: [{value: null, disabled: true}, Validators.required] + provisionDeviceKey: [{value: null, disabled: true}, Validators.required], + certificateValue: [{value: null, disabled: true}, Validators.required], + certificateRegExPattern: [{value: null, disabled: true}, Validators.required], + allowCreateNewDevicesByX509Certificate: [{value: null, disabled: true}, Validators.required] }); this.provisionConfigurationFormGroup.get('type').valueChanges.subscribe((type) => { if (type === DeviceProvisionType.DISABLED) { - this.provisionConfigurationFormGroup.get('provisionDeviceSecret').disable({emitEvent: false}); - this.provisionConfigurationFormGroup.get('provisionDeviceSecret').patchValue(null, {emitEvent: false}); - this.provisionConfigurationFormGroup.get('provisionDeviceKey').disable({emitEvent: false}); - this.provisionConfigurationFormGroup.get('provisionDeviceKey').patchValue(null); + for (const field in this.provisionConfigurationFormGroup.controls) { + if (field !== 'type') { + const control = this.provisionConfigurationFormGroup.get(field); + control.disable({emitEvent: false}); + control.patchValue(null, {emitEvent: false}); + } + } + } else if (type === DeviceProvisionType.X509_CERTIFICATE_CHAIN) { + const certificateValue: string = this.provisionConfigurationFormGroup.get('certificateValue').value; + if (!certificateValue || !certificateValue.length) { + this.provisionConfigurationFormGroup.get('certificateValue').patchValue(null, {emitEvent: false}); + } + const certificateRegExPattern: string = this.provisionConfigurationFormGroup.get('certificateRegExPattern').value; + if (!certificateRegExPattern || !certificateRegExPattern.length) { + this.provisionConfigurationFormGroup.get('certificateRegExPattern').patchValue('[\\w]*', {emitEvent: false}); + } + const allowCreateNewDevicesByX509Certificate: boolean | null = this.provisionConfigurationFormGroup.get('allowCreateNewDevicesByX509Certificate').value; + if (!isBoolean(allowCreateNewDevicesByX509Certificate)) { + this.provisionConfigurationFormGroup.get('allowCreateNewDevicesByX509Certificate').patchValue(true, {emitEvent: false}); + } + this.provisionConfigurationFormGroup.get('certificateValue').enable({emitEvent: false}); + this.provisionConfigurationFormGroup.get('certificateRegExPattern').enable({emitEvent: false}); + this.provisionConfigurationFormGroup.get('allowCreateNewDevicesByX509Certificate').enable({emitEvent: false}); } else { const provisionDeviceSecret: string = this.provisionConfigurationFormGroup.get('provisionDeviceSecret').value; if (!provisionDeviceSecret || !provisionDeviceSecret.length) { @@ -120,16 +142,19 @@ export class DeviceProfileProvisionConfigurationComponent implements ControlValu } writeValue(value: DeviceProvisionConfiguration | null): void { - if (isDefinedAndNotNull(value)){ + if (isDefinedAndNotNull(value)) { + if (value.type === DeviceProvisionType.X509_CERTIFICATE_CHAIN) { + value.certificateValue = value.provisionDeviceSecret; + } this.provisionConfigurationFormGroup.patchValue(value, {emitEvent: false}); } else { this.provisionConfigurationFormGroup.patchValue({type: DeviceProvisionType.DISABLED}); } } - setDisabledState(isDisabled: boolean){ + setDisabledState(isDisabled: boolean) { this.disabled = isDisabled; - if (this.disabled){ + if (this.disabled) { this.provisionConfigurationFormGroup.disable({emitEvent: false}); } else { if (this.provisionConfigurationFormGroup.get('type').value !== DeviceProvisionType.DISABLED) { @@ -150,8 +175,12 @@ export class DeviceProfileProvisionConfigurationComponent implements ControlValu private updateModel(): void { let deviceProvisionConfiguration: DeviceProvisionConfiguration = null; + this.resetFormControls(this.provisionConfigurationFormGroup.value); if (this.provisionConfigurationFormGroup.valid) { deviceProvisionConfiguration = this.provisionConfigurationFormGroup.getRawValue(); + if (deviceProvisionConfiguration.type === DeviceProvisionType.X509_CERTIFICATE_CHAIN) { + deviceProvisionConfiguration.provisionDeviceSecret = deviceProvisionConfiguration.certificateValue; + } } this.propagateChange(deviceProvisionConfiguration); } @@ -166,4 +195,15 @@ export class DeviceProfileProvisionConfigurationComponent implements ControlValu horizontalPosition: 'right' })); } + + private resetFormControls(value: DeviceProvisionConfiguration) { + if (value.type === DeviceProvisionType.CHECK_PRE_PROVISIONED_DEVICES || value.type === DeviceProvisionType.ALLOW_CREATE_NEW_DEVICES) { + this.provisionConfigurationFormGroup.get('certificateValue').reset({value: null, disabled: true}, {emitEvent: false}); + this.provisionConfigurationFormGroup.get('certificateRegExPattern').reset({value: null, disabled: true}, {emitEvent: false}); + this.provisionConfigurationFormGroup.get('allowCreateNewDevicesByX509Certificate').reset({value: null, disabled: true}, {emitEvent: false}); + } else if (value.type === DeviceProvisionType.X509_CERTIFICATE_CHAIN) { + this.provisionConfigurationFormGroup.get('provisionDeviceSecret').reset({value: null, disabled: true}, {emitEvent: false}); + this.provisionConfigurationFormGroup.get('provisionDeviceKey').reset({value: null, disabled: true}, {emitEvent: false}); + } + } } diff --git a/ui-ngx/src/app/modules/home/components/profile/device-profile.component.ts b/ui-ngx/src/app/modules/home/components/profile/device-profile.component.ts index 9adcf9c168..db6539318d 100644 --- a/ui-ngx/src/app/modules/home/components/profile/device-profile.component.ts +++ b/ui-ngx/src/app/modules/home/components/profile/device-profile.component.ts @@ -104,7 +104,9 @@ export class DeviceProfileComponent extends EntityComponent { const deviceProvisionConfiguration: DeviceProvisionConfiguration = { type: entity?.provisionType ? entity.provisionType : DeviceProvisionType.DISABLED, provisionDeviceKey: entity?.provisionDeviceKey, - provisionDeviceSecret: entity?.profileData?.provisionConfiguration?.provisionDeviceSecret + provisionDeviceSecret: entity?.profileData?.provisionConfiguration?.provisionDeviceSecret, + certificateRegExPattern: entity?.profileData?.provisionConfiguration?.certificateRegExPattern, + allowCreateNewDevicesByX509Certificate: entity?.profileData?.provisionConfiguration?.allowCreateNewDevicesByX509Certificate }; const form = this.fb.group( { @@ -185,7 +187,9 @@ export class DeviceProfileComponent extends EntityComponent { const deviceProvisionConfiguration: DeviceProvisionConfiguration = { type: entity?.provisionType ? entity.provisionType : DeviceProvisionType.DISABLED, provisionDeviceKey: entity?.provisionDeviceKey, - provisionDeviceSecret: entity?.profileData?.provisionConfiguration?.provisionDeviceSecret + provisionDeviceSecret: entity?.profileData?.provisionConfiguration?.provisionDeviceSecret, + certificateRegExPattern: entity?.profileData?.provisionConfiguration?.certificateRegExPattern, + allowCreateNewDevicesByX509Certificate: entity?.profileData?.provisionConfiguration?.allowCreateNewDevicesByX509Certificate }; this.entityForm.patchValue({name: entity.name}); this.entityForm.patchValue({type: entity.type}, {emitEvent: false}); diff --git a/ui-ngx/src/app/modules/home/components/profile/device/mqtt-device-profile-transport-configuration.component.html b/ui-ngx/src/app/modules/home/components/profile/device/mqtt-device-profile-transport-configuration.component.html index 17feca514b..45cf2040b2 100644 --- a/ui-ngx/src/app/modules/home/components/profile/device/mqtt-device-profile-transport-configuration.component.html +++ b/ui-ngx/src/app/modules/home/components/profile/device/mqtt-device-profile-transport-configuration.component.html @@ -169,6 +169,6 @@ {{ 'device-profile.mqtt-send-ack-on-validation-exception' | translate }} -
+
diff --git a/ui-ngx/src/app/modules/home/components/profile/tenant/default-tenant-profile-configuration.component.html b/ui-ngx/src/app/modules/home/components/profile/tenant/default-tenant-profile-configuration.component.html index 81a1206800..0bbb6a5926 100644 --- a/ui-ngx/src/app/modules/home/components/profile/tenant/default-tenant-profile-configuration.component.html +++ b/ui-ngx/src/app/modules/home/components/profile/tenant/default-tenant-profile-configuration.component.html @@ -410,6 +410,9 @@ {{ 'tenant-profile.too-small-value-one' | translate}} + + {{ 'tenant-profile.queue-size-is-limited-by-system-configuration' | translate}} +
@@ -480,6 +483,9 @@ + + diff --git a/ui-ngx/src/app/modules/home/components/profile/tenant/default-tenant-profile-configuration.component.ts b/ui-ngx/src/app/modules/home/components/profile/tenant/default-tenant-profile-configuration.component.ts index e070d760ad..cbb5c7444e 100644 --- a/ui-ngx/src/app/modules/home/components/profile/tenant/default-tenant-profile-configuration.component.ts +++ b/ui-ngx/src/app/modules/home/components/profile/tenant/default-tenant-profile-configuration.component.ts @@ -73,6 +73,7 @@ export class DefaultTenantProfileConfigurationComponent implements ControlValueA tenantEntityExportRateLimit: [null, []], tenantEntityImportRateLimit: [null, []], tenantNotificationRequestsRateLimit: [null, []], + tenantNotificationRequestsPerRuleRateLimit: [null, []], maxTransportMessages: [null, [Validators.required, Validators.min(0)]], maxTransportDataPoints: [null, [Validators.required, Validators.min(0)]], maxREExecutions: [null, [Validators.required, Validators.min(0)]], diff --git a/ui-ngx/src/app/modules/home/components/profile/tenant/rate-limits/rate-limits.models.ts b/ui-ngx/src/app/modules/home/components/profile/tenant/rate-limits/rate-limits.models.ts index 09ae3f5139..26326e9f63 100644 --- a/ui-ngx/src/app/modules/home/components/profile/tenant/rate-limits/rate-limits.models.ts +++ b/ui-ngx/src/app/modules/home/components/profile/tenant/rate-limits/rate-limits.models.ts @@ -34,7 +34,8 @@ export enum RateLimitsType { CASSANDRA_QUERY_TENANT_RATE_LIMITS_CONFIGURATION = 'CASSANDRA_QUERY_TENANT_RATE_LIMITS_CONFIGURATION', TENANT_ENTITY_EXPORT_RATE_LIMIT = 'TENANT_ENTITY_EXPORT_RATE_LIMIT', TENANT_ENTITY_IMPORT_RATE_LIMIT = 'TENANT_ENTITY_IMPORT_RATE_LIMIT', - TENANT_NOTIFICATION_REQUEST_RATE_LIMIT = 'TENANT_NOTIFICATION_REQUEST_RATE_LIMIT' + TENANT_NOTIFICATION_REQUEST_RATE_LIMIT = 'TENANT_NOTIFICATION_REQUEST_RATE_LIMIT', + TENANT_NOTIFICATION_REQUESTS_PER_RULE_RATE_LIMIT = 'TENANT_NOTIFICATION_REQUESTS_PER_RULE_RATE_LIMIT' } export const rateLimitsLabelTranslationMap = new Map( @@ -52,6 +53,7 @@ export const rateLimitsLabelTranslationMap = new Map( [RateLimitsType.TENANT_ENTITY_EXPORT_RATE_LIMIT, 'tenant-profile.tenant-entity-export-rate-limit'], [RateLimitsType.TENANT_ENTITY_IMPORT_RATE_LIMIT, 'tenant-profile.tenant-entity-import-rate-limit'], [RateLimitsType.TENANT_NOTIFICATION_REQUEST_RATE_LIMIT, 'tenant-profile.tenant-notification-request-rate-limit'], + [RateLimitsType.TENANT_NOTIFICATION_REQUESTS_PER_RULE_RATE_LIMIT, 'tenant-profile.tenant-notification-requests-per-rule-rate-limit'], ] ); @@ -70,6 +72,7 @@ export const rateLimitsDialogTitleTranslationMap = new Map> = export const HOME_COMPONENTS_MODULE_TOKEN: InjectionToken> = new InjectionToken>('HOME_COMPONENTS_MODULE_TOKEN'); +export const WIDGET_COMPONENTS_MODULE_TOKEN: InjectionToken> = + new InjectionToken>('WIDGET_COMPONENTS_MODULE_TOKEN'); + export const COMPLEX_FILTER_PREDICATE_DIALOG_COMPONENT_TOKEN: InjectionToken> = new InjectionToken>('COMPLEX_FILTER_PREDICATE_DIALOG_COMPONENT_TOKEN'); diff --git a/ui-ngx/src/app/modules/home/components/vc/complex-version-create.component.ts b/ui-ngx/src/app/modules/home/components/vc/complex-version-create.component.ts index 50edca8ce7..6aab7d147a 100644 --- a/ui-ngx/src/app/modules/home/components/vc/complex-version-create.component.ts +++ b/ui-ngx/src/app/modules/home/components/vc/complex-version-create.component.ts @@ -120,32 +120,34 @@ export class ComplexVersionCreateComponent extends PageComponent implements OnIn this.popoverComponent.updatePosition(); } - this.versionCreateResultSubscription = this.versionCreateResult$.subscribe((result) => { - let message: string; - if (!result.error) { - if (result.done && !result.added && !result.modified && !result.removed) { - message = this.translate.instant('version-control.nothing-to-commit'); + this.versionCreateResultSubscription = this.versionCreateResult$.subscribe({ + next: (result) => { + let message: string; + if (!result.error) { + if (result.done && !result.added && !result.modified && !result.removed) { + message = this.translate.instant('version-control.nothing-to-commit'); + } else { + message = this.translate.instant('version-control.version-create-result', + {added: result.added, modified: result.modified, removed: result.removed}); + } } else { - message = this.translate.instant('version-control.version-create-result', - {added: result.added, modified: result.modified, removed: result.removed}); - } - } else { message = result.error; - } - this.resultMessage = this.sanitizer.bypassSecurityTrustHtml(message); - this.versionCreateResult = result; - this.versionCreateBranch = request.branch; - this.cd.detectChanges(); - if (this.popoverComponent) { - this.popoverComponent.updatePosition(); - } - }, - (error) => { - this.hasError = true; - this.resultMessage = this.sanitizer.bypassSecurityTrustHtml(parseHttpErrorMessage(error, this.translate).message); - this.cd.detectChanges(); - if (this.popoverComponent) { - this.popoverComponent.updatePosition(); + } + this.resultMessage = this.sanitizer.bypassSecurityTrustHtml(message); + this.versionCreateResult = result; + this.versionCreateBranch = request.branch; + this.cd.detectChanges(); + if (this.popoverComponent) { + this.popoverComponent.updatePosition(); + } + }, + error: (error) => { + this.hasError = true; + this.resultMessage = this.sanitizer.bypassSecurityTrustHtml(parseHttpErrorMessage(error, this.translate).message); + this.cd.detectChanges(); + if (this.popoverComponent) { + this.popoverComponent.updatePosition(); + } } }); } diff --git a/ui-ngx/src/app/modules/home/components/vc/entity-types-version-create.component.ts b/ui-ngx/src/app/modules/home/components/vc/entity-types-version-create.component.ts index e87d712534..b41e51c5c2 100644 --- a/ui-ngx/src/app/modules/home/components/vc/entity-types-version-create.component.ts +++ b/ui-ngx/src/app/modules/home/components/vc/entity-types-version-create.component.ts @@ -18,12 +18,12 @@ import { Component, forwardRef, Input, OnInit } from '@angular/core'; import { AbstractControl, ControlValueAccessor, + NG_VALIDATORS, + NG_VALUE_ACCESSOR, UntypedFormArray, UntypedFormBuilder, UntypedFormControl, UntypedFormGroup, - NG_VALIDATORS, - NG_VALUE_ACCESSOR, Validator, Validators } from '@angular/forms'; @@ -104,6 +104,9 @@ export class EntityTypesVersionCreateComponent extends PageComponent implements this.entityTypesVersionCreateFormGroup.disable({emitEvent: false}); } else { this.entityTypesVersionCreateFormGroup.enable({emitEvent: false}); + (this.entityTypesVersionCreateFormGroup.get('entityTypes') as UntypedFormArray).controls.forEach( + control => this.updateEntityTypeValidators(control) + ); } } @@ -147,7 +150,7 @@ export class EntityTypesVersionCreateComponent extends PageComponent implements } ); this.updateEntityTypeValidators(entityTypeControl); - entityTypeControl.get('config').get('allEntities').valueChanges.subscribe(() => { + entityTypeControl.get('config.allEntities').valueChanges.subscribe(() => { this.updateEntityTypeValidators(entityTypeControl); }); return entityTypeControl; @@ -229,7 +232,7 @@ export class EntityTypesVersionCreateComponent extends PageComponent implements allowedEntityTypes(entityTypeControl?: AbstractControl): Array { let res = [...exportableEntityTypes]; const currentEntityType: EntityType = entityTypeControl?.get('entityType')?.value; - const value: [{entityType: string, config: EntityTypeVersionCreateConfig}] = + const value: [{entityType: string; config: EntityTypeVersionCreateConfig}] = this.entityTypesVersionCreateFormGroup.get('entityTypes').value || []; const usedEntityTypes = value.map(val => val.entityType).filter(val => val); res = res.filter(entityType => !usedEntityTypes.includes(entityType) || entityType === currentEntityType); @@ -237,7 +240,7 @@ export class EntityTypesVersionCreateComponent extends PageComponent implements } private updateModel() { - const value: [{entityType: string, config: EntityTypeVersionCreateConfig}] = + const value: [{entityType: string; config: EntityTypeVersionCreateConfig}] = this.entityTypesVersionCreateFormGroup.get('entityTypes').value || []; let modelValue: {[entityType: string]: EntityTypeVersionCreateConfig} = null; if (value && value.length) { diff --git a/ui-ngx/src/app/modules/home/components/widget/dialog/custom-dialog.service.ts b/ui-ngx/src/app/modules/home/components/widget/dialog/custom-dialog.service.ts index 73b67017ff..356b31a312 100644 --- a/ui-ngx/src/app/modules/home/components/widget/dialog/custom-dialog.service.ts +++ b/ui-ngx/src/app/modules/home/components/widget/dialog/custom-dialog.service.ts @@ -16,7 +16,7 @@ import { Inject, Injectable, Type } from '@angular/core'; import { Observable } from 'rxjs'; -import { MatDialog } from '@angular/material/dialog'; +import { MatDialog, MatDialogConfig } from '@angular/material/dialog'; import { TranslateService } from '@ngx-translate/core'; import { AuthService } from '@core/auth/auth.service'; import { DynamicComponentFactoryService } from '@core/services/dynamic-component-factory.service'; @@ -44,7 +44,8 @@ export class CustomDialogService { ) { } - customDialog(template: string, controller: (instance: CustomDialogComponent) => void, data?: any): Observable { + customDialog(template: string, controller: (instance: CustomDialogComponent) => void, data?: any, + config?: MatDialogConfig): Observable { return this.dynamicComponentFactoryService.createDynamicComponentFactory( class CustomDialogComponentInstance extends CustomDialogComponent {}, template, @@ -55,13 +56,17 @@ export class CustomDialogService { customComponentFactory: factory, data }; + let dialogConfig: MatDialogConfig = { + disableClose: true, + panelClass: ['tb-dialog', 'tb-fullscreen-dialog'], + data: dialogData + }; + if (config) { + dialogConfig = {...dialogConfig, ...config}; + } return this.dialog.open( CustomDialogContainerComponent, - { - disableClose: true, - panelClass: ['tb-dialog', 'tb-fullscreen-dialog'], - data: dialogData - }).afterClosed().pipe( + dialogConfig).afterClosed().pipe( tap(() => { this.dynamicComponentFactoryService.destroyDynamicComponentFactory(factory); }) diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/alarms-table-widget.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/alarms-table-widget.component.html index f91caf9baa..9a23e86bf8 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/alarms-table-widget.component.html +++ b/ui-ngx/src/app/modules/home/components/widget/lib/alarms-table-widget.component.html @@ -91,14 +91,18 @@ {{ getUserInitials(alarm) }} + matTooltipPosition="above"> {{ getUserDisplayName(alarm) }} - + account_circle - alarm.unassigned + + alarm.unassigned + +
+
+ + +
+ diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/home-page/add-doc-link-dialog.component.scss b/ui-ngx/src/app/modules/home/components/widget/lib/home-page/add-doc-link-dialog.component.scss new file mode 100644 index 0000000000..da03a5e40b --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/lib/home-page/add-doc-link-dialog.component.scss @@ -0,0 +1,29 @@ +/** + * Copyright © 2016-2023 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 { + .tb-add-doc-link-dialog { + width: 480px; + .mat-toolbar-single-row { + padding: 0 24px; + } + h2 { + color: rgba(0, 0, 0, 0.76); + } + .mat-icon { + color: rgba(0, 0, 0, 0.54); + } + } +} diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/home-page/add-doc-link-dialog.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/home-page/add-doc-link-dialog.component.ts new file mode 100644 index 0000000000..8a7413b7dd --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/lib/home-page/add-doc-link-dialog.component.ts @@ -0,0 +1,58 @@ +/// +/// Copyright © 2016-2023 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, OnInit, SkipSelf } from '@angular/core'; +import { ErrorStateMatcher } from '@angular/material/core'; +import { MatDialogRef } from '@angular/material/dialog'; +import { Store } from '@ngrx/store'; +import { AppState } from '@core/core.state'; +import { UntypedFormBuilder, UntypedFormGroup, Validators } from '@angular/forms'; +import { DialogComponent } from '@shared/components/dialog.component'; +import { Router } from '@angular/router'; +import { DocumentationLink } from '@shared/models/user-settings.models'; + +@Component({ + selector: 'tb-add-doc-link-dialog', + templateUrl: './add-doc-link-dialog.component.html', + styleUrls: ['./add-doc-link-dialog.component.scss'] +}) +export class AddDocLinkDialogComponent extends + DialogComponent implements OnInit { + + addDocLinkFormGroup: UntypedFormGroup; + + constructor(protected store: Store, + protected router: Router, + @SkipSelf() private errorStateMatcher: ErrorStateMatcher, + public dialogRef: MatDialogRef, + public fb: UntypedFormBuilder) { + super(store, router, dialogRef); + } + + ngOnInit(): void { + this.addDocLinkFormGroup = this.fb.group({ + docLink: [{ icon: 'notifications' }, [Validators.required]] + }); + } + + cancel(): void { + this.dialogRef.close(null); + } + + add(docLink: DocumentationLink): void { + this.dialogRef.close(docLink); + } +} diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/home-page/cluster-info-table.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/home-page/cluster-info-table.component.html new file mode 100644 index 0000000000..01c02757a7 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/lib/home-page/cluster-info-table.component.html @@ -0,0 +1,77 @@ + + + + + {{ 'widgets.cluster-info.service-id' | translate }} + + + {{ serviceData.serviceId }} + + + + + {{ 'widgets.cluster-info.service-type' | translate }} + + + {{ serviceData.serviceType }} + + + + + {{ 'widgets.system-info.cpu' | translate }} + + + {{ serviceData.cpuUsage }}% + + + + + {{ 'widgets.system-info.ram' | translate }} + + + {{ serviceData.memoryUsage }}% + + + + + {{ 'widgets.system-info.disk' | translate }} + + + {{ serviceData.discUsage }}% + + + + +
+widgets.cluster-info.no-data diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/home-page/cluster-info-table.component.scss b/ui-ngx/src/app/modules/home/components/widget/lib/home-page/cluster-info-table.component.scss new file mode 100644 index 0000000000..0d76956dfc --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/lib/home-page/cluster-info-table.component.scss @@ -0,0 +1,71 @@ +/** + * Copyright © 2016-2023 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 { + span.no-data-found { + font-weight: 400; + font-size: 14px; + line-height: 20px; + letter-spacing: 0.2px; + color: rgba(0, 0, 0, 0.76); + } + + .tb-resource-usage { + cursor: default; + &.tb-status-warn, &.tb-status-critical { + position: relative; + overflow: visible; + &:before { + position: absolute; + top: 15px; + left: -24px; + font-family: 'Material Icons Round'; + font-size: 20px; + line-height: 1; + } + } + &.tb-status-warn { + color: #FAA405; + &:before { + content: "warning"; + color: #FAA405; + } + } + &.tb-status-critical { + color: #D12730; + &:before { + content: "warning"; + color: #D12730; + } + } + } +} + +:host ::ng-deep { + .mdc-data-table__header-cell { + font-weight: 500; + font-size: 11px; + line-height: 20px; + letter-spacing: 0.25px; + color: rgba(0, 0, 0, 0.54); + } + .mdc-data-table__cell { + font-weight: 400; + font-size: 14px; + line-height: 20px; + letter-spacing: 0.2px; + color: rgba(0, 0, 0, 0.76); + } +} diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/home-page/cluster-info-table.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/home-page/cluster-info-table.component.ts new file mode 100644 index 0000000000..7940d2c21a --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/lib/home-page/cluster-info-table.component.ts @@ -0,0 +1,237 @@ +/// +/// Copyright © 2016-2023 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, NgZone, OnInit, ViewChild } from '@angular/core'; +import { PageComponent } from '@shared/components/page.component'; +import { Store } from '@ngrx/store'; +import { AppState } from '@core/core.state'; +import { CollectionViewer, DataSource } from '@angular/cdk/collections'; +import { BehaviorSubject, Observable, of } from 'rxjs'; +import { emptyPageData, PageData } from '@shared/models/page/page-data'; +import { MAX_SAFE_PAGE_SIZE, PageLink } from '@shared/models/page/page-link'; +import { catchError, map, publishReplay, refCount, share } from 'rxjs/operators'; +import { MatSort } from '@angular/material/sort'; +import { Direction, SortOrder } from '@shared/models/page/sort-order'; +import { TelemetryWebsocketService } from '@core/ws/telemetry-websocket.service'; +import { EntityDataUpdate, TelemetrySubscriber } from '@shared/models/telemetry/telemetry.models'; +import { AliasFilterType } from '@shared/models/alias.models'; +import { EntityKeyType } from '@shared/models/query/query.models'; +import { TranslateService } from '@ngx-translate/core'; + +export interface SystemInfoData { + serviceId: string; + serviceType: string; + cpuUsage: number; + cpuCount: number; + memoryUsage: number; + totalMemory: number; + discUsage: number; + totalDiscSpace: number; +} + +@Component({ + selector: 'tb-cluster-info-table', + templateUrl: './cluster-info-table.component.html', + styleUrls: ['./cluster-info-table.component.scss'] +}) +export class ClusterInfoTableComponent extends PageComponent implements OnInit, AfterViewInit { + + @ViewChild(MatSort) sort: MatSort; + + displayedColumns = ['serviceId', 'serviceType', 'cpuUsage', 'memoryUsage', 'discUsage']; + + dataSource: SystemInfoDataSource; + + pageLink: PageLink; + + constructor(protected store: Store, + private telemetryWsService: TelemetryWebsocketService, + private translate: TranslateService, + private zone: NgZone) { + super(store); + } + + ngOnInit() { + this.dataSource = new SystemInfoDataSource(this.telemetryWsService, this.zone); + const sortOrder: SortOrder = { + property: 'serviceId', + direction: Direction.ASC + }; + this.pageLink = new PageLink(MAX_SAFE_PAGE_SIZE, 0, null, sortOrder); + this.dataSource.loadData(this.pageLink); + } + + ngAfterViewInit() { + this.sort.sortChange.subscribe(() => this.updateData()); + } + + updateData() { + this.pageLink.sortOrder.property = this.sort.active; + this.pageLink.sortOrder.direction = Direction[this.sort.direction.toUpperCase()]; + this.dataSource.loadData(this.pageLink); + } + + statusClass(value: number): string { + let status = ''; + if (value > 85) { + status = 'tb-status-critical'; + } else if (value > 75) { + status = 'tb-status-warn'; + } + return status; + } + + infoTooltip(serviceData: SystemInfoData, type: 'cpu' | 'ram' | 'disc'): string { + let tooltip = ''; + let value: number; + switch (type) { + case 'cpu': + value = serviceData.cpuUsage; + tooltip += value + ' / 100 %'; + break; + case 'ram': + value = serviceData.memoryUsage; + const memoryUsage = serviceData.memoryUsage; + const totalMemory = serviceData.totalMemory / 1073741824; + const usedMemory = (totalMemory * (memoryUsage / 100)).toFixed(0); + tooltip += usedMemory + ' / ' + totalMemory.toFixed(0) + ' Gb'; + break; + case 'disc': + value = serviceData.discUsage; + const discUsage = serviceData.discUsage; + const totalDiscSpace = serviceData.totalDiscSpace / 1073741824; + const usedDisc = (totalDiscSpace * (discUsage / 100)).toFixed(0); + tooltip += usedDisc + ' / ' + totalDiscSpace.toFixed(0) + ' Gb'; + break; + } + if (value > 85) { + switch (type) { + case 'cpu': + tooltip += '\n\n' + this.translate.instant('widgets.system-info.cpu-critical-text'); + break; + case 'ram': + tooltip += '\n\n' + this.translate.instant('widgets.system-info.ram-critical-text'); + break; + case 'disc': + tooltip += '\n\n' + this.translate.instant('widgets.system-info.disk-critical-text'); + break; + } + } else if (value > 75) { + switch (type) { + case 'cpu': + tooltip += '\n\n' + this.translate.instant('widgets.system-info.cpu-warning-text'); + break; + case 'ram': + tooltip += '\n\n' + this.translate.instant('widgets.system-info.ram-warning-text'); + break; + case 'disc': + tooltip += '\n\n' + this.translate.instant('widgets.system-info.disk-warning-text'); + break; + } + } + return tooltip; + } +} + +export class SystemInfoDataSource implements DataSource { + + private systemDataSubject = new BehaviorSubject([]); + private pageDataSubject = new BehaviorSubject>(emptyPageData()); + + public pageData$ = this.pageDataSubject.asObservable(); + + private allSystemData: Observable>; + + private telemetrySubscriber: TelemetrySubscriber; + + constructor(private telemetryWsService: TelemetryWebsocketService, + private zone: NgZone) { + } + + connect(collectionViewer: CollectionViewer): Observable> { + return this.systemDataSubject.asObservable(); + } + + disconnect(collectionViewer: CollectionViewer): void { + this.systemDataSubject.complete(); + this.pageDataSubject.complete(); + if (this.telemetrySubscriber) { + this.telemetrySubscriber.unsubscribe(); + this.telemetrySubscriber = null; + } + } + + loadData(pageLink: PageLink): void { + this.getAllSystemData().pipe( + map((data) => pageLink.filterData(data)), + catchError(() => of(emptyPageData())), + ).subscribe( + (pageData) => { + this.onSystemData(pageData.data); + this.pageDataSubject.next(pageData); + } + ); + } + + getAllSystemData(): Observable> { + if (!this.allSystemData) { + this.telemetrySubscriber = TelemetrySubscriber.createEntityFilterLatestSubscription( + this.telemetryWsService, { type: AliasFilterType.apiUsageState }, this.zone, + [ { key: 'clusterSystemData', type: EntityKeyType.TIME_SERIES } ]); + this.telemetrySubscriber.subscribe(); + this.allSystemData = this.telemetrySubscriber.entityData$.pipe( + map((update) => this.toSystemInfoData(update)), + publishReplay(1), + refCount() + ); + } + return this.allSystemData; + } + + private toSystemInfoData(entityDataUpdate: EntityDataUpdate): Array { + const entityData = entityDataUpdate.data?.data ? entityDataUpdate.data?.data : entityDataUpdate.update; + if (entityData && entityData.length) { + const latest = entityData[0].latest; + if (latest && latest[EntityKeyType.TIME_SERIES]) { + const latestTs = latest[EntityKeyType.TIME_SERIES]; + const tsValue = latestTs.clusterSystemData; + if (tsValue && tsValue.value) { + return JSON.parse(tsValue.value); + } + } + } + return []; + } + + protected onSystemData(data: SystemInfoData[]) { + this.systemDataSubject.next(data); + } + + isEmpty(): Observable { + return this.systemDataSubject.pipe( + map((entities) => !entities.length), + share() + ); + } + + total(): Observable { + return this.pageDataSubject.pipe( + map((pageData) => pageData.totalElements), + share() + ); + } + +} diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/home-page/configured-features.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/home-page/configured-features.component.html new file mode 100644 index 0000000000..9c028856be --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/lib/home-page/configured-features.component.html @@ -0,0 +1,72 @@ + + diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/home-page/configured-features.component.scss b/ui-ngx/src/app/modules/home/components/widget/lib/home-page/configured-features.component.scss new file mode 100644 index 0000000000..427aa8bf3a --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/lib/home-page/configured-features.component.scss @@ -0,0 +1,118 @@ +/** + * Copyright © 2016-2023 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 { + .tb-card-content { + width: 100%; + height: 100%; + } + + .tb-title { + margin-top: 8px; + font-style: normal; + font-weight: 500; + font-size: 14px; + line-height: 20px; + letter-spacing: 0.25px; + color: rgba(0, 0, 0, 0.54); + @media #{$mat-md-lg} { + font-size: 12px; + line-height: 16px; + } + } + + .tb-info-icon { + color: rgba(0, 0, 0, 0.12); + font-size: 16px; + width: 16px; + height: 16px; + line-height: 15px; + vertical-align: middle; + } + + .tb-features-list { + overflow: auto; + } + + .mat-grid-tile.tb-feature-tile { + overflow: visible; + } + + .tb-feature-button { + height: 50px; + display: flex; + overflow: hidden; + flex-direction: column; + justify-content: center; + padding: 12px; + background: #FFFFFF; + border: 1px solid rgba(0, 0, 0, 0.05); + box-shadow: 0 5px 16px rgba(0, 0, 0, 0.04); + border-radius: 10px; + @media #{$mat-md-lg} { + height: 22px; + padding: 0 8px; + } + &:hover { + border: 1px solid rgba(0, 0, 0, 0.12); + box-shadow: 0 4px 10px rgba(23, 33, 90, 0.08); + } + .tb-feature-text { + font-weight: 400; + font-size: 14px; + line-height: 20px; + color: rgba(0, 0, 0, 0.87); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + @media #{$mat-md-lg} { + font-size: 11px; + line-height: 16px; + } + } + .tb-feature-status { + display: inline-block; + font-family: 'Material Icons Round'; + font-size: 20px; + line-height: 20px; + color: rgba(0, 0, 0, 0.12); + font-weight: 600; + position: relative; + background: #F4F4F4; + border-radius: 4px; + width: 24px; + height: 24px; + padding: 2px; + margin-right: 8px; + vertical-align: middle; + @media #{$mat-md-lg} { + font-size: 14px; + line-height: 14px; + width: 18px; + height: 18px; + margin-right: 4px; + } + } + &.configured { + .tb-feature-status { + color: #198038; + background: #F3F6FA; + } + } + } +} diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/home-page/configured-features.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/home-page/configured-features.component.ts new file mode 100644 index 0000000000..be58092f1f --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/lib/home-page/configured-features.component.ts @@ -0,0 +1,104 @@ +/// +/// Copyright © 2016-2023 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 { ChangeDetectorRef, Component, OnDestroy, OnInit } from '@angular/core'; +import { PageComponent } from '@shared/components/page.component'; +import { Store } from '@ngrx/store'; +import { AppState } from '@core/core.state'; +import { AdminService } from '@core/http/admin.service'; +import { FeaturesInfo } from '@shared/models/settings.models'; +import { getCurrentAuthUser } from '@core/auth/auth.selectors'; +import { Authority } from '@shared/models/authority.enum'; +import { of, Subscription } from 'rxjs'; +import { BreakpointObserver, BreakpointState } from '@angular/cdk/layout'; +import { TranslateService } from '@ngx-translate/core'; +import { MediaBreakpoints } from '@shared/models/constants'; + +@Component({ + selector: 'tb-configured-features', + templateUrl: './configured-features.component.html', + styleUrls: ['./configured-features.component.scss'] +}) +export class ConfiguredFeaturesComponent extends PageComponent implements OnInit, OnDestroy { + + authUser = getCurrentAuthUser(this.store); + featuresInfo: FeaturesInfo; + rowHeight = '50px'; + gutterSize = '12px'; + bottomColspan = 2; + lastColspan = 2; + + private observeBreakpointSubscription: Subscription; + + constructor(protected store: Store, + private cd: ChangeDetectorRef, + private adminService: AdminService, + private translate: TranslateService, + private breakpointObserver: BreakpointObserver) { + super(store); + } + + ngOnInit() { + const isMdLg = this.breakpointObserver.isMatched(MediaBreakpoints['md-lg']); + const isLtMd = this.breakpointObserver.isMatched(MediaBreakpoints['lt-md']); + this.rowHeight = isMdLg ? '22px' : '50px'; + this.gutterSize = isMdLg ? '8px' : '12px'; + this.bottomColspan = isLtMd ? 3 : 2; + this.lastColspan = isLtMd ? 6 : 2; + this.observeBreakpointSubscription = this.breakpointObserver + .observe([MediaBreakpoints['md-lg'], MediaBreakpoints['lt-md']]) + .subscribe((state: BreakpointState) => { + if (state.breakpoints[MediaBreakpoints['md-lg']]) { + this.rowHeight = '22px'; + this.gutterSize = '8px'; + } else { + this.rowHeight = '50px'; + this.gutterSize = '12px'; + } + if (state.breakpoints[MediaBreakpoints['lt-md']]) { + this.bottomColspan = 3; + this.lastColspan = 6; + } else { + this.bottomColspan = 2; + this.lastColspan = 2; + } + this.cd.markForCheck(); + } + ); + (this.authUser.authority === Authority.SYS_ADMIN ? + this.adminService.getFeaturesInfo() : of(null)).subscribe( + (featuresInfo) => { + this.featuresInfo = featuresInfo; + this.cd.markForCheck(); + } + ); + } + + ngOnDestroy() { + if (this.observeBreakpointSubscription) { + this.observeBreakpointSubscription.unsubscribe(); + } + super.ngOnDestroy(); + } + + featureTooltip(configured: boolean): string { + if (configured) { + return this.translate.instant('widgets.configured-features.feature-configured'); + } else { + return this.translate.instant('widgets.configured-features.feature-not-configured'); + } + } +} diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/home-page/doc-link.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/home-page/doc-link.component.html new file mode 100644 index 0000000000..c64958a2ec --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/lib/home-page/doc-link.component.html @@ -0,0 +1,70 @@ + +
+ +
+ + +
+
+ +
+ +
+ + +
+
+
diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/home-page/doc-link.component.scss b/ui-ngx/src/app/modules/home/components/widget/lib/home-page/doc-link.component.scss new file mode 100644 index 0000000000..1d9de4fc38 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/lib/home-page/doc-link.component.scss @@ -0,0 +1,61 @@ +/** + * Copyright © 2016-2023 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 { + .tb-edit-doc-link { + padding: 16px; + border: 1px solid rgba(0, 0, 0, 0.05); + box-shadow: 0 5px 16px rgba(0, 0, 0, 0.04); + border-radius: 10px; + &.edit-mode { + border: 1px solid rgba(48, 86, 128, 0.32); + } + } + .tb-doc-link { + height: 55px; + display: flex; + flex-direction: column; + justify-content: center; + padding: 8px; + background: #FFFFFF; + border: 1px solid rgba(0, 0, 0, 0.05); + box-shadow: 0 5px 16px rgba(0, 0, 0, 0.04); + border-radius: 10px; + .tb-doc-container { + display: flex; + flex-direction: row; + align-items: center; + .tb-doc-icon-container { + height: 40px; + padding: 8px; + background: #F3F6FA; + border-radius: 6px; + margin-right: 8px; + } + .tb-doc-text { + font-weight: 400; + font-size: 14px; + line-height: 20px; + letter-spacing: 0.2px; + color: rgba(0, 0, 0, 0.87); + } + } + } + .tb-edit-buttons { + .mat-icon { + color: rgba(0, 0, 0, 0.38); + } + } +} diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/home-page/doc-link.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/home-page/doc-link.component.ts new file mode 100644 index 0000000000..488330cf0d --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/lib/home-page/doc-link.component.ts @@ -0,0 +1,190 @@ +/// +/// Copyright © 2016-2023 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, EventEmitter, forwardRef, Input, OnInit, Output, SkipSelf } from '@angular/core'; +import { + ControlValueAccessor, FormGroupDirective, + NG_VALUE_ACCESSOR, NgForm, + UntypedFormBuilder, UntypedFormControl, + UntypedFormGroup, + Validators +} from '@angular/forms'; +import { PageComponent } from '@shared/components/page.component'; +import { Store } from '@ngrx/store'; +import { AppState } from '@core/core.state'; +import { DocumentationLink } from '@shared/models/user-settings.models'; +import { ErrorStateMatcher } from '@angular/material/core'; + +@Component({ + selector: 'tb-doc-link', + templateUrl: './doc-link.component.html', + styleUrls: ['./doc-link.component.scss'], + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => DocLinkComponent), + multi: true + }, + {provide: ErrorStateMatcher, useExisting: DocLinkComponent} + ] +}) +export class DocLinkComponent extends PageComponent implements OnInit, ControlValueAccessor, ErrorStateMatcher { + + @Input() + disabled: boolean; + + @Input() + addOnly = false; + + @Input() + disableEdit = false; + + @Output() + docLinkAdded = new EventEmitter(); + + @Output() + docLinkAddCanceled = new EventEmitter(); + + @Output() + docLinkUpdated = new EventEmitter(); + + @Output() + docLinkDeleted = new EventEmitter(); + + @Output() + editModeChanged = new EventEmitter(); + + editMode = false; + addMode = false; + + docLink: DocumentationLink; + + private propagateChange = null; + + public editDocLinkFormGroup: UntypedFormGroup; + + private submitted = false; + + constructor(protected store: Store, + private fb: UntypedFormBuilder, + @SkipSelf() private errorStateMatcher: ErrorStateMatcher) { + super(store); + } + + ngOnInit(): void { + this.addMode = this.addOnly; + this.editDocLinkFormGroup = this.fb.group({ + icon: [null, [Validators.required]], + name: [null, [Validators.required]], + link: [null, [Validators.required]] + }); + } + + 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; + } + + registerOnChange(fn: any): void { + this.propagateChange = fn; + } + + registerOnTouched(fn: any): void { + } + + setDisabledState(isDisabled: boolean): void { + this.disabled = isDisabled; + if (isDisabled) { + this.editDocLinkFormGroup.disable({emitEvent: false}); + } else { + this.editDocLinkFormGroup.enable({emitEvent: false}); + } + } + + writeValue(value: DocumentationLink): void { + this.docLink = value; + this.editDocLinkFormGroup.patchValue( + value, {emitEvent: false} + ); + if (!this.editDocLinkFormGroup.valid) { + this.addMode = true; + this.editModeChanged.emit(true); + } + } + + switchToEditMode() { + if (!this.disableEdit && !this.editMode) { + this.submitted = false; + this.editDocLinkFormGroup.patchValue( + this.docLink, {emitEvent: false} + ); + this.editMode = true; + this.editModeChanged.emit(true); + } + } + + apply() { + this.submitted = true; + this.updateModel(); + if (this.editDocLinkFormGroup.valid) { + this.editMode = false; + this.editModeChanged.emit(false); + this.docLinkUpdated.next(this.editDocLinkFormGroup.value); + } + } + + cancelEdit() { + this.submitted = false; + this.editMode = false; + this.editModeChanged.emit(false); + } + + add() { + this.submitted = true; + this.updateModel(); + if (this.editDocLinkFormGroup.valid) { + if (!this.addOnly) { + this.addMode = false; + this.editModeChanged.emit(false); + } + this.docLinkAdded.next(this.editDocLinkFormGroup.value); + } + } + + cancelAdd() { + this.editModeChanged.emit(false); + this.docLinkAddCanceled.emit(); + } + + delete() { + this.docLinkDeleted.emit(); + } + + isEditing() { + return this.editMode || this.addMode; + } + + private updateModel() { + if (this.editDocLinkFormGroup.valid) { + this.docLink = this.editDocLinkFormGroup.value; + this.propagateChange(this.editDocLinkFormGroup.value); + } else { + this.propagateChange(null); + } + } + +} diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/home-page/doc-links-widget.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/home-page/doc-links-widget.component.html new file mode 100644 index 0000000000..96c5642feb --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/lib/home-page/doc-links-widget.component.html @@ -0,0 +1,50 @@ + + diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/home-page/doc-links-widget.component.scss b/ui-ngx/src/app/modules/home/components/widget/lib/home-page/doc-links-widget.component.scss new file mode 100644 index 0000000000..ddc5a40eec --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/lib/home-page/doc-links-widget.component.scss @@ -0,0 +1,159 @@ +/** + * Copyright © 2016-2023 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 { + .tb-card-content { + width: 100%; + height: 100%; + } + + .tb-card-header { + display: flex; + flex-direction: row; + justify-content: space-between; + align-items: flex-end; + } + + .tb-title-link { + font-style: normal; + font-weight: 500; + font-size: 14px; + line-height: 20px; + letter-spacing: 0.25px; + color: rgba(0, 0, 0, 0.54); + position: relative; + border-bottom: none; + @media #{$mat-md-lg} { + font-size: 12px; + line-height: 16px; + } + &:hover, &:focus { + border-bottom: none; + } + &::after { + content: 'arrow_forward'; + display: inline-block; + transform: rotate(315deg); + font-family: 'Material Icons'; + font-weight: normal; + font-style: normal; + font-size: 18px; + color: rgba(0, 0, 0, 0.12); + vertical-align: bottom; + margin-left: 6px; + } + &:hover::after { + color: inherit; + } + } + + .tb-title-icon { + color: rgba(0, 0, 0, 0.38); + width: 32px; + height: 32px; + padding: 4px; + } + + .tb-docs-list { + overflow: auto; + } + + .mat-grid-tile.tb-docs-tile { + overflow: visible; + } + + .tb-doc-button { + height: 55px; + display: flex; + overflow: hidden; + flex-direction: column; + justify-content: center; + padding: 8px; + background: #FFFFFF; + border: 1px solid rgba(0, 0, 0, 0.05); + box-shadow: 0 5px 16px rgba(0, 0, 0, 0.04); + border-radius: 10px; + @media #{$mat-md-lg} { + height: 18px; + padding: 0 8px; + } + &:hover { + border: 1px solid rgba(0, 0, 0, 0.12); + box-shadow: 0 4px 10px rgba(23, 33, 90, 0.08); + } + .tb-doc-container { + display: flex; + flex-direction: row; + align-items: center; + .tb-doc-icon-container { + height: 40px; + padding: 8px; + background: #F3F6FA; + border-radius: 6px; + margin-right: 8px; + @media #{$mat-md-lg} { + display: none; + } + } + .tb-doc-text { + font-weight: 400; + font-size: 14px; + line-height: 20px; + letter-spacing: 0.2px; + color: rgba(0, 0, 0, 0.87); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + @media #{$mat-md-lg} { + font-size: 11px; + line-height: 16px; + } + } + } + } + + .tb-add-doc-button { + height: 55px; + cursor: pointer; + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; + background: #FFFFFF; + padding: 8px; + border: 2px dashed rgba(0, 0, 0, 0.08); + border-radius: 10px; + @media #{$mat-md-lg} { + height: 18px; + padding: 0 8px; + .tb-add-icon { + height: 12px; + width: 12px; + font-size: 12px; + } + } + .tb-add-icon { + color: rgba(0, 0, 0, 0.12); + } + &:hover { + .tb-add-icon { + color: rgba(0, 0, 0, 0.38); + } + } + } +} diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/home-page/doc-links-widget.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/home-page/doc-links-widget.component.ts new file mode 100644 index 0000000000..1c7c5885b5 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/lib/home-page/doc-links-widget.component.ts @@ -0,0 +1,206 @@ +/// +/// Copyright © 2016-2023 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 { ChangeDetectorRef, Component, Input, OnDestroy, OnInit } from '@angular/core'; +import { PageComponent } from '@shared/components/page.component'; +import { Store } from '@ngrx/store'; +import { AppState } from '@core/core.state'; +import { Authority } from '@shared/models/authority.enum'; +import { map, Subscription } from 'rxjs'; +import { DocumentationLink, DocumentationLinks } from '@shared/models/user-settings.models'; +import { UserSettingsService } from '@core/http/user-settings.service'; +import { getCurrentAuthUser } from '@core/auth/auth.selectors'; +import { WidgetContext } from '@home/models/widget-component.models'; +import { + ImportDialogCsvComponent, + ImportDialogCsvData +} from '@home/components/import-export/import-dialog-csv.component'; +import { MatDialog } from '@angular/material/dialog'; +import { AddDocLinkDialogComponent } from '@home/components/widget/lib/home-page/add-doc-link-dialog.component'; +import { + EditDocLinksDialogComponent, + EditDocLinksDialogData +} from '@home/components/widget/lib/home-page/edit-doc-links-dialog.component'; +import { BreakpointObserver, BreakpointState } from '@angular/cdk/layout'; +import { MediaBreakpoints } from '@shared/models/constants'; + +const defaultDocLinksMap = new Map( + [ + [Authority.SYS_ADMIN, { + links: [ + { + icon: 'rocket', + name: 'Getting started', + link: 'https://thingsboard.io/docs/getting-started-guides/helloworld/' + }, + { + icon: 'title', + name: 'Tenant profiles', + link: 'https://thingsboard.io/docs/user-guide/tenant-profiles/' + }, + { + icon: 'insert_chart', + name: 'API', + link: 'https://thingsboard.io/docs/api/' + }, + { + icon: 'now_widgets', + name: 'Widgets Library', + link: 'https://thingsboard.io/docs/user-guide/ui/widget-library/' + } + ] + }], + [Authority.TENANT_ADMIN, { + links: [ + { + icon: 'rocket', + name: 'Getting started', + link: 'https://thingsboard.io/docs/getting-started-guides/helloworld/' + }, + { + icon: 'settings_ethernet', + name: 'Rule engine', + link: 'https://thingsboard.io/docs/user-guide/rule-engine-2-0/re-getting-started/' + }, + { + icon: 'insert_chart', + name: 'API', + link: 'https://thingsboard.io/docs/api/' + }, + { + icon: 'devices', + name: 'Device profiles', + link: 'https://thingsboard.io/docs/user-guide/device-profiles/' + } + ] + }], + [Authority.CUSTOMER_USER, { + links: [] + }] + ] +); + +interface DocLinksWidgetSettings { + columns: number; +} + +@Component({ + selector: 'tb-doc-links-widget', + templateUrl: './doc-links-widget.component.html', + styleUrls: ['./doc-links-widget.component.scss'] +}) +export class DocLinksWidgetComponent extends PageComponent implements OnInit, OnDestroy { + + @Input() + ctx: WidgetContext; + + settings: DocLinksWidgetSettings; + columns: number; + rowHeight = '55px'; + gutterSize = '12px'; + + documentationLinks: DocumentationLinks; + authUser = getCurrentAuthUser(this.store); + + private observeBreakpointSubscription: Subscription; + + constructor(protected store: Store, + private cd: ChangeDetectorRef, + private userSettingsService: UserSettingsService, + private dialog: MatDialog, + private breakpointObserver: BreakpointObserver) { + super(store); + } + + ngOnInit() { + this.settings = this.ctx.settings; + this.columns = this.settings.columns || 3; + const isMdLg = this.breakpointObserver.isMatched(MediaBreakpoints['md-lg']); + this.rowHeight = isMdLg ? '18px' : '55px'; + this.gutterSize = isMdLg ? '8px' : '12px'; + this.observeBreakpointSubscription = this.breakpointObserver + .observe(MediaBreakpoints['md-lg']) + .subscribe((state: BreakpointState) => { + if (state.matches) { + this.rowHeight = '18px'; + this.gutterSize = '8px'; + } else { + this.rowHeight = '55px'; + this.gutterSize = '12px'; + } + this.cd.markForCheck(); + } + ); + this.loadDocLinks(); + } + + ngOnDestroy() { + if (this.observeBreakpointSubscription) { + this.observeBreakpointSubscription.unsubscribe(); + } + super.ngOnDestroy(); + } + + private loadDocLinks() { + this.userSettingsService.getDocumentationLinks().pipe( + map((documentationLinks) => { + if (!documentationLinks || !documentationLinks.links) { + return defaultDocLinksMap.get(this.authUser.authority); + } else { + return documentationLinks; + } + }) + ).subscribe( + (documentationLinks) => { + this.documentationLinks = documentationLinks; + this.cd.markForCheck(); + } + ); + } + + edit() { + this.dialog.open(EditDocLinksDialogComponent, { + disableClose: true, + autoFocus: false, + data: { + docLinks: this.documentationLinks + }, + panelClass: ['tb-dialog', 'tb-fullscreen-dialog'] + }).afterClosed().subscribe( + (result) => { + if (result) { + this.loadDocLinks(); + } + }); + } + + addLink() { + this.dialog.open(AddDocLinkDialogComponent, { + disableClose: true, + autoFocus: false, + panelClass: ['tb-dialog', 'tb-fullscreen-dialog'] + }).afterClosed().subscribe( + (docLink) => { + if (docLink) { + this.documentationLinks.links.push(docLink); + this.cd.markForCheck(); + this.userSettingsService.updateDocumentationLinks(this.documentationLinks).subscribe(); + } + }); + } +} diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/home-page/edit-doc-links-dialog.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/home-page/edit-doc-links-dialog.component.html new file mode 100644 index 0000000000..13cbf35539 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/lib/home-page/edit-doc-links-dialog.component.html @@ -0,0 +1,63 @@ + + diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/home-page/edit-doc-links-dialog.component.scss b/ui-ngx/src/app/modules/home/components/widget/lib/home-page/edit-doc-links-dialog.component.scss new file mode 100644 index 0000000000..495c6f4037 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/lib/home-page/edit-doc-links-dialog.component.scss @@ -0,0 +1,65 @@ +/** + * Copyright © 2016-2023 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 { + .tb-edit-doc-links-dialog { + width: 480px; + .mat-mdc-dialog-content { + max-height: 50vh; + } + .mat-toolbar-single-row { + padding: 0 24px; + } + h2 { + color: rgba(0, 0, 0, 0.76); + } + .mat-icon { + color: rgba(0, 0, 0, 0.54); + } + .mat-mdc-dialog-content { + padding: 24px 24px 8px 24px; + } + .mdc-dialog__actions { + padding: 0 24px 24px; + } + } + .tb-drag-handle { + height: 24px; + margin-left: 12px; + .mat-icon { + color: rgba(0, 0, 0, 0.38); + } + } + .tb-add-doc-button { + height: 55px; + cursor: pointer; + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; + background: #FFFFFF; + padding: 8px; + border: 2px dashed rgba(0, 0, 0, 0.08); + border-radius: 10px; + .tb-add-icon { + color: rgba(0, 0, 0, 0.12); + } + &:hover { + .tb-add-icon { + color: rgba(0, 0, 0, 0.38); + } + } + } +} diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/home-page/edit-doc-links-dialog.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/home-page/edit-doc-links-dialog.component.ts new file mode 100644 index 0000000000..eca41713c4 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/lib/home-page/edit-doc-links-dialog.component.ts @@ -0,0 +1,114 @@ +/// +/// Copyright © 2016-2023 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, Inject, OnInit } from '@angular/core'; +import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog'; +import { Store } from '@ngrx/store'; +import { AppState } from '@core/core.state'; +import { AbstractControl, UntypedFormArray, UntypedFormBuilder, UntypedFormGroup, Validators } from '@angular/forms'; +import { DialogComponent } from '@shared/components/dialog.component'; +import { Router } from '@angular/router'; +import { DocumentationLink, DocumentationLinks } from '@shared/models/user-settings.models'; +import { CdkDragDrop } from '@angular/cdk/drag-drop'; +import { UserSettingsService } from '@core/http/user-settings.service'; + +export interface EditDocLinksDialogData { + docLinks: DocumentationLinks; +} + +@Component({ + selector: 'tb-edit-doc-links-dialog', + templateUrl: './edit-doc-links-dialog.component.html', + styleUrls: ['./edit-doc-links-dialog.component.scss'] +}) +export class EditDocLinksDialogComponent extends + DialogComponent implements OnInit { + + updated = false; + addMode = false; + editMode = false; + + docLinks = this.data.docLinks; + addingDocLink: Partial; + + editDocLinksFormGroup: UntypedFormGroup; + + constructor(protected store: Store, + protected router: Router, + @Inject(MAT_DIALOG_DATA) public data: EditDocLinksDialogData, + public dialogRef: MatDialogRef, + public fb: UntypedFormBuilder, + private userSettingsService: UserSettingsService) { + super(store, router, dialogRef); + } + + ngOnInit(): void { + const docLinksControls: Array = []; + for (const docLink of this.docLinks.links) { + docLinksControls.push(this.fb.control(docLink, [Validators.required])); + } + this.editDocLinksFormGroup = this.fb.group({ + links: this.fb.array(docLinksControls) + }); + } + + docLinksFormArray(): UntypedFormArray { + return this.editDocLinksFormGroup.get('links') as UntypedFormArray; + } + + trackByDocLink(index: number, docLinkControl: AbstractControl): any { + return docLinkControl; + } + + docLinkDrop(event: CdkDragDrop) { + const docLinksArray = this.editDocLinksFormGroup.get('links') as UntypedFormArray; + const docLink = docLinksArray.at(event.previousIndex); + docLinksArray.removeAt(event.previousIndex); + docLinksArray.insert(event.currentIndex, docLink); + this.update(); + } + + addLink() { + this.addingDocLink = { icon: 'notifications' }; + this.addMode = true; + } + + linkAdded(docLink: DocumentationLink) { + this.addMode = false; + const docLinksArray = this.editDocLinksFormGroup.get('links') as UntypedFormArray; + const docLinkControl = this.fb.control(docLink, [Validators.required]); + docLinksArray.push(docLinkControl); + this.update(); + } + + deleteLink(index: number) { + (this.editDocLinksFormGroup.get('links') as UntypedFormArray).removeAt(index); + this.update(); + } + + update() { + if (this.editDocLinksFormGroup.valid) { + const docLinks: DocumentationLinks = this.editDocLinksFormGroup.value; + this.userSettingsService.updateDocumentationLinks(docLinks).subscribe(() => { + this.updated = true; + }); + } + } + + close(): void { + this.dialogRef.close(this.updated); + } +} diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/home-page/getting-started-completed-dialog.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/home-page/getting-started-completed-dialog.component.html new file mode 100644 index 0000000000..ac57d77c3a --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/lib/home-page/getting-started-completed-dialog.component.html @@ -0,0 +1,34 @@ + +
+
+ + +
+
+
+ done +
+
+
+
widgets.getting-started.done-welcome-title
+
widgets.getting-started.done-welcome-text
+
+
diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/home-page/getting-started-completed-dialog.component.scss b/ui-ngx/src/app/modules/home/components/widget/lib/home-page/getting-started-completed-dialog.component.scss new file mode 100644 index 0000000000..e440fa89a0 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/lib/home-page/getting-started-completed-dialog.component.scss @@ -0,0 +1,62 @@ +/** + * Copyright © 2016-2023 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. + */ +.tb-getting-started-completed-dialog { + .mat-mdc-dialog-container .mdc-dialog__surface { + border-radius: 8px; + background-image: url(/assets/home/greetings_bg.svg); + background-repeat: no-repeat; + } + + .tb-getting-started-completed-dialog-container { + width: 500px; + height: 290px; + } + + .tb-done-container { + width: 70px; + height: 70px; + border-radius: 50%; + background: rgba(0, 0, 0, 0.05); + } + + .tb-done-icon { + width: 62px; + height: 62px; + line-height: 56px; + font-size: 36px; + text-align: center; + border-radius: 50%; + border: 2px solid #305680; + background: #FFFFFF; + } + + .tb-done-welcome-title { + font-weight: 600; + font-size: 20px; + line-height: 24px; + letter-spacing: 0.1px; + color: rgba(0, 0, 0, 0.87); + padding-bottom: 8px; + } + + .tb-done-welcome-text { + font-weight: 400; + font-size: 16px; + line-height: 24px; + letter-spacing: 0.15px; + color: rgba(0, 0, 0, 0.54); + } +} diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/home-page/getting-started-completed-dialog.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/home-page/getting-started-completed-dialog.component.ts new file mode 100644 index 0000000000..087b70f3d8 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/lib/home-page/getting-started-completed-dialog.component.ts @@ -0,0 +1,46 @@ +/// +/// Copyright © 2016-2023 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, ViewEncapsulation } from '@angular/core'; +import { MatDialogRef } from '@angular/material/dialog'; +import { Store } from '@ngrx/store'; +import { AppState } from '@core/core.state'; +import { UntypedFormGroup } from '@angular/forms'; +import { DialogComponent } from '@shared/components/dialog.component'; +import { Router } from '@angular/router'; + +@Component({ + selector: 'tb-getting-started-completed-dialog', + templateUrl: './getting-started-completed-dialog.component.html', + styleUrls: ['./getting-started-completed-dialog.component.scss'], + encapsulation: ViewEncapsulation.None +}) +export class GettingStartedCompletedDialogComponent extends + DialogComponent { + + addDocLinkFormGroup: UntypedFormGroup; + + constructor(protected store: Store, + protected router: Router, + public dialogRef: MatDialogRef) { + super(store, router, dialogRef); + dialogRef.addPanelClass('tb-getting-started-completed-dialog'); + } + + close(): void { + this.dialogRef.close(); + } +} diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/home-page/getting-started-widget.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/home-page/getting-started-widget.component.html new file mode 100644 index 0000000000..b2425a0231 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/lib/home-page/getting-started-widget.component.html @@ -0,0 +1,100 @@ + +
widgets.getting-started.get-started
+ diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/home-page/getting-started-widget.component.scss b/ui-ngx/src/app/modules/home/components/widget/lib/home-page/getting-started-widget.component.scss new file mode 100644 index 0000000000..193fb5e793 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/lib/home-page/getting-started-widget.component.scss @@ -0,0 +1,121 @@ +/** + * Copyright © 2016-2023 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-direction: column; + height: 100%; + + .tb-title { + font-weight: 600; + font-size: 20px; + line-height: 24px; + letter-spacing: 0.1px; + color: rgba(0, 0, 0, 0.76); + padding-bottom: 24px; + @media #{$mat-md-lg} { + font-weight: 500; + font-size: 14px; + line-height: 20px; + letter-spacing: 0.25px; + padding-bottom: 20px; + } + } + .tb-get-started-container { + flex: 1; + overflow: auto; + } +} + +:host ::ng-deep { + + .tb-get-started .mat-vertical-stepper-header { + border-radius: 12px; + padding: 12px; + height: auto; + } + + .tb-get-started .mat-vertical-stepper-header[aria-selected="true"] { + background: rgba(0, 0, 0, 0.03); + border-bottom-right-radius: 0; + border-bottom-left-radius: 0; + } + + .tb-get-started .mat-vertical-content-container { + margin-left: 0; + border-bottom-left-radius: 12px; + border-bottom-right-radius: 12px; + background: rgba(0, 0, 0, 0.03); + } + + .tb-get-started .mat-stepper-vertical-line::before { + content: none; + } + + .tb-get-started .mat-step-label { + width: 100%; + white-space: inherit; + font-weight: 400; + font-size: 14px; + line-height: 20px; + color: rgba(0, 0, 0, 0.76); + } + + .tb-get-started .mat-step-label.mat-step-label-selected { + font-weight: 500; + font-size: 16px; + line-height: 24px; + color: rgba(0, 0, 0, 0.87); + } + + .tb-get-started .mat-vertical-content p { + font-style: normal; + font-weight: 400; + font-size: 14px; + line-height: 20px; + letter-spacing: 0.2px; + color: rgba(0, 0, 0, 0.54); + } + + @media #{$mat-md-lg} { + .tb-get-started .mat-step-label { + font-size: 12px; + line-height: 16px; + } + .tb-get-started .mat-step-label.mat-step-label-selected { + font-size: 13px; + line-height: 16px; + } + + .tb-get-started .mdc-button { + font-size: 12px; + line-height: 16px; + } + + .tb-get-started .mat-vertical-content p { + font-size: 12px; + line-height: 16px; + letter-spacing: 0.25px; + } + + .tb-get-started .mat-vertical-content { + padding: 0 16px 16px 16px; + } + + } +} diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/home-page/getting-started-widget.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/home-page/getting-started-widget.component.ts new file mode 100644 index 0000000000..9c07e62978 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/lib/home-page/getting-started-widget.component.ts @@ -0,0 +1,134 @@ +/// +/// Copyright © 2016-2023 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 { ChangeDetectorRef, Component, Input, OnDestroy, OnInit, ViewChild } from '@angular/core'; +import { PageComponent } from '@shared/components/page.component'; +import { Store } from '@ngrx/store'; +import { AppState } from '@core/core.state'; +import { UserSettingsService } from '@core/http/user-settings.service'; +import { getCurrentAuthUser } from '@core/auth/auth.selectors'; +import { WidgetContext } from '@home/models/widget-component.models'; +import { MatDialog } from '@angular/material/dialog'; +import { + GettingStartedCompletedDialogComponent +} from '@home/components/widget/lib/home-page/getting-started-completed-dialog.component'; +import { GettingStarted } from '@shared/models/user-settings.models'; +import { CdkStep, StepperSelectionEvent } from '@angular/cdk/stepper'; +import { isUndefined } from '@core/utils'; +import { MatStepper } from '@angular/material/stepper'; +import { first } from 'rxjs/operators'; +import { Authority } from '@shared/models/authority.enum'; + +@Component({ + selector: 'tb-getting-started-widget', + templateUrl: './getting-started-widget.component.html', + styleUrls: ['./getting-started-widget.component.scss'] +}) +export class GettingStartedWidgetComponent extends PageComponent implements OnInit, OnDestroy { + + @ViewChild('matStepper') + matStepper: MatStepper; + + @Input() + ctx: WidgetContext; + + authority = Authority; + + authUser = getCurrentAuthUser(this.store); + gettingStarted: GettingStarted = { + lastSelectedIndex: 0, + maxSelectedIndex: 0 + }; + allCompleted = false; + + constructor(protected store: Store, + private cd: ChangeDetectorRef, + private userSettingsService: UserSettingsService, + private dialog: MatDialog) { + super(store); + } + + ngOnInit() { + this.userSettingsService.getGettingStarted().subscribe( + (gettingStarted) => { + if (gettingStarted) { + this.gettingStarted = gettingStarted; + if (isUndefined(this.gettingStarted.lastSelectedIndex)) { + this.gettingStarted.lastSelectedIndex = 0; + } + if (isUndefined(this.gettingStarted.maxSelectedIndex)) { + this.gettingStarted.maxSelectedIndex = 0; + } + if (this.gettingStarted.lastSelectedIndex > 0 && this.gettingStarted.lastSelectedIndex < this.matStepper.steps.length) { + const animationDuration = this.matStepper.animationDuration; + this.matStepper.animationDuration = '0'; + this.matStepper.selectedIndex = this.gettingStarted.lastSelectedIndex; + this.matStepper.animationDone.pipe(first()).subscribe(() => { + setTimeout(() => {this.matStepper.animationDuration = animationDuration;}, 0); + }); + } + if (this.matStepper.steps.length) { + this.updateCompletedSteps(); + this.cd.markForCheck(); + } else { + this.matStepper.steps.changes.subscribe( + () => { + this.updateCompletedSteps(); + this.cd.markForCheck(); + } + ); + } + } + } + ); + } + + ngOnDestroy() { + super.ngOnDestroy(); + } + + isSelected(step: CdkStep) { + return this.matStepper?.selected === step; + } + + gettingStartedCompleted() { + this.dialog.open(GettingStartedCompletedDialogComponent, { + disableClose: true, + autoFocus: false, + panelClass: ['tb-dialog', 'tb-fullscreen-dialog'] + }).afterClosed().subscribe(); + } + + updateSelectedIndex(event: StepperSelectionEvent) { + if (this.gettingStarted.lastSelectedIndex !== event.selectedIndex) { + this.gettingStarted.lastSelectedIndex = event.selectedIndex; + if (event.selectedIndex > this.gettingStarted.maxSelectedIndex) { + this.gettingStarted.maxSelectedIndex = event.selectedIndex; + this.updateCompletedSteps(); + } + this.userSettingsService + .updateGettingStarted(this.gettingStarted, {ignoreLoading: true}).subscribe(); + } + } + + updateCompletedSteps() { + if (this.gettingStarted.maxSelectedIndex >= this.matStepper.steps.length-1) { + this.allCompleted = true; + } + } + +} diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/home-page/home-page-widgets.module.ts b/ui-ngx/src/app/modules/home/components/widget/lib/home-page/home-page-widgets.module.ts new file mode 100644 index 0000000000..3be30dad30 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/lib/home-page/home-page-widgets.module.ts @@ -0,0 +1,61 @@ +/// +/// Copyright © 2016-2023 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 { NgModule } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { SharedModule } from '@app/shared/shared.module'; +import { ClusterInfoTableComponent } from '@home/components/widget/lib/home-page/cluster-info-table.component'; +import { ConfiguredFeaturesComponent } from '@home/components/widget/lib/home-page/configured-features.component'; +import { VersionInfoComponent } from '@home/components/widget/lib/home-page/version-info.component'; +import { DocLinksWidgetComponent } from '@home/components/widget/lib/home-page/doc-links-widget.component'; +import { DocLinkComponent } from '@home/components/widget/lib/home-page/doc-link.component'; +import { AddDocLinkDialogComponent } from '@home/components/widget/lib/home-page/add-doc-link-dialog.component'; +import { EditDocLinksDialogComponent } from '@home/components/widget/lib/home-page/edit-doc-links-dialog.component'; +import { GettingStartedWidgetComponent } from '@home/components/widget/lib/home-page/getting-started-widget.component'; +import { + GettingStartedCompletedDialogComponent +} from '@home/components/widget/lib/home-page/getting-started-completed-dialog.component'; + +@NgModule({ + declarations: + [ + ClusterInfoTableComponent, + ConfiguredFeaturesComponent, + VersionInfoComponent, + DocLinksWidgetComponent, + DocLinkComponent, + AddDocLinkDialogComponent, + EditDocLinksDialogComponent, + GettingStartedWidgetComponent, + GettingStartedCompletedDialogComponent + ], + imports: [ + CommonModule, + SharedModule + ], + exports: [ + ClusterInfoTableComponent, + ConfiguredFeaturesComponent, + VersionInfoComponent, + DocLinksWidgetComponent, + DocLinkComponent, + AddDocLinkDialogComponent, + EditDocLinksDialogComponent, + GettingStartedWidgetComponent, + GettingStartedCompletedDialogComponent + ] +}) +export class HomePageWidgetsModule { } diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/home-page/version-info.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/home-page/version-info.component.html new file mode 100644 index 0000000000..fb5dde16d2 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/lib/home-page/version-info.component.html @@ -0,0 +1,63 @@ + + + +
+
check
+
widgets.version-info.version-is-up-to-date
+
+
diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/home-page/version-info.component.scss b/ui-ngx/src/app/modules/home/components/widget/lib/home-page/version-info.component.scss new file mode 100644 index 0000000000..133a0d498e --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/lib/home-page/version-info.component.scss @@ -0,0 +1,150 @@ +/** + * Copyright © 2016-2023 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 { + .tb-card-content { + width: 100%; + height: 100%; + } + + .tb-card-header { + display: flex; + flex-direction: row; + justify-content: space-between; + align-items: center; + margin-bottom: 8px; + @media #{$mat-md-lg} { + margin-bottom: 4px; + } + } + + .tb-title { + font-style: normal; + font-weight: 500; + font-size: 14px; + line-height: 20px; + letter-spacing: 0.25px; + color: rgba(0, 0, 0, 0.54); + @media #{$mat-md-lg} { + font-size: 12px; + line-height: 16px; + } + } + + .tb-contact-us { + @media #{$mat-md-lg} { + height: 24px; + font-size: 12px; + } + } + + .tb-version-info-container { + border: 1px solid rgba(0, 0, 0, 0.05); + box-shadow: 0 5px 16px rgba(0, 0, 0, 0.04); + border-radius: 10px; + padding: 8px 16px 4px 16px; + overflow: hidden; + white-space: nowrap; + @media #{$mat-lt-md} { + padding: 16px; + } + @media #{$mat-md-lg} { + padding: 4px; + } + &.up-to-date { + box-shadow: none; + border: none; + background: #F3F6FA; + white-space: initial; + text-align: center; + } + } + + .tb-version-link { + font-weight: 400; + font-size: 14px; + line-height: 20px; + letter-spacing: 0.2px; + color: rgba(0, 0, 0, 0.76); + position: relative; + border-bottom: none; + @media #{$mat-md-lg} { + font-size: 11px; + line-height: 16px; + } + &:hover, &:focus { + border-bottom: none; + } + &::after { + content: 'arrow_forward'; + display: inline-block; + transform: rotate(315deg); + font-family: 'Material Icons'; + font-weight: normal; + font-style: normal; + font-size: 18px; + color: rgba(0, 0, 0, 0.12); + vertical-align: bottom; + margin-left: 6px; + } + &:hover::after { + color: inherit; + } + } + + .tb-version { + font-weight: 500; + font-size: 18px; + line-height: 24px; + letter-spacing: 0.15px; + color: rgba(0, 0, 0, 0.87); + @media #{$mat-md-lg} { + font-size: 12px; + line-height: 14px; + } + } + + .tb-upgrade { + @media #{$mat-md-lg} { + height: 24px; + font-size: 12px; + padding: 0; + overflow: hidden; + } + } + + .check-icon { + color: #198038; + font-weight: 600; + @media #{$mat-md-lg} { + font-size: 16px; + } + } + + .up-to-date-text { + font-weight: 400; + font-size: 14px; + line-height: 20px; + letter-spacing: 0.2px; + color: rgba(0, 0, 0, 0.76); + @media #{$mat-md-lg} { + font-size: 11px; + line-height: 16px; + } + } +} diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/home-page/version-info.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/home-page/version-info.component.ts new file mode 100644 index 0000000000..27acef15f4 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/lib/home-page/version-info.component.ts @@ -0,0 +1,52 @@ +/// +/// Copyright © 2016-2023 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 { ChangeDetectorRef, Component, OnInit } from '@angular/core'; +import { PageComponent } from '@shared/components/page.component'; +import { Store } from '@ngrx/store'; +import { AppState } from '@core/core.state'; +import { AdminService } from '@core/http/admin.service'; +import { UpdateMessage } from '@shared/models/settings.models'; +import { getCurrentAuthUser } from '@core/auth/auth.selectors'; +import { Authority } from '@shared/models/authority.enum'; +import { of } from 'rxjs'; + +@Component({ + selector: 'tb-version-info', + templateUrl: './version-info.component.html', + styleUrls: ['./version-info.component.scss'] +}) +export class VersionInfoComponent extends PageComponent implements OnInit { + + authUser = getCurrentAuthUser(this.store); + updateMessage: UpdateMessage; + + constructor(protected store: Store, + private cd: ChangeDetectorRef, + private adminService: AdminService) { + super(store); + } + + ngOnInit() { + (this.authUser.authority === Authority.SYS_ADMIN ? + this.adminService.checkUpdates() : of(null)).subscribe( + (updateMessage) => { + this.updateMessage = updateMessage; + this.cd.markForCheck(); + } + ); + } +} diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/maps/dialogs/select-entity-dialog.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/maps/dialogs/select-entity-dialog.component.html index a2169cc129..99740ac601 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/maps/dialogs/select-entity-dialog.component.html +++ b/ui-ngx/src/app/modules/home/components/widget/lib/maps/dialogs/select-entity-dialog.component.html @@ -31,7 +31,7 @@ entity.entity - {{ entity.entityName }} + {{ entity.entityParseName }} diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/maps/dialogs/select-entity-dialog.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/maps/dialogs/select-entity-dialog.component.ts index 9822f6dca9..f2b3d09b5c 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/maps/dialogs/select-entity-dialog.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/maps/dialogs/select-entity-dialog.component.ts @@ -20,7 +20,7 @@ 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 { UntypedFormBuilder, UntypedFormGroup, Validators } from '@angular/forms'; +import { FormBuilder, FormGroup, Validators } from '@angular/forms'; import { FormattedData } from '@shared/models/widget.models'; export interface SelectEntityDialogData { @@ -33,14 +33,13 @@ export interface SelectEntityDialogData { styleUrls: ['./select-entity-dialog.component.scss'] }) export class SelectEntityDialogComponent extends DialogComponent { - - selectEntityFormGroup: UntypedFormGroup; + selectEntityFormGroup: FormGroup; constructor(protected store: Store, protected router: Router, @Inject(MAT_DIALOG_DATA) public data: SelectEntityDialogData, public dialogRef: MatDialogRef, - public fb: UntypedFormBuilder) { + public fb: FormBuilder) { super(store, router, dialogRef); this.selectEntityFormGroup = this.fb.group( diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/maps/leaflet-map.ts b/ui-ngx/src/app/modules/home/components/widget/lib/maps/leaflet-map.ts index a78dd27431..7b63176203 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/maps/leaflet-map.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/maps/leaflet-map.ts @@ -36,7 +36,7 @@ import { Observable, of } from 'rxjs'; import { Polyline } from './polyline'; import { Polygon } from './polygon'; import { Circle } from './circle'; -import { createTooltip, isCutPolygon, isJSON } from '@home/components/widget/lib/maps/maps-utils'; +import { createTooltip, entitiesParseName, isCutPolygon, isJSON } from '@home/components/widget/lib/maps/maps-utils'; import { checkLngLat, createLoadingDiv } from '@home/components/widget/lib/maps/common-maps-utils'; import { WidgetContext } from '@home/models/widget-component.models'; import { @@ -182,20 +182,40 @@ export default abstract class LeafletMap { private selectEntityWithoutLocationDialog(shapes: L.PM.SUPPORTED_SHAPES): Observable { let entities; + let labelSettings; switch (shapes) { case 'Polygon': case 'Rectangle': entities = this.datasources.filter(pData => !this.isValidPolygonPosition(pData)); + labelSettings = { + showLabel: this.options.showPolygonLabel, + useLabelFunction: this.options.usePolygonLabelFunction, + parsedLabelFunction: this.options.parsedPolygonLabelFunction, + label: this.options.polygonLabel + }; break; case 'Marker': entities = this.datasources.filter(mData => !this.extractPosition(mData)); + labelSettings = { + showLabel: this.options.showLabel, + useLabelFunction: this.options.useLabelFunction, + parsedLabelFunction: this.options.parsedLabelFunction, + label: this.options.label + }; break; case 'Circle': entities = this.datasources.filter(mData => !this.isValidCircle(mData)); + labelSettings = { + showLabel: this.options.showCircleLabel, + useLabelFunction: this.options.useCircleLabelFunction, + parsedLabelFunction: this.options.parsedCircleLabelFunction, + label: this.options.circleLabel + }; break; default: return of(null); } + entities = entitiesParseName(entities, labelSettings); if (entities.length === 1) { return of(entities[0]); } @@ -219,38 +239,38 @@ export default abstract class LeafletMap { let customTranslation; switch (type) { case 'tbMarker': - tooltipText = this.translateService.instant('widgets.maps.tooltips.placeMarker', {entityName: data.entityName}); + tooltipText = this.translateService.instant('widgets.maps.tooltips.placeMarker', {entityName: data.entityParseName}); // @ts-ignore this.map.pm.Draw.tbMarker._hintMarker.setTooltipContent(tooltipText); break; case 'tbCircle': - tooltipText = this.translateService.instant('widgets.maps.tooltips.startCircle', {entityName: data.entityName}); + tooltipText = this.translateService.instant('widgets.maps.tooltips.startCircle', {entityName: data.entityParseName}); // @ts-ignore this.map.pm.Draw.tbCircle._hintMarker.setTooltipContent(tooltipText); customTranslation = { tooltips: { - finishCircle: this.translateService.instant('widgets.maps.tooltips.finishCircle', {entityName: data.entityName}) + finishCircle: this.translateService.instant('widgets.maps.tooltips.finishCircle', {entityName: data.entityParseName}) } }; break; case 'tbRectangle': - tooltipText = this.translateService.instant('widgets.maps.tooltips.firstVertex', {entityName: data.entityName}); + tooltipText = this.translateService.instant('widgets.maps.tooltips.firstVertex', {entityName: data.entityParseName}); // @ts-ignore this.map.pm.Draw.tbRectangle._hintMarker.setTooltipContent(tooltipText); customTranslation = { tooltips: { - finishRect: this.translateService.instant('widgets.maps.tooltips.finishRect', {entityName: data.entityName}) + finishRect: this.translateService.instant('widgets.maps.tooltips.finishRect', {entityName: data.entityParseName}) } }; break; case 'tbPolygon': - tooltipText = this.translateService.instant('widgets.maps.tooltips.firstVertex', {entityName: data.entityName}); + tooltipText = this.translateService.instant('widgets.maps.tooltips.firstVertex', {entityName: data.entityParseName}); // @ts-ignore this.map.pm.Draw.tbPolygon._hintMarker.setTooltipContent(tooltipText); customTranslation = { tooltips: { - continueLine: this.translateService.instant('widgets.maps.tooltips.continueLine', {entityName: data.entityName}), - finishPoly: this.translateService.instant('widgets.maps.tooltips.finishPoly', {entityName: data.entityName}) + continueLine: this.translateService.instant('widgets.maps.tooltips.continueLine', {entityName: data.entityParseName}), + finishPoly: this.translateService.instant('widgets.maps.tooltips.finishPoly', {entityName: data.entityParseName}) } }; break; diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/maps/maps-utils.ts b/ui-ngx/src/app/modules/home/components/widget/lib/maps/maps-utils.ts index 4b4de38712..4aad829eac 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/maps/maps-utils.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/maps/maps-utils.ts @@ -16,9 +16,12 @@ import L from 'leaflet'; import { + GenericFunction, ShowTooltipAction, WidgetToolipSettings } from './map-models'; -import { Datasource } from '@app/shared/models/widget.models'; +import { Datasource, FormattedData } from '@app/shared/models/widget.models'; +import { fillDataPattern, processDataPattern, safeExecute } from '@core/utils'; +import { parseWithTranslation } from '@home/components/widget/lib/maps/common-maps-utils'; export function createTooltip(target: L.Layer, settings: Partial, @@ -83,3 +86,27 @@ export function isJSON(data: string): boolean { return false; } } + +interface labelSettings { + showLabel: boolean; + useLabelFunction: boolean; + parsedLabelFunction: GenericFunction; + label: string; +} + +export function entitiesParseName(entities: FormattedData[], labelSettings: labelSettings): FormattedData[] { + const div = document.createElement('div'); + for (const entity of entities) { + if (labelSettings?.showLabel) { + const pattern = labelSettings.useLabelFunction ? safeExecute(labelSettings.parsedLabelFunction, + [entity, entities, entity.dsIndex]) : labelSettings.label; + const markerLabelText = parseWithTranslation.prepareProcessPattern(pattern, true); + const replaceInfoLabelMarker = processDataPattern(pattern, entity); + div.innerHTML = fillDataPattern(markerLabelText, replaceInfoLabelMarker, entity); + entity.entityParseName = div.textContent || div.innerText || ''; + } else { + entity.entityParseName = entity.entityName; + } + } + return entities; +} diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/markdown-widget.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/markdown-widget.component.html index 1c3df367c4..16fb6090e8 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/markdown-widget.component.html +++ b/ui-ngx/src/app/modules/home/components/widget/lib/markdown-widget.component.html @@ -15,7 +15,7 @@ limitations under the License. --> -, private utils: UtilsService, @Inject(HOME_COMPONENTS_MODULE_TOKEN) public homeComponentsModule: Type, + @Inject(WIDGET_COMPONENTS_MODULE_TOKEN) public widgetComponentsModule: Type, private cd: ChangeDetectorRef) { super(store); } diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/multiple-input-widget.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/multiple-input-widget.component.html index 256f6e7f9f..0a757fd34f 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/multiple-input-widget.component.html +++ b/ui-ngx/src/app/modules/home/components/widget/lib/multiple-input-widget.component.html @@ -85,6 +85,39 @@ {{key.label}} +
+ + {{key.label}} + + + {{key.settings.icon}} + + icon + + + + + {{ getErrorMessageText(key.settings,'required') }} + + + {{ getErrorMessageText(key.settings,'invalidJSON') | translate }} + + +
any; @@ -90,6 +95,7 @@ interface MultipleInputWidgetDataKeySettings { invalidDateErrorMessage?: string; minValueErrorMessage?: string; maxValueErrorMessage?: string; + invalidJsonErrorMessage?: string; useCustomIcon: boolean; icon: string; customIcon: string ; @@ -103,6 +109,9 @@ interface MultipleInputWidgetDataKeySettings { useSetValueFunction?: boolean; setValueFunctionBody?: string; setValueFunction?: ConvertSetValueFunction; + dialogTitle?: string; + saveButtonLabel?: string; + cancelButtonLabel?: string; } interface MultipleInputWidgetDataKey extends DataKey { @@ -161,7 +170,8 @@ export class MultipleInputWidgetComponent extends PageComponent implements OnIni private fb: UntypedFormBuilder, private attributeService: AttributeService, private translate: TranslateService, - private sanitizer: DomSanitizer) { + private sanitizer: DomSanitizer, + private dialog: MatDialog) { super(store); } @@ -425,6 +435,13 @@ export class MultipleInputWidgetComponent extends PageComponent implements OnIni case 'select': value = keyValue !== null ? keyValue.toString() : null; break; + case 'JSON': + try { + value = JSON.parse(keyValue); + } catch (e) { + value = keyValue ? keyValue : null; + } + break; default: value = keyValue; } @@ -520,6 +537,10 @@ export class MultipleInputWidgetComponent extends PageComponent implements OnIni errorMessage = keySettings.invalidDateErrorMessage; defaultMessage = 'widgets.input-widgets.invalid-date'; break; + case 'invalidJSON': + errorMessage = keySettings.invalidJsonErrorMessage; + defaultMessage = 'widgets.input-widgets.json-invalid'; + break; default: return ''; } @@ -670,8 +691,8 @@ export class MultipleInputWidgetComponent extends PageComponent implements OnIni } }); if (tasks.length) { - forkJoin(tasks).subscribe( - () => { + forkJoin(tasks).subscribe({ + next: () => { this.multipleInputFormGroup.markAsPristine(); this.ctx.detectChanges(); this.isSavingInProgress = false; @@ -680,13 +701,13 @@ export class MultipleInputWidgetComponent extends PageComponent implements OnIni 1000, 'bottom', 'left', this.toastTargetId); } }, - () => { + error: () => { this.isSavingInProgress = false; if (this.settings.showResultMessage) { this.ctx.showErrorToast(this.translate.instant('widgets.input-widgets.update-failed'), 'bottom', 'left', this.toastTargetId); } - }); + }}); } else { this.multipleInputFormGroup.markAsPristine(); this.ctx.detectChanges(); @@ -718,4 +739,31 @@ export class MultipleInputWidgetComponent extends PageComponent implements OnIni }); this.multipleInputFormGroup.markAsPristine(); } + + openEditJSONDialog($event: Event, key: MultipleInputWidgetDataKey, source: MultipleInputWidgetSource) { + if ($event) { + $event.stopPropagation(); + } + const formControl = this.multipleInputFormGroup.controls[key.formId]; + this.dialog.open(JsonObjectEditDialogComponent, { + disableClose: true, + panelClass: ['tb-dialog', 'tb-fullscreen-dialog'], + data: { + jsonValue: formControl.value, + title: key.settings.dialogTitle, + saveLabel: key.settings.saveButtonLabel, + cancelLabel: key.settings.cancelButtonLabel + } + }).afterClosed().subscribe( + (res) => { + if (!isEqual(res, formControl.value)) { + formControl.patchValue(res); + formControl.markAsDirty(); + if(!this.settings.showActionButtons) { + this.inputChanged(source, key); + } + } + } + ); + } } diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/rpc/knob.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/rpc/knob.component.ts index 17f46fda3c..36a2bca18d 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/rpc/knob.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/rpc/knob.component.ts @@ -22,12 +22,10 @@ import { Store } from '@ngrx/store'; import { AppState } from '@core/core.state'; import { isDefined, isNumber } from '@core/utils'; import { CanvasDigitalGaugeOptions } from '@home/components/widget/lib/canvas-digital-gauge'; -import * as tinycolor_ from 'tinycolor2'; +import tinycolor from 'tinycolor2'; import { ResizeObserver } from '@juggle/resize-observer'; import GenericOptions = CanvasGauges.GenericOptions; -const tinycolor = tinycolor_; - interface KnobSettings { minValue: number; maxValue: number; diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/rpc/led-indicator.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/rpc/led-indicator.component.ts index 430aafb996..8a048100d4 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/rpc/led-indicator.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/rpc/led-indicator.component.ts @@ -20,7 +20,7 @@ import { WidgetContext } from '@home/models/widget-component.models'; import { Store } from '@ngrx/store'; import { AppState } from '@core/core.state'; import { isDefined } from '@core/utils'; -import * as tinycolor_ from 'tinycolor2'; +import tinycolor from 'tinycolor2'; import { UtilsService } from '@core/services/utils.service'; import { IWidgetSubscription, SubscriptionInfo, WidgetSubscriptionOptions } from '@core/api/widget-api.models'; import { DatasourceType, widgetType } from '@shared/models/widget.models'; @@ -28,8 +28,6 @@ import { EntityType } from '@shared/models/entity-type.models'; import { ResizeObserver } from '@juggle/resize-observer'; import Timeout = NodeJS.Timeout; -const tinycolor = tinycolor_; - const checkStatusPollingInterval = 10000; type RetrieveValueMethod = 'attribute' | 'timeseries'; diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/home-page/doc-links-widget-settings.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/settings/home-page/doc-links-widget-settings.component.html new file mode 100644 index 0000000000..08d2d8d8e0 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/home-page/doc-links-widget-settings.component.html @@ -0,0 +1,23 @@ + +
+ + widgets.documentation.columns + + +
diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/home-page/doc-links-widget-settings.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/settings/home-page/doc-links-widget-settings.component.ts new file mode 100644 index 0000000000..7e0205a1e2 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/home-page/doc-links-widget-settings.component.ts @@ -0,0 +1,52 @@ +/// +/// Copyright © 2016-2023 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 } from '@angular/core'; +import { WidgetSettings, WidgetSettingsComponent } from '@shared/models/widget.models'; +import { UntypedFormBuilder, UntypedFormGroup, Validators } from '@angular/forms'; +import { Store } from '@ngrx/store'; +import { AppState } from '@core/core.state'; + +@Component({ + selector: 'tb-doc-links-widget-settings', + templateUrl: './doc-links-widget-settings.component.html', + styleUrls: ['./../widget-settings.scss'] +}) +export class DocLinksWidgetSettingsComponent extends WidgetSettingsComponent { + + docLinksWidgetSettingsForm: UntypedFormGroup; + + constructor(protected store: Store, + private fb: UntypedFormBuilder) { + super(store); + } + + protected settingsForm(): UntypedFormGroup { + return this.docLinksWidgetSettingsForm; + } + + protected defaultSettings(): WidgetSettings { + return { + columns: 3 + }; + } + + protected onSettingsSet(settings: WidgetSettings) { + this.docLinksWidgetSettingsForm = this.fb.group({ + columns: [settings.columns, [Validators.required, Validators.min(1), Validators.max(20)]] + }); + } +} diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/input/update-multiple-attributes-key-settings.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/settings/input/update-multiple-attributes-key-settings.component.html index 186583d5c1..3c62810000 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/input/update-multiple-attributes-key-settings.component.html +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/input/update-multiple-attributes-key-settings.component.html @@ -66,6 +66,9 @@ {{ 'widgets.input-widgets.datakey-value-type-select' | translate }} + + {{ 'widgets.input-widgets.datakey-value-type-json' | translate }} + @@ -179,6 +182,28 @@ widgets.input-widgets.invalid-date-error-message + + widgets.input-widgets.invalid-JSON-error-message + + + +
+ widgets.input-widgets.dialog-editor-settings + + widgets.input-widgets.title + + +
+ + widgets.input-widgets.save-button-label + + + + widgets.input-widgets.cancel-button-label + + +
widgets.input-widgets.icon-settings diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/input/update-multiple-attributes-key-settings.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/settings/input/update-multiple-attributes-key-settings.component.ts index 56ab12605d..7eff1bdc80 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/input/update-multiple-attributes-key-settings.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/input/update-multiple-attributes-key-settings.component.ts @@ -24,6 +24,7 @@ import { dataKeySelectOptionValidator } from '@home/components/widget/lib/settings/input/datakey-select-option.component'; import { CdkDragDrop } from '@angular/cdk/drag-drop'; +import { MultipleInputWidgetDataKeyValueType } from '@home/components/widget/lib/multiple-input-widget.component'; @Component({ selector: 'tb-update-multiple-attributes-key-settings', @@ -62,6 +63,11 @@ export class UpdateMultipleAttributesKeySettingsComponent extends WidgetSettings minValueErrorMessage: '', maxValueErrorMessage: '', invalidDateErrorMessage: '', + invalidJsonErrorMessage: '', + + dialogTitle: '', + saveButtonLabel: '', + cancelButtonLabel: '', useCustomIcon: false, icon: '', @@ -107,6 +113,13 @@ export class UpdateMultipleAttributesKeySettingsComponent extends WidgetSettings minValueErrorMessage: [settings.minValueErrorMessage, []], maxValueErrorMessage: [settings.maxValueErrorMessage, []], invalidDateErrorMessage: [settings.invalidDateErrorMessage, []], + invalidJsonErrorMessage: [settings.invalidJsonErrorMessage, []], + + // Dialog settings + + dialogTitle: [settings.dialogTitle, []], + saveButtonLabel: [settings.saveButtonLabel, []], + cancelButtonLabel: [settings.cancelButtonLabel, []], // Icon settings @@ -130,7 +143,8 @@ export class UpdateMultipleAttributesKeySettingsComponent extends WidgetSettings protected updateValidators(emitEvent: boolean) { const dataKeyHidden: boolean = this.updateMultipleAttributesKeySettingsForm.get('dataKeyHidden').value; - const dataKeyValueType: string = this.updateMultipleAttributesKeySettingsForm.get('dataKeyValueType').value; + const dataKeyValueType: MultipleInputWidgetDataKeyValueType = + this.updateMultipleAttributesKeySettingsForm.get('dataKeyValueType').value; const required: boolean = this.updateMultipleAttributesKeySettingsForm.get('required').value; const isEditable: string = this.updateMultipleAttributesKeySettingsForm.get('isEditable').value; const useCustomIcon: boolean = this.updateMultipleAttributesKeySettingsForm.get('useCustomIcon').value; @@ -165,6 +179,11 @@ export class UpdateMultipleAttributesKeySettingsComponent extends WidgetSettings this.updateMultipleAttributesKeySettingsForm.get('maxValueErrorMessage').enable({emitEvent: false}); } else if (dataKeyValueType === 'dateTime' || dataKeyValueType === 'date' || dataKeyValueType === 'time') { this.updateMultipleAttributesKeySettingsForm.get('invalidDateErrorMessage').enable({emitEvent: false}); + } else if (dataKeyValueType === 'JSON') { + this.updateMultipleAttributesKeySettingsForm.get('invalidJsonErrorMessage').enable({emitEvent: false}); + this.updateMultipleAttributesKeySettingsForm.get('dialogTitle').enable({emitEvent: false}); + this.updateMultipleAttributesKeySettingsForm.get('saveButtonLabel').enable({emitEvent: false}); + this.updateMultipleAttributesKeySettingsForm.get('cancelButtonLabel').enable({emitEvent: false}); } if (required) { this.updateMultipleAttributesKeySettingsForm.get('requiredErrorMessage').enable({emitEvent: false}); @@ -243,8 +262,9 @@ export class UpdateMultipleAttributesKeySettingsComponent extends WidgetSettings displayErrorMessagesSection(): boolean { const dataKeyHidden: boolean = this.updateMultipleAttributesKeySettingsForm.get('dataKeyHidden').value; const required: boolean = this.updateMultipleAttributesKeySettingsForm.get('required').value; - const dataKeyValueType: string = this.updateMultipleAttributesKeySettingsForm.get('dataKeyValueType').value; - return !dataKeyHidden && (required || (['integer', 'double', 'dateTime', 'date', 'time'].includes(dataKeyValueType))); + const dataKeyValueType: MultipleInputWidgetDataKeyValueType = + this.updateMultipleAttributesKeySettingsForm.get('dataKeyValueType').value; + return !dataKeyHidden && (required || (['integer', 'double', 'dateTime', 'date', 'time', 'JSON'].includes(dataKeyValueType))); } } diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/widget-settings.module.ts b/ui-ngx/src/app/modules/home/components/widget/lib/settings/widget-settings.module.ts index ef7668f306..73ff1433a9 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/widget-settings.module.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/widget-settings.module.ts @@ -259,6 +259,9 @@ import { import { TripAnimationPointSettingsComponent } from '@home/components/widget/lib/settings/map/trip-animation-point-settings.component'; +import { + DocLinksWidgetSettingsComponent +} from '@home/components/widget/lib/settings/home-page/doc-links-widget-settings.component'; @NgModule({ declarations: [ @@ -357,7 +360,8 @@ import { TripAnimationPointSettingsComponent, MapWidgetSettingsComponent, RouteMapWidgetSettingsComponent, - TripAnimationWidgetSettingsComponent + TripAnimationWidgetSettingsComponent, + DocLinksWidgetSettingsComponent ], imports: [ CommonModule, @@ -460,7 +464,8 @@ import { TripAnimationPointSettingsComponent, MapWidgetSettingsComponent, RouteMapWidgetSettingsComponent, - TripAnimationWidgetSettingsComponent + TripAnimationWidgetSettingsComponent, + DocLinksWidgetSettingsComponent ] }) export class WidgetSettingsModule { @@ -527,5 +532,6 @@ export const widgetSettingsComponentsMap: {[key: string]: Type -
+
diff --git a/ui-ngx/src/app/modules/home/models/dashboard-component.models.ts b/ui-ngx/src/app/modules/home/models/dashboard-component.models.ts index 88b5ddca91..2c79ef7b68 100644 --- a/ui-ngx/src/app/modules/home/models/dashboard-component.models.ts +++ b/ui-ngx/src/app/modules/home/models/dashboard-component.models.ts @@ -285,7 +285,7 @@ export class DashboardWidgets implements Iterable { return this.dashboardWidgets.find((dashboardWidget) => dashboardWidget.widgetId === widgetId); } - private updateRowsAndSort() { + updateRowsAndSort() { let maxRows = this.dashboard.gridsterOpts.maxRows; this.activeDashboardWidgets.forEach((dashboardWidget) => { const bottom = dashboardWidget.y + dashboardWidget.rows; @@ -300,6 +300,13 @@ export class DashboardWidgets implements Iterable { this.dashboardWidgets.sort((widget1, widget2) => { const row1 = widget1.widgetOrder; const row2 = widget2.widgetOrder; + if (isDefined(row1) && isUndefined(row2)) { + return -1; + } else if (isUndefined(row1) && isDefined(row2)) { + return 1; + } else if (isUndefined(row1) && isUndefined(row2)) { + return 0; + } let res = row1 - row2; if (res === 0) { res = widget1.x - widget2.x; @@ -627,10 +634,12 @@ export class DashboardWidget implements GridsterItem, IDashboardWidget { order = this.widgetLayout.mobileOrder; } else if (isDefined(this.widget.config.mobileOrder) && this.widget.config.mobileOrder >= 0) { order = this.widget.config.mobileOrder; - } else if (this.widgetLayout) { - order = this.widgetLayout.row; - } else { - order = this.widget.row; + } else if (!this.dashboard.isMobileSize) { + if (this.widgetLayout) { + order = this.widgetLayout.row; + } else { + order = this.widget.row; + } } return order; } diff --git a/ui-ngx/src/app/modules/home/models/entity/entity-table-component.models.ts b/ui-ngx/src/app/modules/home/models/entity/entity-table-component.models.ts index bbc8d76951..033747f6d3 100644 --- a/ui-ngx/src/app/modules/home/models/entity/entity-table-component.models.ts +++ b/ui-ngx/src/app/modules/home/models/entity/entity-table-component.models.ts @@ -33,6 +33,8 @@ import { } from '@home/models/entity/entities-table-config.models'; import { ActivatedRoute } from '@angular/router'; +export type EntitiesTableAction = 'add'; + export interface IEntitiesTableComponent { entitiesTableConfig: EntityTableConfig>; translations: EntityTypeTranslation; diff --git a/ui-ngx/src/app/modules/home/pages/edge/edge.component.html b/ui-ngx/src/app/modules/home/pages/edge/edge.component.html index 15759508c2..ef0c452a7b 100644 --- a/ui-ngx/src/app/modules/home/pages/edge/edge.component.html +++ b/ui-ngx/src/app/modules/home/pages/edge/edge.component.html @@ -40,49 +40,48 @@ [fxShow]="!isEdit && (edgeScope === 'customer' || edgeScope === 'tenant') && isAssignedToCustomer(entity)"> {{ (entity?.customerIsPublic ? 'edge.make-private' : 'edge.unassign-from-customer') | translate }} + + + + + -
- - - - - -
-
+