thingsboard-aplcache

Changes

msa/pom.xml 3(+2 -1)

msa/tb/README.md 30(+21 -9)

Details

diff --git a/application/src/main/java/org/thingsboard/server/controller/EntityViewController.java b/application/src/main/java/org/thingsboard/server/controller/EntityViewController.java
index 9b7ffd2..2a9c073 100644
--- a/application/src/main/java/org/thingsboard/server/controller/EntityViewController.java
+++ b/application/src/main/java/org/thingsboard/server/controller/EntityViewController.java
@@ -29,7 +29,6 @@ import org.springframework.web.bind.annotation.RequestParam;
 import org.springframework.web.bind.annotation.ResponseBody;
 import org.springframework.web.bind.annotation.ResponseStatus;
 import org.springframework.web.bind.annotation.RestController;
-import org.thingsboard.rule.engine.api.msg.DeviceAttributesEventNotificationMsg;
 import org.thingsboard.server.common.data.Customer;
 import org.thingsboard.server.common.data.DataConstants;
 import org.thingsboard.server.common.data.EntitySubtype;
@@ -39,7 +38,6 @@ import org.thingsboard.server.common.data.audit.ActionType;
 import org.thingsboard.server.common.data.entityview.EntityViewSearchQuery;
 import org.thingsboard.server.common.data.exception.ThingsboardException;
 import org.thingsboard.server.common.data.id.CustomerId;
-import org.thingsboard.server.common.data.id.DeviceId;
 import org.thingsboard.server.common.data.id.EntityId;
 import org.thingsboard.server.common.data.id.EntityViewId;
 import org.thingsboard.server.common.data.id.TenantId;
@@ -47,7 +45,6 @@ import org.thingsboard.server.common.data.id.UUIDBased;
 import org.thingsboard.server.common.data.kv.AttributeKvEntry;
 import org.thingsboard.server.common.data.page.TextPageData;
 import org.thingsboard.server.common.data.page.TextPageLink;
-import org.thingsboard.server.common.msg.cluster.SendToClusterMsg;
 import org.thingsboard.server.dao.exception.IncorrectParameterException;
 import org.thingsboard.server.dao.model.ModelConstants;
 import org.thingsboard.server.service.security.model.SecurityUser;
@@ -174,7 +171,7 @@ public class EntityViewController extends BaseController {
             EntityView entityView = checkEntityViewId(entityViewId);
             entityViewService.deleteEntityView(entityViewId);
             logEntityAction(entityViewId, entityView, entityView.getCustomerId(),
-                    ActionType.DELETED,null, strEntityViewId);
+                    ActionType.DELETED, null, strEntityViewId);
         } catch (Exception e) {
             logEntityAction(emptyId(EntityType.ENTITY_VIEW),
                     null,
@@ -185,10 +182,23 @@ public class EntityViewController extends BaseController {
     }
 
     @PreAuthorize("hasAuthority('TENANT_ADMIN')")
+    @RequestMapping(value = "/tenant/entityViews", params = {"entityViewName"}, method = RequestMethod.GET)
+    @ResponseBody
+    public EntityView getTenantEntityView(
+            @RequestParam String entityViewName) throws ThingsboardException {
+        try {
+            TenantId tenantId = getCurrentUser().getTenantId();
+            return checkNotNull(entityViewService.findEntityViewByTenantIdAndName(tenantId, entityViewName));
+        } catch (Exception e) {
+            throw handleException(e);
+        }
+    }
+
+    @PreAuthorize("hasAuthority('TENANT_ADMIN')")
     @RequestMapping(value = "/customer/{customerId}/entityView/{entityViewId}", method = RequestMethod.POST)
     @ResponseBody
     public EntityView assignEntityViewToCustomer(@PathVariable(CUSTOMER_ID) String strCustomerId,
-                                             @PathVariable(ENTITY_VIEW_ID) String strEntityViewId) throws ThingsboardException {
+                                                 @PathVariable(ENTITY_VIEW_ID) String strEntityViewId) throws ThingsboardException {
         checkParameter(CUSTOMER_ID, strCustomerId);
         checkParameter(ENTITY_VIEW_ID, strEntityViewId);
         try {
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 6c37614..ef09a7a 100644
--- a/application/src/main/java/org/thingsboard/server/controller/TelemetryController.java
+++ b/application/src/main/java/org/thingsboard/server/controller/TelemetryController.java
@@ -49,9 +49,11 @@ import org.thingsboard.server.common.data.kv.Aggregation;
 import org.thingsboard.server.common.data.kv.AttributeKey;
 import org.thingsboard.server.common.data.kv.AttributeKvEntry;
 import org.thingsboard.server.common.data.kv.BaseAttributeKvEntry;
+import org.thingsboard.server.common.data.kv.BaseDeleteTsKvQuery;
 import org.thingsboard.server.common.data.kv.BaseReadTsKvQuery;
 import org.thingsboard.server.common.data.kv.BasicTsKvEntry;
 import org.thingsboard.server.common.data.kv.BooleanDataEntry;
+import org.thingsboard.server.common.data.kv.DeleteTsKvQuery;
 import org.thingsboard.server.common.data.kv.DoubleDataEntry;
 import org.thingsboard.server.common.data.kv.KvEntry;
 import org.thingsboard.server.common.data.kv.LongDataEntry;
@@ -60,12 +62,10 @@ import org.thingsboard.server.common.data.kv.StringDataEntry;
 import org.thingsboard.server.common.data.kv.TsKvEntry;
 import org.thingsboard.server.common.msg.cluster.SendToClusterMsg;
 import org.thingsboard.server.common.transport.adaptor.JsonConverter;
-import org.thingsboard.server.dao.attributes.AttributesService;
 import org.thingsboard.server.dao.timeseries.TimeseriesService;
 import org.thingsboard.server.service.security.AccessValidator;
 import org.thingsboard.server.service.security.model.SecurityUser;
 import org.thingsboard.server.service.telemetry.AttributeData;
-import org.thingsboard.server.service.telemetry.TelemetrySubscriptionService;
 import org.thingsboard.server.service.telemetry.TsData;
 import org.thingsboard.server.service.telemetry.exception.InvalidParametersException;
 import org.thingsboard.server.service.telemetry.exception.UncheckedApiException;
@@ -250,6 +250,60 @@ public class TelemetryController extends BaseController {
     }
 
     @PreAuthorize("hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN', 'CUSTOMER_USER')")
+    @RequestMapping(value = "/{entityType}/{entityId}/timeseries/delete", method = RequestMethod.DELETE)
+    @ResponseBody
+    public DeferredResult<ResponseEntity> deleteEntityTimeseries(@PathVariable("entityType") String entityType, @PathVariable("entityId") String entityIdStr,
+                                                                 @RequestParam(name = "keys") String keysStr,
+                                                                 @RequestParam(name = "deleteAllDataForKeys", defaultValue = "false") boolean deleteAllDataForKeys,
+                                                                 @RequestParam(name = "startTs", required = false) Long startTs,
+                                                                 @RequestParam(name = "endTs", required = false) Long endTs,
+                                                                 @RequestParam(name = "rewriteLatestIfDeleted", defaultValue = "false") boolean rewriteLatestIfDeleted) throws ThingsboardException {
+        EntityId entityId = EntityIdFactory.getByTypeAndId(entityType, entityIdStr);
+        return deleteTimeseries(entityId, keysStr, deleteAllDataForKeys, startTs, endTs, rewriteLatestIfDeleted);
+    }
+
+    private DeferredResult<ResponseEntity> deleteTimeseries(EntityId entityIdStr, String keysStr, boolean deleteAllDataForKeys,
+                                                            Long startTs, Long endTs, boolean rewriteLatestIfDeleted) throws ThingsboardException {
+        List<String> keys = toKeysList(keysStr);
+        if (keys.isEmpty()) {
+            return getImmediateDeferredResult("Empty keys: " + keysStr, HttpStatus.BAD_REQUEST);
+        }
+        SecurityUser user = getCurrentUser();
+
+        long deleteFromTs;
+        long deleteToTs;
+        if (deleteAllDataForKeys) {
+            deleteFromTs = 0L;
+            deleteToTs = System.currentTimeMillis();
+        } else {
+            deleteFromTs = startTs;
+            deleteToTs = endTs;
+        }
+
+        return accessValidator.validateEntityAndCallback(user, entityIdStr, (result, entityId) -> {
+            List<DeleteTsKvQuery> deleteTsKvQueries = new ArrayList<>();
+            for (String key : keys) {
+                deleteTsKvQueries.add(new BaseDeleteTsKvQuery(key, deleteFromTs, deleteToTs, rewriteLatestIfDeleted));
+            }
+
+            ListenableFuture<List<Void>> future = tsService.remove(entityId, deleteTsKvQueries);
+            Futures.addCallback(future, new FutureCallback<List<Void>>() {
+                @Override
+                public void onSuccess(@Nullable List<Void> tmp) {
+                    logTimeseriesDeleted(user, entityId, keys, null);
+                    result.setResult(new ResponseEntity<>(HttpStatus.OK));
+                }
+
+                @Override
+                public void onFailure(Throwable t) {
+                    logTimeseriesDeleted(user, entityId, keys, t);
+                    result.setResult(new ResponseEntity<>(HttpStatus.INTERNAL_SERVER_ERROR));
+                }
+            }, executor);
+        });
+    }
+
+    @PreAuthorize("hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN', 'CUSTOMER_USER')")
     @RequestMapping(value = "/{deviceId}/{scope}", method = RequestMethod.DELETE)
     @ResponseBody
     public DeferredResult<ResponseEntity> deleteEntityAttributes(@PathVariable("deviceId") String deviceIdStr,
@@ -506,6 +560,15 @@ public class TelemetryController extends BaseController {
         };
     }
 
+    private void logTimeseriesDeleted(SecurityUser user, EntityId entityId, List<String> keys, Throwable e) {
+        try {
+            logEntityAction(user, (UUIDBased & EntityId) entityId, null, null, ActionType.TIMESERIES_DELETED, toException(e),
+                    keys);
+        } catch (ThingsboardException te) {
+            log.warn("Failed to log timeseries delete", te);
+        }
+    }
+
     private void logAttributesDeleted(SecurityUser user, EntityId entityId, String scope, List<String> keys, Throwable e) {
         try {
             logEntityAction(user, (UUIDBased & EntityId) entityId, null, null, ActionType.ATTRIBUTES_DELETED, toException(e),
diff --git a/application/src/main/java/org/thingsboard/server/service/cluster/discovery/ZkDiscoveryService.java b/application/src/main/java/org/thingsboard/server/service/cluster/discovery/ZkDiscoveryService.java
index bb92642..997058c 100644
--- a/application/src/main/java/org/thingsboard/server/service/cluster/discovery/ZkDiscoveryService.java
+++ b/application/src/main/java/org/thingsboard/server/service/cluster/discovery/ZkDiscoveryService.java
@@ -30,6 +30,7 @@ import org.apache.curator.framework.state.ConnectionStateListener;
 import org.apache.curator.retry.RetryForever;
 import org.apache.curator.utils.CloseableUtils;
 import org.apache.zookeeper.CreateMode;
+import org.apache.zookeeper.KeeperException;
 import org.springframework.beans.factory.annotation.Autowired;
 import org.springframework.beans.factory.annotation.Value;
 import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
@@ -51,6 +52,8 @@ import java.util.List;
 import java.util.NoSuchElementException;
 import java.util.stream.Collectors;
 
+import static org.apache.curator.framework.recipes.cache.PathChildrenCacheEvent.Type.CHILD_REMOVED;
+
 /**
  * @author Andrew Shvayka
  */
@@ -128,19 +131,42 @@ public class ZkDiscoveryService implements DiscoveryService, PathChildrenCacheLi
     }
 
     @Override
-    public void publishCurrentServer() {
+    public synchronized void publishCurrentServer() {
+        ServerInstance self = this.serverInstance.getSelf();
+        if (currentServerExists()) {
+            log.info("[{}:{}] ZK node for current instance already exists, NOT created new one: {}", self.getHost(), self.getPort(), nodePath);
+        } else {
+            try {
+                log.info("[{}:{}] Creating ZK node for current instance", self.getHost(), self.getPort());
+                nodePath = client.create()
+                        .creatingParentsIfNeeded()
+                        .withMode(CreateMode.EPHEMERAL_SEQUENTIAL).forPath(zkNodesDir + "/", SerializationUtils.serialize(self.getServerAddress()));
+                log.info("[{}:{}] Created ZK node for current instance: {}", self.getHost(), self.getPort(), nodePath);
+                client.getConnectionStateListenable().addListener(checkReconnect(self));
+            } catch (Exception e) {
+                log.error("Failed to create ZK node", e);
+                throw new RuntimeException(e);
+            }
+        }
+    }
+
+    private boolean currentServerExists() {
+        if (nodePath == null) {
+            return false;
+        }
         try {
             ServerInstance self = this.serverInstance.getSelf();
-            log.info("[{}:{}] Creating ZK node for current instance", self.getHost(), self.getPort());
-            nodePath = client.create()
-                    .creatingParentsIfNeeded()
-                    .withMode(CreateMode.EPHEMERAL_SEQUENTIAL).forPath(zkNodesDir + "/", SerializationUtils.serialize(self.getServerAddress()));
-            log.info("[{}:{}] Created ZK node for current instance: {}", self.getHost(), self.getPort(), nodePath);
-            client.getConnectionStateListenable().addListener(checkReconnect(self));
+            ServerAddress registeredServerAdress = null;
+            registeredServerAdress = SerializationUtils.deserialize(client.getData().forPath(nodePath));
+            if (self.getServerAddress() != null && self.getServerAddress().equals(registeredServerAdress)) {
+                return true;
+            }
+        } catch (KeeperException.NoNodeException e) {
+            log.info("ZK node does not exist: {}", nodePath);
         } catch (Exception e) {
-            log.error("Failed to create ZK node", e);
-            throw new RuntimeException(e);
+            log.error("Couldn't check if ZK node exists", e);
         }
+        return false;
     }
 
     private ConnectionStateListener checkReconnect(ServerInstance self) {
@@ -218,6 +244,10 @@ public class ZkDiscoveryService implements DiscoveryService, PathChildrenCacheLi
             log.debug("Ignoring {} due to empty child's data", pathChildrenCacheEvent);
             return;
         } else if (nodePath != null && nodePath.equals(data.getPath())) {
+            if (pathChildrenCacheEvent.getType() == CHILD_REMOVED) {
+                log.info("ZK node for current instance is somehow deleted.");
+                publishCurrentServer();
+            }
             log.debug("Ignoring event about current server {}", pathChildrenCacheEvent);
             return;
         }
diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/audit/ActionType.java b/common/data/src/main/java/org/thingsboard/server/common/data/audit/ActionType.java
index c37d460..822387c 100644
--- a/common/data/src/main/java/org/thingsboard/server/common/data/audit/ActionType.java
+++ b/common/data/src/main/java/org/thingsboard/server/common/data/audit/ActionType.java
@@ -24,6 +24,7 @@ public enum ActionType {
     UPDATED(false), // log entity
     ATTRIBUTES_UPDATED(false), // log attributes/values
     ATTRIBUTES_DELETED(false), // log attributes
+    TIMESERIES_DELETED(false), // log timeseries
     RPC_CALL(false), // log method and params
     CREDENTIALS_UPDATED(false), // log new credentials
     ASSIGNED_TO_CUSTOMER(false), // log customer name
@@ -32,11 +33,11 @@ public enum ActionType {
     SUSPENDED(false), // log string id
     CREDENTIALS_READ(true), // log device id
     ATTRIBUTES_READ(true), // log attributes
-    RELATION_ADD_OR_UPDATE (false),
-    RELATION_DELETED (false),
-    RELATIONS_DELETED (false),
-    ALARM_ACK (false),
-    ALARM_CLEAR (false);
+    RELATION_ADD_OR_UPDATE(false),
+    RELATION_DELETED(false),
+    RELATIONS_DELETED(false),
+    ALARM_ACK(false),
+    ALARM_CLEAR(false);
 
     private final boolean isRead;
 
diff --git a/dao/src/main/java/org/thingsboard/server/dao/entityview/EntityViewService.java b/dao/src/main/java/org/thingsboard/server/dao/entityview/EntityViewService.java
index da87b44..9c82866 100644
--- a/dao/src/main/java/org/thingsboard/server/dao/entityview/EntityViewService.java
+++ b/dao/src/main/java/org/thingsboard/server/dao/entityview/EntityViewService.java
@@ -43,6 +43,8 @@ public interface EntityViewService {
 
     EntityView findEntityViewById(EntityViewId entityViewId);
 
+    EntityView findEntityViewByTenantIdAndName(TenantId tenantId, String name);
+
     TextPageData<EntityView> findEntityViewByTenantId(TenantId tenantId, TextPageLink pageLink);
 
     TextPageData<EntityView> findEntityViewByTenantIdAndType(TenantId tenantId, TextPageLink pageLink, String type);
diff --git a/dao/src/main/java/org/thingsboard/server/dao/entityview/EntityViewServiceImpl.java b/dao/src/main/java/org/thingsboard/server/dao/entityview/EntityViewServiceImpl.java
index 2d94cc2..9f8949d 100644
--- a/dao/src/main/java/org/thingsboard/server/dao/entityview/EntityViewServiceImpl.java
+++ b/dao/src/main/java/org/thingsboard/server/dao/entityview/EntityViewServiceImpl.java
@@ -29,8 +29,6 @@ import org.springframework.cache.annotation.Cacheable;
 import org.springframework.cache.annotation.Caching;
 import org.springframework.stereotype.Service;
 import org.thingsboard.server.common.data.Customer;
-import org.thingsboard.server.common.data.DataConstants;
-import org.thingsboard.server.common.data.Device;
 import org.thingsboard.server.common.data.EntitySubtype;
 import org.thingsboard.server.common.data.EntityType;
 import org.thingsboard.server.common.data.EntityView;
@@ -40,12 +38,10 @@ import org.thingsboard.server.common.data.id.CustomerId;
 import org.thingsboard.server.common.data.id.EntityId;
 import org.thingsboard.server.common.data.id.EntityViewId;
 import org.thingsboard.server.common.data.id.TenantId;
-import org.thingsboard.server.common.data.kv.AttributeKvEntry;
 import org.thingsboard.server.common.data.page.TextPageData;
 import org.thingsboard.server.common.data.page.TextPageLink;
 import org.thingsboard.server.common.data.relation.EntityRelation;
 import org.thingsboard.server.common.data.relation.EntitySearchDirection;
-import org.thingsboard.server.dao.attributes.AttributesService;
 import org.thingsboard.server.dao.customer.CustomerDao;
 import org.thingsboard.server.dao.entity.AbstractEntityService;
 import org.thingsboard.server.dao.exception.DataValidationException;
@@ -56,15 +52,13 @@ import org.thingsboard.server.dao.tenant.TenantDao;
 import javax.annotation.Nullable;
 import java.util.ArrayList;
 import java.util.Arrays;
-import java.util.Collection;
 import java.util.Collections;
 import java.util.Comparator;
 import java.util.List;
-import java.util.concurrent.ExecutionException;
+import java.util.Optional;
 import java.util.stream.Collectors;
 
 import static org.thingsboard.server.common.data.CacheConstants.ENTITY_VIEW_CACHE;
-import static org.thingsboard.server.common.data.CacheConstants.RELATIONS_CACHE;
 import static org.thingsboard.server.dao.model.ModelConstants.NULL_UUID;
 import static org.thingsboard.server.dao.service.Validator.validateId;
 import static org.thingsboard.server.dao.service.Validator.validatePageLink;
@@ -96,6 +90,7 @@ public class EntityViewServiceImpl extends AbstractEntityService implements Enti
 
     @Caching(evict = {
             @CacheEvict(cacheNames = ENTITY_VIEW_CACHE, key = "{#entityView.tenantId, #entityView.entityId}"),
+            @CacheEvict(cacheNames = ENTITY_VIEW_CACHE, key = "{#entityView.tenantId, #entityView.name}"),
             @CacheEvict(cacheNames = ENTITY_VIEW_CACHE, key = "{#entityView.id}")})
     @Override
     public EntityView saveEntityView(EntityView entityView) {
@@ -137,6 +132,15 @@ public class EntityViewServiceImpl extends AbstractEntityService implements Enti
         return entityViewDao.findById(entityViewId.getId());
     }
 
+    @Cacheable(cacheNames = ENTITY_VIEW_CACHE, key = "{#tenantId, #name}")
+    @Override
+    public EntityView findEntityViewByTenantIdAndName(TenantId tenantId, String name) {
+        log.trace("Executing findEntityViewByTenantIdAndName [{}][{}]", tenantId, name);
+        validateId(tenantId, INCORRECT_TENANT_ID + tenantId);
+        Optional<EntityView> entityViewOpt = entityViewDao.findEntityViewByTenantIdAndName(tenantId.getId(), name);
+        return entityViewOpt.orElse(null);
+    }
+
     @Override
     public TextPageData<EntityView> findEntityViewByTenantId(TenantId tenantId, TextPageLink pageLink) {
         log.trace("Executing findEntityViewsByTenantId, tenantId [{}], pageLink [{}]", tenantId, pageLink);
@@ -255,6 +259,7 @@ public class EntityViewServiceImpl extends AbstractEntityService implements Enti
         deleteEntityRelations(entityViewId);
         EntityView entityView = entityViewDao.findById(entityViewId.getId());
         cacheManager.getCache(ENTITY_VIEW_CACHE).evict(Arrays.asList(entityView.getTenantId(), entityView.getEntityId()));
+        cacheManager.getCache(ENTITY_VIEW_CACHE).evict(Arrays.asList(entityView.getTenantId(), entityView.getName()));
         entityViewDao.removeById(entityViewId.getId());
     }
 
diff --git a/dao/src/main/java/org/thingsboard/server/dao/sql/timeseries/JpaTimeseriesDao.java b/dao/src/main/java/org/thingsboard/server/dao/sql/timeseries/JpaTimeseriesDao.java
index 5bd9175..04227a3 100644
--- a/dao/src/main/java/org/thingsboard/server/dao/sql/timeseries/JpaTimeseriesDao.java
+++ b/dao/src/main/java/org/thingsboard/server/dao/sql/timeseries/JpaTimeseriesDao.java
@@ -17,6 +17,7 @@ package org.thingsboard.server.dao.sql.timeseries;
 
 import com.google.common.base.Function;
 import com.google.common.collect.Lists;
+import com.google.common.util.concurrent.FutureCallback;
 import com.google.common.util.concurrent.Futures;
 import com.google.common.util.concurrent.ListenableFuture;
 import com.google.common.util.concurrent.ListeningExecutorService;
@@ -31,6 +32,7 @@ import org.springframework.stereotype.Component;
 import org.thingsboard.server.common.data.UUIDConverter;
 import org.thingsboard.server.common.data.id.EntityId;
 import org.thingsboard.server.common.data.kv.Aggregation;
+import org.thingsboard.server.common.data.kv.BaseReadTsKvQuery;
 import org.thingsboard.server.common.data.kv.BasicTsKvEntry;
 import org.thingsboard.server.common.data.kv.DeleteTsKvQuery;
 import org.thingsboard.server.common.data.kv.ReadTsKvQuery;
@@ -41,9 +43,9 @@ import org.thingsboard.server.dao.model.sql.TsKvEntity;
 import org.thingsboard.server.dao.model.sql.TsKvLatestCompositeKey;
 import org.thingsboard.server.dao.model.sql.TsKvLatestEntity;
 import org.thingsboard.server.dao.sql.JpaAbstractDaoListeningExecutorService;
+import org.thingsboard.server.dao.timeseries.SimpleListenableFuture;
 import org.thingsboard.server.dao.timeseries.TimeseriesDao;
 import org.thingsboard.server.dao.timeseries.TsInsertExecutorType;
-import org.thingsboard.server.dao.util.SqlDao;
 import org.thingsboard.server.dao.util.SqlTsDao;
 
 import javax.annotation.Nullable;
@@ -53,6 +55,7 @@ import java.util.ArrayList;
 import java.util.List;
 import java.util.Optional;
 import java.util.concurrent.CompletableFuture;
+import java.util.concurrent.ExecutionException;
 import java.util.concurrent.Executors;
 import java.util.stream.Collectors;
 
@@ -64,6 +67,8 @@ import static org.thingsboard.server.common.data.UUIDConverter.fromTimeUUID;
 @SqlTsDao
 public class JpaTimeseriesDao extends JpaAbstractDaoListeningExecutorService implements TimeseriesDao {
 
+    private static final String DESC_ORDER = "DESC";
+
     @Value("${sql.ts_inserts_executor_type}")
     private String insertExecutorType;
 
@@ -326,14 +331,72 @@ public class JpaTimeseriesDao extends JpaAbstractDaoListeningExecutorService imp
 
     @Override
     public ListenableFuture<Void> removeLatest(EntityId entityId, DeleteTsKvQuery query) {
-        TsKvLatestEntity latestEntity = new TsKvLatestEntity();
-        latestEntity.setEntityType(entityId.getEntityType());
-        latestEntity.setEntityId(fromTimeUUID(entityId.getId()));
-        latestEntity.setKey(query.getKey());
-        return service.submit(() -> {
-            tsKvLatestRepository.delete(latestEntity);
-            return null;
+        ListenableFuture<TsKvEntry> latestFuture = findLatest(entityId, query.getKey());
+
+        ListenableFuture<Boolean> booleanFuture = Futures.transform(latestFuture, tsKvEntry -> {
+            long ts = tsKvEntry.getTs();
+            return ts > query.getStartTs() && ts <= query.getEndTs();
+        }, service);
+
+        ListenableFuture<Void> removedLatestFuture = Futures.transformAsync(booleanFuture, isRemove -> {
+            if (isRemove) {
+                TsKvLatestEntity latestEntity = new TsKvLatestEntity();
+                latestEntity.setEntityType(entityId.getEntityType());
+                latestEntity.setEntityId(fromTimeUUID(entityId.getId()));
+                latestEntity.setKey(query.getKey());
+                return service.submit(() -> {
+                    tsKvLatestRepository.delete(latestEntity);
+                    return null;
+                });
+            }
+            return Futures.immediateFuture(null);
+        }, service);
+
+        final SimpleListenableFuture<Void> resultFuture = new SimpleListenableFuture<>();
+        Futures.addCallback(removedLatestFuture, new FutureCallback<Void>() {
+            @Override
+            public void onSuccess(@Nullable Void result) {
+                if (query.getRewriteLatestIfDeleted()) {
+                    ListenableFuture<Void> savedLatestFuture = Futures.transformAsync(booleanFuture, isRemove -> {
+                        if (isRemove) {
+                            return getNewLatestEntryFuture(entityId, query);
+                        }
+                        return Futures.immediateFuture(null);
+                    }, service);
+
+                    try {
+                        resultFuture.set(savedLatestFuture.get());
+                    } catch (InterruptedException | ExecutionException e) {
+                        log.warn("Could not get latest saved value for [{}], {}", entityId, query.getKey(), e);
+                    }
+                } else {
+                    resultFuture.set(null);
+                }
+            }
+
+            @Override
+            public void onFailure(Throwable t) {
+                log.warn("[{}] Failed to process remove of the latest value", entityId, t);
+            }
         });
+        return resultFuture;
+    }
+
+    private ListenableFuture<Void> getNewLatestEntryFuture(EntityId entityId, DeleteTsKvQuery query) {
+        long startTs = 0;
+        long endTs = query.getStartTs() - 1;
+        ReadTsKvQuery findNewLatestQuery = new BaseReadTsKvQuery(query.getKey(), startTs, endTs, endTs - startTs, 1,
+                Aggregation.NONE, DESC_ORDER);
+        ListenableFuture<List<TsKvEntry>> future = findAllAsync(entityId, findNewLatestQuery);
+
+        return Futures.transformAsync(future, entryList -> {
+            if (entryList.size() == 1) {
+                return saveLatest(entityId, entryList.get(0));
+            } else {
+                log.trace("Could not find new latest value for [{}], key - {}", entityId, query.getKey());
+            }
+            return Futures.immediateFuture(null);
+        }, service);
     }
 
     @Override
diff --git a/dao/src/main/java/org/thingsboard/server/dao/sql/timeseries/TsKvRepository.java b/dao/src/main/java/org/thingsboard/server/dao/sql/timeseries/TsKvRepository.java
index 4c743e5..296d173 100644
--- a/dao/src/main/java/org/thingsboard/server/dao/sql/timeseries/TsKvRepository.java
+++ b/dao/src/main/java/org/thingsboard/server/dao/sql/timeseries/TsKvRepository.java
@@ -47,7 +47,7 @@ public interface TsKvRepository extends CrudRepository<TsKvEntity, TsKvComposite
     @Modifying
     @Query("DELETE FROM TsKvEntity tskv WHERE tskv.entityId = :entityId " +
             "AND tskv.entityType = :entityType AND tskv.key = :entityKey " +
-            "AND tskv.ts > :startTs AND tskv.ts < :endTs")
+            "AND tskv.ts > :startTs AND tskv.ts <= :endTs")
     void delete(@Param("entityId") String entityId,
                 @Param("entityType") EntityType entityType,
                 @Param("entityKey") String key,
diff --git a/dao/src/main/java/org/thingsboard/server/dao/timeseries/CassandraBaseTimeseriesDao.java b/dao/src/main/java/org/thingsboard/server/dao/timeseries/CassandraBaseTimeseriesDao.java
index 709bfd5..fdc69f9 100644
--- a/dao/src/main/java/org/thingsboard/server/dao/timeseries/CassandraBaseTimeseriesDao.java
+++ b/dao/src/main/java/org/thingsboard/server/dao/timeseries/CassandraBaseTimeseriesDao.java
@@ -48,7 +48,6 @@ import org.thingsboard.server.common.data.kv.StringDataEntry;
 import org.thingsboard.server.common.data.kv.TsKvEntry;
 import org.thingsboard.server.dao.model.ModelConstants;
 import org.thingsboard.server.dao.nosql.CassandraAbstractAsyncDao;
-import org.thingsboard.server.dao.util.NoSqlDao;
 import org.thingsboard.server.dao.util.NoSqlTsDao;
 
 import javax.annotation.Nullable;
@@ -62,6 +61,7 @@ import java.util.Arrays;
 import java.util.Collections;
 import java.util.List;
 import java.util.Optional;
+import java.util.concurrent.ExecutionException;
 import java.util.stream.Collectors;
 
 import static com.datastax.driver.core.querybuilder.QueryBuilder.eq;
@@ -434,14 +434,14 @@ public class CassandraBaseTimeseriesDao extends CassandraAbstractAsyncDao implem
     public ListenableFuture<Void> removeLatest(EntityId entityId, DeleteTsKvQuery query) {
         ListenableFuture<TsKvEntry> latestEntryFuture = findLatest(entityId, query.getKey());
 
-        ListenableFuture<Boolean> booleanFuture = Futures.transformAsync(latestEntryFuture, latestEntry -> {
+        ListenableFuture<Boolean> booleanFuture = Futures.transform(latestEntryFuture, latestEntry -> {
             long ts = latestEntry.getTs();
-            if (ts >= query.getStartTs() && ts <= query.getEndTs()) {
-                return Futures.immediateFuture(true);
+            if (ts > query.getStartTs() && ts <= query.getEndTs()) {
+                return true;
             } else {
                 log.trace("Won't be deleted latest value for [{}], key - {}", entityId, query.getKey());
             }
-            return Futures.immediateFuture(false);
+            return false;
         }, readResultsProcessingExecutor);
 
         ListenableFuture<Void> removedLatestFuture = Futures.transformAsync(booleanFuture, isRemove -> {
@@ -451,18 +451,34 @@ public class CassandraBaseTimeseriesDao extends CassandraAbstractAsyncDao implem
             return Futures.immediateFuture(null);
         }, readResultsProcessingExecutor);
 
-        if (query.getRewriteLatestIfDeleted()) {
-            ListenableFuture<Void> savedLatestFuture = Futures.transformAsync(booleanFuture, isRemove -> {
-                if (isRemove) {
-                    return getNewLatestEntryFuture(entityId, query);
+        final SimpleListenableFuture<Void> resultFuture = new SimpleListenableFuture<>();
+        Futures.addCallback(removedLatestFuture, new FutureCallback<Void>() {
+            @Override
+            public void onSuccess(@Nullable Void result) {
+                if (query.getRewriteLatestIfDeleted()) {
+                    ListenableFuture<Void> savedLatestFuture = Futures.transformAsync(booleanFuture, isRemove -> {
+                        if (isRemove) {
+                            return getNewLatestEntryFuture(entityId, query);
+                        }
+                        return Futures.immediateFuture(null);
+                    }, readResultsProcessingExecutor);
+
+                    try {
+                        resultFuture.set(savedLatestFuture.get());
+                    } catch (InterruptedException | ExecutionException e) {
+                        log.warn("Could not get latest saved value for [{}], {}", entityId, query.getKey(), e);
+                    }
+                } else {
+                    resultFuture.set(null);
                 }
-                return Futures.immediateFuture(null);
-            }, readResultsProcessingExecutor);
+            }
 
-            return Futures.transformAsync(Futures.allAsList(Arrays.asList(savedLatestFuture, removedLatestFuture)),
-                    list -> Futures.immediateFuture(null), readResultsProcessingExecutor);
-        }
-        return removedLatestFuture;
+            @Override
+            public void onFailure(Throwable t) {
+                log.warn("[{}] Failed to process remove of the latest value", entityId, t);
+            }
+        });
+        return resultFuture;
     }
 
     private ListenableFuture<Void> getNewLatestEntryFuture(EntityId entityId, DeleteTsKvQuery query) {
diff --git a/dao/src/test/java/org/thingsboard/server/dao/service/timeseries/BaseTimeseriesServiceTest.java b/dao/src/test/java/org/thingsboard/server/dao/service/timeseries/BaseTimeseriesServiceTest.java
index 88f4d84..81de40a 100644
--- a/dao/src/test/java/org/thingsboard/server/dao/service/timeseries/BaseTimeseriesServiceTest.java
+++ b/dao/src/test/java/org/thingsboard/server/dao/service/timeseries/BaseTimeseriesServiceTest.java
@@ -152,7 +152,7 @@ public abstract class BaseTimeseriesServiceTest extends AbstractServiceTest {
     }
 
     @Test
-    public void testDeleteDeviceTsData() throws Exception {
+    public void testDeleteDeviceTsDataWithoutOverwritingLatest() throws Exception {
         DeviceId deviceId = new DeviceId(UUIDs.timeBased());
 
         saveEntries(deviceId, 10000);
@@ -172,6 +172,26 @@ public abstract class BaseTimeseriesServiceTest extends AbstractServiceTest {
     }
 
     @Test
+    public void testDeleteDeviceTsDataWithOverwritingLatest() throws Exception {
+        DeviceId deviceId = new DeviceId(UUIDs.timeBased());
+
+        saveEntries(deviceId, 10000);
+        saveEntries(deviceId, 20000);
+        saveEntries(deviceId, 30000);
+        saveEntries(deviceId, 40000);
+
+        tsService.remove(deviceId, Collections.singletonList(
+                new BaseDeleteTsKvQuery(STRING_KEY, 25000, 45000, true))).get();
+
+        List<TsKvEntry> list = tsService.findAll(deviceId, Collections.singletonList(
+                new BaseReadTsKvQuery(STRING_KEY, 5000, 45000, 10000, 10, Aggregation.NONE))).get();
+        Assert.assertEquals(2, list.size());
+
+        List<TsKvEntry> latest = tsService.findLatest(deviceId, Collections.singletonList(STRING_KEY)).get();
+        Assert.assertEquals(20000, latest.get(0).getTs());
+    }
+
+    @Test
     public void testFindDeviceTsData() throws Exception {
         DeviceId deviceId = new DeviceId(UUIDs.timeBased());
         List<TsKvEntry> entries = new ArrayList<>();
diff --git a/msa/black-box-tests/pom.xml b/msa/black-box-tests/pom.xml
new file mode 100644
index 0000000..2af4d2c
--- /dev/null
+++ b/msa/black-box-tests/pom.xml
@@ -0,0 +1,104 @@
+<!--
+
+    Copyright © 2016-2018 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.
+
+-->
+<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
+         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
+    <modelVersion>4.0.0</modelVersion>
+
+    <parent>
+        <groupId>org.thingsboard</groupId>
+        <version>2.2.0-SNAPSHOT</version>
+        <artifactId>msa</artifactId>
+    </parent>
+    <groupId>org.thingsboard.msa</groupId>
+    <artifactId>black-box-tests</artifactId>
+
+    <name>ThingsBoard Black Box Tests</name>
+    <url>https://thingsboard.io</url>
+    <description>Project for ThingsBoard black box testing with using Docker</description>
+
+    <properties>
+        <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
+        <main.dir>${basedir}/../..</main.dir>
+        <blackBoxTests.skip>true</blackBoxTests.skip>
+        <testcontainers.version>1.9.1</testcontainers.version>
+        <java-websocket.version>1.3.9</java-websocket.version>
+        <httpclient.version>4.5.6</httpclient.version>
+    </properties>
+
+    <dependencies>
+        <dependency>
+            <groupId>org.testcontainers</groupId>
+            <artifactId>testcontainers</artifactId>
+            <version>${testcontainers.version}</version>
+        </dependency>
+        <dependency>
+            <groupId>org.java-websocket</groupId>
+            <artifactId>Java-WebSocket</artifactId>
+            <version>${java-websocket.version}</version>
+        </dependency>
+        <dependency>
+            <groupId>org.apache.httpcomponents</groupId>
+            <artifactId>httpclient</artifactId>
+            <version>${httpclient.version}</version>
+        </dependency>
+        <dependency>
+            <groupId>io.takari.junit</groupId>
+            <artifactId>takari-cpsuite</artifactId>
+        </dependency>
+        <dependency>
+            <groupId>ch.qos.logback</groupId>
+            <artifactId>logback-classic</artifactId>
+        </dependency>
+        <dependency>
+            <groupId>com.google.code.gson</groupId>
+            <artifactId>gson</artifactId>
+        </dependency>
+        <dependency>
+            <groupId>org.apache.commons</groupId>
+            <artifactId>commons-lang3</artifactId>
+        </dependency>
+        <dependency>
+            <groupId>com.google.guava</groupId>
+            <artifactId>guava</artifactId>
+        </dependency>
+        <dependency>
+            <groupId>org.thingsboard</groupId>
+            <artifactId>netty-mqtt</artifactId>
+        </dependency>
+        <dependency>
+            <groupId>org.thingsboard</groupId>
+            <artifactId>tools</artifactId>
+        </dependency>
+    </dependencies>
+
+    <build>
+        <plugins>
+            <plugin>
+                <groupId>org.apache.maven.plugins</groupId>
+                <artifactId>maven-surefire-plugin</artifactId>
+                <configuration>
+                    <includes>
+                        <include>**/*TestSuite.java</include>
+                    </includes>
+                    <skipTests>${blackBoxTests.skip}</skipTests>
+                </configuration>
+            </plugin>
+        </plugins>
+    </build>
+
+</project>
diff --git a/msa/black-box-tests/README.md b/msa/black-box-tests/README.md
new file mode 100644
index 0000000..c26d9c5
--- /dev/null
+++ b/msa/black-box-tests/README.md
@@ -0,0 +1,21 @@
+
+## Black box tests execution
+To run the black box tests with using Docker, the local Docker images of Thingsboard's microservices should be built. <br />
+- Build the local Docker images in the directory with the Thingsboard's main [pom.xml](./../../pom.xml):
+        
+        mvn clean install -Ddockerfile.skip=false
+- Verify that the new local images were built: 
+
+        docker image ls
+As result, in REPOSITORY column, next images should be present:
+        
+        thingsboard/tb-coap-transport
+        thingsboard/tb-http-transport
+        thingsboard/tb-mqtt-transport
+        thingsboard/tb-node
+        thingsboard/tb-web-ui
+        thingsboard/tb-js-executor
+
+- Run the black box tests in the [msa/black-box-tests](../black-box-tests) directory:
+
+        mvn clean install -DblackBoxTests.skip=false
\ No newline at end of file
diff --git a/msa/black-box-tests/src/test/java/org/thingsboard/server/msa/AbstractContainerTest.java b/msa/black-box-tests/src/test/java/org/thingsboard/server/msa/AbstractContainerTest.java
new file mode 100644
index 0000000..5ebab78
--- /dev/null
+++ b/msa/black-box-tests/src/test/java/org/thingsboard/server/msa/AbstractContainerTest.java
@@ -0,0 +1,175 @@
+/**
+ * Copyright © 2016-2018 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.msa;
+
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.google.common.collect.ImmutableMap;
+import com.google.gson.JsonArray;
+import com.google.gson.JsonObject;
+import lombok.extern.slf4j.Slf4j;
+import org.apache.commons.lang3.RandomStringUtils;
+import org.apache.http.config.Registry;
+import org.apache.http.config.RegistryBuilder;
+import org.apache.http.conn.socket.ConnectionSocketFactory;
+import org.apache.http.conn.ssl.SSLConnectionSocketFactory;
+import org.apache.http.conn.ssl.TrustStrategy;
+import org.apache.http.conn.ssl.X509HostnameVerifier;
+import org.apache.http.impl.client.CloseableHttpClient;
+import org.apache.http.impl.client.HttpClients;
+import org.apache.http.impl.conn.PoolingHttpClientConnectionManager;
+import org.apache.http.ssl.SSLContextBuilder;
+import org.apache.http.ssl.SSLContexts;
+import org.junit.*;
+import org.springframework.http.client.HttpComponentsClientHttpRequestFactory;
+import org.thingsboard.client.tools.RestClient;
+import org.thingsboard.server.common.data.Device;
+import org.thingsboard.server.common.data.EntityType;
+import org.thingsboard.server.common.data.id.DeviceId;
+import org.thingsboard.server.msa.mapper.WsTelemetryResponse;
+
+import javax.net.ssl.*;
+import java.net.URI;
+import java.security.cert.X509Certificate;
+import java.util.List;
+import java.util.Map;
+import java.util.Random;
+
+@Slf4j
+public abstract class AbstractContainerTest {
+    protected static final String HTTPS_URL = "https://localhost";
+    protected static final String WSS_URL = "wss://localhost";
+    protected static RestClient restClient;
+    protected ObjectMapper mapper = new ObjectMapper();
+
+    @BeforeClass
+    public static void before() throws Exception {
+        restClient = new RestClient(HTTPS_URL);
+        restClient.getRestTemplate().setRequestFactory(getRequestFactoryForSelfSignedCert());
+    }
+
+    protected Device createDevice(String name) {
+        return restClient.createDevice(name + RandomStringUtils.randomAlphanumeric(7), "DEFAULT");
+    }
+
+    protected WsClient subscribeToWebSocket(DeviceId deviceId, String scope, CmdsType property) throws Exception {
+        WsClient wsClient = new WsClient(new URI(WSS_URL + "/api/ws/plugins/telemetry?token=" + restClient.getToken()));
+        SSLContextBuilder builder = SSLContexts.custom();
+        builder.loadTrustMaterial(null, (TrustStrategy) (chain, authType) -> true);
+        wsClient.setSocket(builder.build().getSocketFactory().createSocket());
+        wsClient.connectBlocking();
+
+        JsonObject cmdsObject = new JsonObject();
+        cmdsObject.addProperty("entityType", EntityType.DEVICE.name());
+        cmdsObject.addProperty("entityId", deviceId.toString());
+        cmdsObject.addProperty("scope", scope);
+        cmdsObject.addProperty("cmdId", new Random().nextInt(100));
+
+        JsonArray cmd = new JsonArray();
+        cmd.add(cmdsObject);
+        JsonObject wsRequest = new JsonObject();
+        wsRequest.add(property.toString(), cmd);
+        wsClient.send(wsRequest.toString());
+        return wsClient;
+    }
+
+    protected Map<String, Long> getExpectedLatestValues(long ts) {
+        return ImmutableMap.<String, Long>builder()
+                .put("booleanKey", ts)
+                .put("stringKey", ts)
+                .put("doubleKey", ts)
+                .put("longKey", ts)
+                .build();
+    }
+
+    protected boolean verify(WsTelemetryResponse wsTelemetryResponse, String key, Long expectedTs, String expectedValue) {
+        List<Object> list = wsTelemetryResponse.getDataValuesByKey(key);
+        return expectedTs.equals(list.get(0)) && expectedValue.equals(list.get(1));
+    }
+
+    protected boolean verify(WsTelemetryResponse wsTelemetryResponse, String key, String expectedValue) {
+        List<Object> list = wsTelemetryResponse.getDataValuesByKey(key);
+        return expectedValue.equals(list.get(1));
+    }
+
+    protected JsonObject createPayload(long ts) {
+        JsonObject values = createPayload();
+        JsonObject payload = new JsonObject();
+        payload.addProperty("ts", ts);
+        payload.add("values", values);
+        return payload;
+    }
+
+    protected JsonObject createPayload() {
+        JsonObject values = new JsonObject();
+        values.addProperty("stringKey", "value1");
+        values.addProperty("booleanKey", true);
+        values.addProperty("doubleKey", 42.0);
+        values.addProperty("longKey", 73L);
+
+        return values;
+    }
+
+    protected enum CmdsType {
+        TS_SUB_CMDS("tsSubCmds"),
+        HISTORY_CMDS("historyCmds"),
+        ATTR_SUB_CMDS("attrSubCmds");
+
+        private final String text;
+
+        CmdsType(final String text) {
+            this.text = text;
+        }
+
+        @Override
+        public String toString() {
+            return text;
+        }
+    }
+
+    private static HttpComponentsClientHttpRequestFactory getRequestFactoryForSelfSignedCert() throws Exception {
+        SSLContextBuilder builder = SSLContexts.custom();
+        builder.loadTrustMaterial(null, (TrustStrategy) (chain, authType) -> true);
+        SSLContext sslContext = builder.build();
+        SSLConnectionSocketFactory sslSelfSigned = new SSLConnectionSocketFactory(sslContext, new X509HostnameVerifier() {
+            @Override
+            public void verify(String host, SSLSocket ssl) {
+            }
+
+            @Override
+            public void verify(String host, X509Certificate cert) {
+            }
+
+            @Override
+            public void verify(String host, String[] cns, String[] subjectAlts) {
+            }
+
+            @Override
+            public boolean verify(String s, SSLSession sslSession) {
+                return true;
+            }
+        });
+
+        Registry<ConnectionSocketFactory> socketFactoryRegistry = RegistryBuilder
+                .<ConnectionSocketFactory>create()
+                .register("https", sslSelfSigned)
+                .build();
+
+        PoolingHttpClientConnectionManager cm = new PoolingHttpClientConnectionManager(socketFactoryRegistry);
+        CloseableHttpClient httpClient = HttpClients.custom().setConnectionManager(cm).build();
+        return new HttpComponentsClientHttpRequestFactory(httpClient);
+    }
+
+}
diff --git a/msa/black-box-tests/src/test/java/org/thingsboard/server/msa/connectivity/HttpClientTest.java b/msa/black-box-tests/src/test/java/org/thingsboard/server/msa/connectivity/HttpClientTest.java
new file mode 100644
index 0000000..a6e89de
--- /dev/null
+++ b/msa/black-box-tests/src/test/java/org/thingsboard/server/msa/connectivity/HttpClientTest.java
@@ -0,0 +1,57 @@
+/**
+ * Copyright © 2016-2018 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.msa.connectivity;
+
+import com.google.common.collect.Sets;
+import org.junit.Assert;
+import org.junit.Test;
+import org.springframework.http.ResponseEntity;
+import org.thingsboard.server.common.data.Device;
+import org.thingsboard.server.common.data.security.DeviceCredentials;
+import org.thingsboard.server.msa.AbstractContainerTest;
+import org.thingsboard.server.msa.WsClient;
+import org.thingsboard.server.msa.mapper.WsTelemetryResponse;
+
+public class HttpClientTest extends AbstractContainerTest {
+
+    @Test
+    public void telemetryUpload() throws Exception {
+        restClient.login("tenant@thingsboard.org", "tenant");
+
+        Device device = createDevice("http_");
+        DeviceCredentials deviceCredentials = restClient.getCredentials(device.getId());
+
+        WsClient wsClient = subscribeToWebSocket(device.getId(), "LATEST_TELEMETRY", CmdsType.TS_SUB_CMDS);
+        ResponseEntity deviceTelemetryResponse = restClient.getRestTemplate()
+                .postForEntity(HTTPS_URL + "/api/v1/{credentialsId}/telemetry",
+                        mapper.readTree(createPayload().toString()),
+                        ResponseEntity.class,
+                        deviceCredentials.getCredentialsId());
+        Assert.assertTrue(deviceTelemetryResponse.getStatusCode().is2xxSuccessful());
+        WsTelemetryResponse actualLatestTelemetry = wsClient.getLastMessage();
+        wsClient.closeBlocking();
+
+        Assert.assertEquals(Sets.newHashSet("booleanKey", "stringKey", "doubleKey", "longKey"),
+                actualLatestTelemetry.getLatestValues().keySet());
+
+        Assert.assertTrue(verify(actualLatestTelemetry, "booleanKey", Boolean.TRUE.toString()));
+        Assert.assertTrue(verify(actualLatestTelemetry, "stringKey", "value1"));
+        Assert.assertTrue(verify(actualLatestTelemetry, "doubleKey", Double.toString(42.0)));
+        Assert.assertTrue(verify(actualLatestTelemetry, "longKey", Long.toString(73)));
+
+        restClient.getRestTemplate().delete(HTTPS_URL + "/api/device/" + device.getId());
+    }
+}
diff --git a/msa/black-box-tests/src/test/java/org/thingsboard/server/msa/connectivity/MqttClientTest.java b/msa/black-box-tests/src/test/java/org/thingsboard/server/msa/connectivity/MqttClientTest.java
new file mode 100644
index 0000000..d889d2c
--- /dev/null
+++ b/msa/black-box-tests/src/test/java/org/thingsboard/server/msa/connectivity/MqttClientTest.java
@@ -0,0 +1,392 @@
+/**
+ * Copyright © 2016-2018 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.msa.connectivity;
+
+import com.fasterxml.jackson.databind.JsonNode;
+import com.google.common.collect.Sets;
+import com.google.common.util.concurrent.ListenableFuture;
+import com.google.common.util.concurrent.ListeningExecutorService;
+import com.google.common.util.concurrent.MoreExecutors;
+import com.google.gson.JsonObject;
+import io.netty.buffer.ByteBuf;
+import io.netty.buffer.Unpooled;
+import io.netty.handler.codec.mqtt.MqttQoS;
+import lombok.Data;
+import lombok.extern.slf4j.Slf4j;
+import org.apache.commons.lang3.RandomStringUtils;
+import org.junit.*;
+import org.springframework.core.ParameterizedTypeReference;
+import org.springframework.http.HttpMethod;
+import org.springframework.http.ResponseEntity;
+import org.thingsboard.mqtt.MqttClient;
+import org.thingsboard.mqtt.MqttClientConfig;
+import org.thingsboard.mqtt.MqttHandler;
+import org.thingsboard.server.common.data.Device;
+import org.thingsboard.server.common.data.id.RuleChainId;
+import org.thingsboard.server.common.data.page.TextPageData;
+import org.thingsboard.server.common.data.rule.NodeConnectionInfo;
+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.security.DeviceCredentials;
+import org.thingsboard.server.msa.AbstractContainerTest;
+import org.thingsboard.server.msa.WsClient;
+import org.thingsboard.server.msa.mapper.AttributesResponse;
+import org.thingsboard.server.msa.mapper.WsTelemetryResponse;
+
+import java.io.IOException;
+import java.nio.charset.StandardCharsets;
+import java.util.*;
+import java.util.concurrent.*;
+
+@Slf4j
+public class MqttClientTest extends AbstractContainerTest {
+
+    @Test
+    public void telemetryUpload() throws Exception {
+        restClient.login("tenant@thingsboard.org", "tenant");
+        Device device = createDevice("mqtt_");
+        DeviceCredentials deviceCredentials = restClient.getCredentials(device.getId());
+
+        WsClient wsClient = subscribeToWebSocket(device.getId(), "LATEST_TELEMETRY", CmdsType.TS_SUB_CMDS);
+        MqttClient mqttClient = getMqttClient(deviceCredentials, null);
+        mqttClient.publish("v1/devices/me/telemetry", Unpooled.wrappedBuffer(createPayload().toString().getBytes()));
+        WsTelemetryResponse actualLatestTelemetry = wsClient.getLastMessage();
+        wsClient.closeBlocking();
+
+        Assert.assertEquals(4, actualLatestTelemetry.getData().size());
+        Assert.assertEquals(Sets.newHashSet("booleanKey", "stringKey", "doubleKey", "longKey"),
+                actualLatestTelemetry.getLatestValues().keySet());
+
+        Assert.assertTrue(verify(actualLatestTelemetry, "booleanKey", Boolean.TRUE.toString()));
+        Assert.assertTrue(verify(actualLatestTelemetry, "stringKey", "value1"));
+        Assert.assertTrue(verify(actualLatestTelemetry, "doubleKey", Double.toString(42.0)));
+        Assert.assertTrue(verify(actualLatestTelemetry, "longKey", Long.toString(73)));
+
+        restClient.getRestTemplate().delete(HTTPS_URL + "/api/device/" + device.getId());
+    }
+
+    @Test
+    public void telemetryUploadWithTs() throws Exception {
+        long ts = 1451649600512L;
+
+        restClient.login("tenant@thingsboard.org", "tenant");
+        Device device = createDevice("mqtt_");
+        DeviceCredentials deviceCredentials = restClient.getCredentials(device.getId());
+
+        WsClient wsClient = subscribeToWebSocket(device.getId(), "LATEST_TELEMETRY", CmdsType.TS_SUB_CMDS);
+        MqttClient mqttClient = getMqttClient(deviceCredentials, null);
+        mqttClient.publish("v1/devices/me/telemetry", Unpooled.wrappedBuffer(createPayload(ts).toString().getBytes()));
+        WsTelemetryResponse actualLatestTelemetry = wsClient.getLastMessage();
+        wsClient.closeBlocking();
+
+        Assert.assertEquals(4, actualLatestTelemetry.getData().size());
+        Assert.assertEquals(getExpectedLatestValues(ts), actualLatestTelemetry.getLatestValues());
+
+        Assert.assertTrue(verify(actualLatestTelemetry, "booleanKey", ts, Boolean.TRUE.toString()));
+        Assert.assertTrue(verify(actualLatestTelemetry, "stringKey", ts, "value1"));
+        Assert.assertTrue(verify(actualLatestTelemetry, "doubleKey", ts, Double.toString(42.0)));
+        Assert.assertTrue(verify(actualLatestTelemetry, "longKey", ts, Long.toString(73)));
+
+        restClient.getRestTemplate().delete(HTTPS_URL + "/api/device/" + device.getId());
+    }
+
+    @Test
+    public void publishAttributeUpdateToServer() throws Exception {
+        restClient.login("tenant@thingsboard.org", "tenant");
+        Device device = createDevice("mqtt_");
+        DeviceCredentials deviceCredentials = restClient.getCredentials(device.getId());
+
+        WsClient wsClient = subscribeToWebSocket(device.getId(), "CLIENT_SCOPE", CmdsType.ATTR_SUB_CMDS);
+        MqttMessageListener listener = new MqttMessageListener();
+        MqttClient mqttClient = getMqttClient(deviceCredentials, listener);
+        JsonObject clientAttributes = new JsonObject();
+        clientAttributes.addProperty("attr1", "value1");
+        clientAttributes.addProperty("attr2", true);
+        clientAttributes.addProperty("attr3", 42.0);
+        clientAttributes.addProperty("attr4", 73);
+        mqttClient.publish("v1/devices/me/attributes", Unpooled.wrappedBuffer(clientAttributes.toString().getBytes()));
+        WsTelemetryResponse actualLatestTelemetry = wsClient.getLastMessage();
+        wsClient.closeBlocking();
+
+        Assert.assertEquals(4, actualLatestTelemetry.getData().size());
+        Assert.assertEquals(Sets.newHashSet("attr1", "attr2", "attr3", "attr4"),
+                actualLatestTelemetry.getLatestValues().keySet());
+
+        Assert.assertTrue(verify(actualLatestTelemetry, "attr1", "value1"));
+        Assert.assertTrue(verify(actualLatestTelemetry, "attr2", Boolean.TRUE.toString()));
+        Assert.assertTrue(verify(actualLatestTelemetry, "attr3", Double.toString(42.0)));
+        Assert.assertTrue(verify(actualLatestTelemetry, "attr4", Long.toString(73)));
+
+        restClient.getRestTemplate().delete(HTTPS_URL + "/api/device/" + device.getId());
+    }
+
+    @Test
+    public void requestAttributeValuesFromServer() throws Exception {
+        restClient.login("tenant@thingsboard.org", "tenant");
+        Device device = createDevice("mqtt_");
+        DeviceCredentials deviceCredentials = restClient.getCredentials(device.getId());
+
+        MqttMessageListener listener = new MqttMessageListener();
+        MqttClient mqttClient = getMqttClient(deviceCredentials, listener);
+
+        // Add a new client attribute
+        JsonObject clientAttributes = new JsonObject();
+        String clientAttributeValue = RandomStringUtils.randomAlphanumeric(8);
+        clientAttributes.addProperty("clientAttr", clientAttributeValue);
+        mqttClient.publish("v1/devices/me/attributes", Unpooled.wrappedBuffer(clientAttributes.toString().getBytes()));
+
+        // Add a new shared attribute
+        JsonObject sharedAttributes = new JsonObject();
+        String sharedAttributeValue = RandomStringUtils.randomAlphanumeric(8);
+        sharedAttributes.addProperty("sharedAttr", sharedAttributeValue);
+        ResponseEntity sharedAttributesResponse = restClient.getRestTemplate()
+                .postForEntity(HTTPS_URL + "/api/plugins/telemetry/DEVICE/{deviceId}/SHARED_SCOPE",
+                        mapper.readTree(sharedAttributes.toString()), ResponseEntity.class,
+                        device.getId());
+        Assert.assertTrue(sharedAttributesResponse.getStatusCode().is2xxSuccessful());
+
+        // Subscribe to attributes response
+        mqttClient.on("v1/devices/me/attributes/response/+", listener, MqttQoS.AT_LEAST_ONCE);
+        // Request attributes
+        JsonObject request = new JsonObject();
+        request.addProperty("clientKeys", "clientAttr");
+        request.addProperty("sharedKeys", "sharedAttr");
+        mqttClient.publish("v1/devices/me/attributes/request/" + new Random().nextInt(100), Unpooled.wrappedBuffer(request.toString().getBytes()));
+        MqttEvent event = listener.getEvents().poll(10, TimeUnit.SECONDS);
+        AttributesResponse attributes = mapper.readValue(Objects.requireNonNull(event).getMessage(), AttributesResponse.class);
+
+        Assert.assertEquals(1, attributes.getClient().size());
+        Assert.assertEquals(clientAttributeValue, attributes.getClient().get("clientAttr"));
+
+        Assert.assertEquals(1, attributes.getShared().size());
+        Assert.assertEquals(sharedAttributeValue, attributes.getShared().get("sharedAttr"));
+
+        restClient.getRestTemplate().delete(HTTPS_URL + "/api/device/" + device.getId());
+    }
+
+    @Test
+    public void subscribeToAttributeUpdatesFromServer() throws Exception {
+        restClient.login("tenant@thingsboard.org", "tenant");
+        Device device = createDevice("mqtt_");
+        DeviceCredentials deviceCredentials = restClient.getCredentials(device.getId());
+
+        MqttMessageListener listener = new MqttMessageListener();
+        MqttClient mqttClient = getMqttClient(deviceCredentials, listener);
+        mqttClient.on("v1/devices/me/attributes", listener, MqttQoS.AT_LEAST_ONCE);
+
+        String sharedAttributeName = "sharedAttr";
+
+        // Add a new shared attribute
+        JsonObject sharedAttributes = new JsonObject();
+        String sharedAttributeValue = RandomStringUtils.randomAlphanumeric(8);
+        sharedAttributes.addProperty(sharedAttributeName, sharedAttributeValue);
+        ResponseEntity sharedAttributesResponse = restClient.getRestTemplate()
+                .postForEntity(HTTPS_URL + "/api/plugins/telemetry/DEVICE/{deviceId}/SHARED_SCOPE",
+                        mapper.readTree(sharedAttributes.toString()), ResponseEntity.class,
+                        device.getId());
+        Assert.assertTrue(sharedAttributesResponse.getStatusCode().is2xxSuccessful());
+
+        MqttEvent event = listener.getEvents().poll(10, TimeUnit.SECONDS);
+        Assert.assertEquals(sharedAttributeValue,
+                mapper.readValue(Objects.requireNonNull(event).getMessage(), JsonNode.class).get(sharedAttributeName).asText());
+
+        // Update the shared attribute value
+        JsonObject updatedSharedAttributes = new JsonObject();
+        String updatedSharedAttributeValue = RandomStringUtils.randomAlphanumeric(8);
+        updatedSharedAttributes.addProperty(sharedAttributeName, updatedSharedAttributeValue);
+        ResponseEntity updatedSharedAttributesResponse = restClient.getRestTemplate()
+                .postForEntity(HTTPS_URL + "/api/plugins/telemetry/DEVICE/{deviceId}/SHARED_SCOPE",
+                        mapper.readTree(updatedSharedAttributes.toString()), ResponseEntity.class,
+                        device.getId());
+        Assert.assertTrue(updatedSharedAttributesResponse.getStatusCode().is2xxSuccessful());
+
+        event = listener.getEvents().poll(10, TimeUnit.SECONDS);
+        Assert.assertEquals(updatedSharedAttributeValue,
+                mapper.readValue(Objects.requireNonNull(event).getMessage(), JsonNode.class).get(sharedAttributeName).asText());
+
+        restClient.getRestTemplate().delete(HTTPS_URL + "/api/device/" + device.getId());
+    }
+
+    @Test
+    public void serverSideRpc() throws Exception {
+        restClient.login("tenant@thingsboard.org", "tenant");
+        Device device = createDevice("mqtt_");
+        DeviceCredentials deviceCredentials = restClient.getCredentials(device.getId());
+
+        MqttMessageListener listener = new MqttMessageListener();
+        MqttClient mqttClient = getMqttClient(deviceCredentials, listener);
+        mqttClient.on("v1/devices/me/rpc/request/+", listener, MqttQoS.AT_LEAST_ONCE);
+
+        // Send an RPC from the server
+        JsonObject serverRpcPayload = new JsonObject();
+        serverRpcPayload.addProperty("method", "getValue");
+        serverRpcPayload.addProperty("params", true);
+        ListeningExecutorService service = MoreExecutors.listeningDecorator(Executors.newSingleThreadExecutor());
+        ListenableFuture<ResponseEntity> future = service.submit(() -> {
+            try {
+                return restClient.getRestTemplate()
+                        .postForEntity(HTTPS_URL + "/api/plugins/rpc/twoway/{deviceId}",
+                                mapper.readTree(serverRpcPayload.toString()), String.class,
+                                device.getId());
+            } catch (IOException e) {
+                return ResponseEntity.badRequest().build();
+            }
+        });
+
+        // Wait for RPC call from the server and send the response
+        MqttEvent requestFromServer = listener.getEvents().poll(10, TimeUnit.SECONDS);
+
+        Assert.assertEquals("{\"method\":\"getValue\",\"params\":true}", Objects.requireNonNull(requestFromServer).getMessage());
+
+        Integer requestId = Integer.valueOf(Objects.requireNonNull(requestFromServer).getTopic().substring("v1/devices/me/rpc/request/".length()));
+        JsonObject clientResponse = new JsonObject();
+        clientResponse.addProperty("response", "someResponse");
+        // Send a response to the server's RPC request
+        mqttClient.publish("v1/devices/me/rpc/response/" + requestId, Unpooled.wrappedBuffer(clientResponse.toString().getBytes()));
+
+        ResponseEntity serverResponse = future.get(5, TimeUnit.SECONDS);
+        Assert.assertTrue(serverResponse.getStatusCode().is2xxSuccessful());
+        Assert.assertEquals(clientResponse.toString(), serverResponse.getBody());
+
+        restClient.getRestTemplate().delete(HTTPS_URL + "/api/device/" + device.getId());
+    }
+
+    @Test
+    public void clientSideRpc() throws Exception {
+        restClient.login("tenant@thingsboard.org", "tenant");
+        Device device = createDevice("mqtt_");
+        DeviceCredentials deviceCredentials = restClient.getCredentials(device.getId());
+
+        MqttMessageListener listener = new MqttMessageListener();
+        MqttClient mqttClient = getMqttClient(deviceCredentials, listener);
+        mqttClient.on("v1/devices/me/rpc/request/+", listener, MqttQoS.AT_LEAST_ONCE);
+
+        // Get the default rule chain id to make it root again after test finished
+        RuleChainId defaultRuleChainId = getDefaultRuleChainId();
+
+        // Create a new root rule chain
+        RuleChainId ruleChainId = createRootRuleChainForRpcResponse();
+
+        // Send the request to the server
+        JsonObject clientRequest = new JsonObject();
+        clientRequest.addProperty("method", "getResponse");
+        clientRequest.addProperty("params", true);
+        Integer requestId = 42;
+        mqttClient.publish("v1/devices/me/rpc/request/" + requestId, Unpooled.wrappedBuffer(clientRequest.toString().getBytes()));
+
+        // Check the response from the server
+        TimeUnit.SECONDS.sleep(1);
+        MqttEvent responseFromServer = listener.getEvents().poll(1, TimeUnit.SECONDS);
+        Integer responseId = Integer.valueOf(Objects.requireNonNull(responseFromServer).getTopic().substring("v1/devices/me/rpc/response/".length()));
+        Assert.assertEquals(requestId, responseId);
+        Assert.assertEquals("requestReceived", mapper.readTree(responseFromServer.getMessage()).get("response").asText());
+
+        // Make the default rule chain a root again
+        ResponseEntity<RuleChain> rootRuleChainResponse = restClient.getRestTemplate()
+                .postForEntity(HTTPS_URL + "/api/ruleChain/{ruleChainId}/root",
+                        null,
+                        RuleChain.class,
+                        defaultRuleChainId);
+        Assert.assertTrue(rootRuleChainResponse.getStatusCode().is2xxSuccessful());
+
+        // Delete the created rule chain
+        restClient.getRestTemplate().delete(HTTPS_URL + "/api/ruleChain/{ruleChainId}", ruleChainId);
+        restClient.getRestTemplate().delete(HTTPS_URL + "/api/device/" + device.getId());
+    }
+
+    private RuleChainId createRootRuleChainForRpcResponse() throws Exception {
+        RuleChain newRuleChain = new RuleChain();
+        newRuleChain.setName("testRuleChain");
+        ResponseEntity<RuleChain> ruleChainResponse = restClient.getRestTemplate()
+                .postForEntity(HTTPS_URL + "/api/ruleChain",
+                        newRuleChain,
+                        RuleChain.class);
+        Assert.assertTrue(ruleChainResponse.getStatusCode().is2xxSuccessful());
+        RuleChain ruleChain = ruleChainResponse.getBody();
+
+        JsonNode configuration = mapper.readTree(this.getClass().getClassLoader().getResourceAsStream("RpcResponseRuleChainMetadata.json"));
+        RuleChainMetaData ruleChainMetaData = new RuleChainMetaData();
+        ruleChainMetaData.setRuleChainId(ruleChain.getId());
+        ruleChainMetaData.setFirstNodeIndex(configuration.get("firstNodeIndex").asInt());
+        ruleChainMetaData.setNodes(Arrays.asList(mapper.treeToValue(configuration.get("nodes"), RuleNode[].class)));
+        ruleChainMetaData.setConnections(Arrays.asList(mapper.treeToValue(configuration.get("connections"), NodeConnectionInfo[].class)));
+
+        ResponseEntity<RuleChainMetaData> ruleChainMetadataResponse = restClient.getRestTemplate()
+                .postForEntity(HTTPS_URL + "/api/ruleChain/metadata",
+                        ruleChainMetaData,
+                        RuleChainMetaData.class);
+        Assert.assertTrue(ruleChainMetadataResponse.getStatusCode().is2xxSuccessful());
+
+        // Set a new rule chain as root
+        ResponseEntity<RuleChain> rootRuleChainResponse = restClient.getRestTemplate()
+                .postForEntity(HTTPS_URL + "/api/ruleChain/{ruleChainId}/root",
+                        null,
+                        RuleChain.class,
+                        ruleChain.getId());
+        Assert.assertTrue(rootRuleChainResponse.getStatusCode().is2xxSuccessful());
+
+        return ruleChain.getId();
+    }
+
+    private RuleChainId getDefaultRuleChainId() {
+        ResponseEntity<TextPageData<RuleChain>> ruleChains = restClient.getRestTemplate().exchange(
+                HTTPS_URL + "/api/ruleChains?limit=40&textSearch=",
+                HttpMethod.GET,
+                null,
+                new ParameterizedTypeReference<TextPageData<RuleChain>>() {
+                });
+
+        Optional<RuleChain> defaultRuleChain = ruleChains.getBody().getData()
+                .stream()
+                .filter(RuleChain::isRoot)
+                .findFirst();
+        if (!defaultRuleChain.isPresent()) {
+            Assert.fail("Root rule chain wasn't found");
+        }
+        return defaultRuleChain.get().getId();
+    }
+
+    private MqttClient getMqttClient(DeviceCredentials deviceCredentials, MqttMessageListener listener) throws InterruptedException {
+        MqttClientConfig clientConfig = new MqttClientConfig();
+        clientConfig.setClientId("MQTT client from test");
+        clientConfig.setUsername(deviceCredentials.getCredentialsId());
+        MqttClient mqttClient = MqttClient.create(clientConfig, listener);
+        mqttClient.connect("localhost", 1883).sync();
+        return mqttClient;
+    }
+
+    @Data
+    private class MqttMessageListener implements MqttHandler {
+        private final BlockingQueue<MqttEvent> events;
+
+        private MqttMessageListener() {
+            events = new ArrayBlockingQueue<>(100);
+        }
+
+        @Override
+        public void onMessage(String topic, ByteBuf message) {
+            log.info("MQTT message [{}], topic [{}]", message.toString(StandardCharsets.UTF_8), topic);
+            events.add(new MqttEvent(topic, message.toString(StandardCharsets.UTF_8)));
+        }
+    }
+
+    @Data
+    private class MqttEvent {
+        private final String topic;
+        private final String message;
+    }
+}
diff --git a/msa/black-box-tests/src/test/java/org/thingsboard/server/msa/ContainerTestSuite.java b/msa/black-box-tests/src/test/java/org/thingsboard/server/msa/ContainerTestSuite.java
new file mode 100644
index 0000000..495fd94
--- /dev/null
+++ b/msa/black-box-tests/src/test/java/org/thingsboard/server/msa/ContainerTestSuite.java
@@ -0,0 +1,39 @@
+/**
+ * Copyright © 2016-2018 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.msa;
+
+import org.junit.ClassRule;
+import org.junit.extensions.cpsuite.ClasspathSuite;
+import org.junit.runner.RunWith;
+import org.testcontainers.containers.DockerComposeContainer;
+import org.testcontainers.containers.wait.strategy.Wait;
+
+import java.io.File;
+import java.time.Duration;
+
+@RunWith(ClasspathSuite.class)
+@ClasspathSuite.ClassnameFilters({"org.thingsboard.server.msa.*Test"})
+public class ContainerTestSuite {
+
+    @ClassRule
+    public static DockerComposeContainer composeContainer = new DockerComposeContainer(
+            new File("./../../docker/docker-compose.yml"),
+            new File("./../../docker/docker-compose.postgres.yml"))
+            .withPull(false)
+            .withLocalCompose(true)
+            .withTailChildContainers(true)
+            .withExposedService("tb-web-ui1", 8080, Wait.forHttp("/login").withStartupTimeout(Duration.ofSeconds(120)));
+}
diff --git a/msa/black-box-tests/src/test/java/org/thingsboard/server/msa/mapper/AttributesResponse.java b/msa/black-box-tests/src/test/java/org/thingsboard/server/msa/mapper/AttributesResponse.java
new file mode 100644
index 0000000..f9774ee
--- /dev/null
+++ b/msa/black-box-tests/src/test/java/org/thingsboard/server/msa/mapper/AttributesResponse.java
@@ -0,0 +1,26 @@
+/**
+ * Copyright © 2016-2018 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.msa.mapper;
+
+import lombok.Data;
+
+import java.util.Map;
+
+@Data
+public class AttributesResponse {
+    private Map<String, Object> client;
+    private Map<String, Object> shared;
+}
diff --git a/msa/black-box-tests/src/test/java/org/thingsboard/server/msa/mapper/WsTelemetryResponse.java b/msa/black-box-tests/src/test/java/org/thingsboard/server/msa/mapper/WsTelemetryResponse.java
new file mode 100644
index 0000000..b22244f
--- /dev/null
+++ b/msa/black-box-tests/src/test/java/org/thingsboard/server/msa/mapper/WsTelemetryResponse.java
@@ -0,0 +1,40 @@
+/**
+ * Copyright © 2016-2018 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.msa.mapper;
+
+import lombok.Data;
+
+import java.io.Serializable;
+import java.util.Collection;
+import java.util.List;
+import java.util.Map;
+import java.util.stream.Collectors;
+
+@Data
+public class WsTelemetryResponse implements Serializable {
+    private int subscriptionId;
+    private int errorCode;
+    private String errorMsg;
+    private Map<String, List<List<Object>>> data;
+    private Map<String, Object> latestValues;
+
+    public List<Object> getDataValuesByKey(String key) {
+        return data.entrySet().stream()
+                .filter(e -> e.getKey().equals(key))
+                .flatMap(e -> e.getValue().stream().flatMap(Collection::stream))
+                .collect(Collectors.toList());
+    }
+}
diff --git a/msa/black-box-tests/src/test/java/org/thingsboard/server/msa/WsClient.java b/msa/black-box-tests/src/test/java/org/thingsboard/server/msa/WsClient.java
new file mode 100644
index 0000000..a9835ed
--- /dev/null
+++ b/msa/black-box-tests/src/test/java/org/thingsboard/server/msa/WsClient.java
@@ -0,0 +1,76 @@
+/**
+ * Copyright © 2016-2018 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.msa;
+
+import com.fasterxml.jackson.databind.ObjectMapper;
+import lombok.extern.slf4j.Slf4j;
+import org.java_websocket.client.WebSocketClient;
+import org.java_websocket.handshake.ServerHandshake;
+import org.thingsboard.server.msa.mapper.WsTelemetryResponse;
+
+import java.io.IOException;
+import java.net.URI;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.TimeUnit;
+
+@Slf4j
+public class WsClient extends WebSocketClient {
+    private static final ObjectMapper mapper = new ObjectMapper();
+    private WsTelemetryResponse message;
+
+    private CountDownLatch latch = new CountDownLatch(1);;
+
+    public WsClient(URI serverUri) {
+        super(serverUri);
+    }
+
+    @Override
+    public void onOpen(ServerHandshake serverHandshake) {
+    }
+
+    @Override
+    public void onMessage(String message) {
+        try {
+            WsTelemetryResponse response = mapper.readValue(message, WsTelemetryResponse.class);
+            if (!response.getData().isEmpty()) {
+                this.message = response;
+                latch.countDown();
+            }
+        } catch (IOException e) {
+            log.error("ws message can't be read");
+        }
+    }
+
+    @Override
+    public void onClose(int code, String reason, boolean remote) {
+        log.info("ws is closed, due to [{}]", reason);
+    }
+
+    @Override
+    public void onError(Exception ex) {
+        ex.printStackTrace();
+    }
+
+    public WsTelemetryResponse getLastMessage() {
+        try {
+            latch.await(10, TimeUnit.SECONDS);
+            return this.message;
+        } catch (InterruptedException e) {
+            log.error("Timeout, ws message wasn't received");
+        }
+        return null;
+    }
+}
diff --git a/msa/black-box-tests/src/test/resources/RpcResponseRuleChainMetadata.json b/msa/black-box-tests/src/test/resources/RpcResponseRuleChainMetadata.json
new file mode 100644
index 0000000..09178ef
--- /dev/null
+++ b/msa/black-box-tests/src/test/resources/RpcResponseRuleChainMetadata.json
@@ -0,0 +1,59 @@
+{
+  "firstNodeIndex": 0,
+  "nodes": [
+    {
+      "additionalInfo": {
+        "layoutX": 325,
+        "layoutY": 150
+      },
+      "type": "org.thingsboard.rule.engine.filter.TbMsgTypeSwitchNode",
+      "name": "msgTypeSwitch",
+      "debugMode": true,
+      "configuration": {
+        "version": 0
+      }
+    },
+    {
+      "additionalInfo": {
+        "layoutX": 60,
+        "layoutY": 300
+      },
+      "type": "org.thingsboard.rule.engine.transform.TbTransformMsgNode",
+      "name": "formResponse",
+      "debugMode": true,
+      "configuration": {
+        "jsScript": "if (msg.method == \"getResponse\") {\n    return {msg: {\"response\": \"requestReceived\"}, metadata: metadata, msgType: msgType};\n}\n\nreturn {msg: msg, metadata: metadata, msgType: msgType};"
+      }
+    },
+    {
+      "additionalInfo": {
+        "layoutX": 450,
+        "layoutY": 300
+      },
+      "type": "org.thingsboard.rule.engine.rpc.TbSendRPCReplyNode",
+      "name": "rpcReply",
+      "debugMode": true,
+      "configuration": {
+        "requestIdMetaDataAttribute": "requestId"
+      }
+    }
+  ],
+  "connections": [
+    {
+      "fromIndex": 0,
+      "toIndex": 1,
+      "type": "RPC Request from Device"
+    },
+    {
+      "fromIndex": 1,
+      "toIndex": 2,
+      "type": "Success"
+    },
+    {
+      "fromIndex": 1,
+      "toIndex": 2,
+      "type": "Failure"
+    }
+  ],
+  "ruleChainConnections": null
+}
\ No newline at end of file

msa/pom.xml 3(+2 -1)

diff --git a/msa/pom.xml b/msa/pom.xml
index d6d6c8d..5a9d9ab 100644
--- a/msa/pom.xml
+++ b/msa/pom.xml
@@ -16,7 +16,7 @@
 
 -->
 <project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
-    xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
+         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
     <modelVersion>4.0.0</modelVersion>
     <parent>
         <groupId>org.thingsboard</groupId>
@@ -41,6 +41,7 @@
         <module>web-ui</module>
         <module>tb-node</module>
         <module>transport</module>
+        <module>black-box-tests</module>
     </modules>
 
     <build>

msa/tb/README.md 30(+21 -9)

diff --git a/msa/tb/README.md b/msa/tb/README.md
index 8ed84c5..e4b7400 100644
--- a/msa/tb/README.md
+++ b/msa/tb/README.md
@@ -25,11 +25,23 @@ Where:
 - `-v ~/.mytb-data:/data`   - mounts the host's dir `~/.mytb-data` to ThingsBoard DataBase data directory
 - `--name mytb`             - friendly local name of this machine
 - `thingsboard/tb`          - docker image, can be also `thingsboard/tb-postgres` or `thingsboard/tb-cassandra`
-    
-After executing this command you can open `http://{your-host-ip}:9090` in you browser (for ex. `http://localhost:9090`). You should see ThingsBoard login page.
+
+> **NOTE**: **Windows** users should use docker managed volume instead of host's dir. Create docker volume (for ex. `mytb-data`) before executing `docker run` command:
+> ```
+> $ docker create volume mytb-data
+> ```
+> After you can execute docker run command using `mytb-data` volume instead of `~/.mytb-data`.
+> In order to get access to necessary resources from external IP/Host on **Windows** machine, please execute the following commands:
+> ```
+> $ VBoxManage controlvm "default" natpf1 "tcp-port9090,tcp,,9090,,9090"  
+> $ VBoxManage controlvm "default" natpf1 "tcp-port1883,tcp,,1883,,1883"
+> $ VBoxManage controlvm "default" natpf1 "tcp-port5683,tcp,,5683,,5683"
+> ```
+
+After executing `docker run` command you can open `http://{your-host-ip}:9090` in you browser (for ex. `http://localhost:9090`). You should see ThingsBoard login page.
 Use the following default credentials:
 
-- **Systen Administrator**: sysadmin@thingsboard.org / sysadmin
+- **System Administrator**: sysadmin@thingsboard.org / sysadmin
 - **Tenant Administrator**: tenant@thingsboard.org / tenant
 - **Customer User**: customer@thingsboard.org / customer
     
@@ -39,21 +51,21 @@ You can detach from session terminal with `Ctrl-p` `Ctrl-q` - the container will
 
 To reattach to the terminal (to see ThingsBoard logs) run:
 
-`
+```
 $ docker attach mytb
-`
+```
 
 To stop the container:
 
-`
+```
 $ docker stop mytb
-`
+```
 
 To start the container:
 
-`
+```
 $ docker start mytb
-`
+```
 
 ## Upgrading
 
diff --git a/ui/src/app/common/thirdparty-fix.js b/ui/src/app/common/thirdparty-fix.js
index b00a782..af4222c 100644
--- a/ui/src/app/common/thirdparty-fix.js
+++ b/ui/src/app/common/thirdparty-fix.js
@@ -15,10 +15,13 @@
  */
 
 import tinycolor from 'tinycolor2';
+import moment from 'moment';
 
 export default angular.module('thingsboard.thirdpartyFix', [])
     .factory('Fullscreen', Fullscreen)
     .factory('$mdColorPicker', mdColorPicker)
+    .provider('$mdpDatePicker', mdpDatePicker)
+    .provider('$mdpTimePicker', mdpTimePicker)
     .name;
 
 /*@ngInject*/
@@ -193,3 +196,264 @@ function mdColorPicker($q, $mdDialog, mdColorPickerHistory) {
 
     /* eslint-enable angular/definedundefined */
 }
+
+function DatePickerCtrl($scope, $mdDialog, $mdMedia, $timeout, currentDate, options) {
+    var self = this;
+
+    this.date = moment(currentDate);
+    this.minDate = options.minDate && moment(options.minDate).isValid() ? moment(options.minDate) : null;
+    this.maxDate = options.maxDate && moment(options.maxDate).isValid() ? moment(options.maxDate) : null;
+    this.displayFormat = options.displayFormat || "ddd, MMM DD";
+    this.dateFilter = angular.isFunction(options.dateFilter) ? options.dateFilter : null;
+    this.selectingYear = false;
+
+    // validate min and max date
+    if (this.minDate && this.maxDate) {
+        if (this.maxDate.isBefore(this.minDate)) {
+            this.maxDate = moment(this.minDate).add(1, 'days');
+        }
+    }
+
+    if (this.date) {
+        // check min date
+        if (this.minDate && this.date.isBefore(this.minDate)) {
+            this.date = moment(this.minDate);
+        }
+
+        // check max date
+        if (this.maxDate && this.date.isAfter(this.maxDate)) {
+            this.date = moment(this.maxDate);
+        }
+    }
+
+    this.yearItems = {
+        currentIndex_: 0,
+        PAGE_SIZE: 5,
+        START: (self.minDate ? self.minDate.year() : 1900),
+        END: (self.maxDate ? self.maxDate.year() : 0),
+        getItemAtIndex: function(index) {
+            if(this.currentIndex_ < index)
+                this.currentIndex_ = index;
+
+            return this.START + index;
+        },
+        getLength: function() {
+            return Math.min(
+                this.currentIndex_ + Math.floor(this.PAGE_SIZE / 2),
+                Math.abs(this.START - this.END) + 1
+            );
+        }
+    };
+
+    $scope.$mdMedia = $mdMedia;
+    $scope.year = this.date.year();
+
+    this.selectYear = function(year) {
+        self.date.year(year);
+        $scope.year = year;
+        self.selectingYear = false;
+        self.animate();
+    };
+
+    this.showYear = function() {
+        self.yearTopIndex = (self.date.year() - self.yearItems.START) + Math.floor(self.yearItems.PAGE_SIZE / 2);
+        self.yearItems.currentIndex_ = (self.date.year() - self.yearItems.START) + 1;
+        self.selectingYear = true;
+    };
+
+    this.showCalendar = function() {
+        self.selectingYear = false;
+    };
+
+    this.cancel = function() {
+        $mdDialog.cancel();
+    };
+
+    this.confirm = function() {
+        var date = this.date;
+
+        if (this.minDate && this.date.isBefore(this.minDate)) {
+            date = moment(this.minDate);
+        }
+
+        if (this.maxDate && this.date.isAfter(this.maxDate)) {
+            date = moment(this.maxDate);
+        }
+
+        $mdDialog.hide(date.toDate());
+    };
+
+    this.animate = function() {
+        self.animating = true;
+        $timeout(angular.noop).then(function() {
+            self.animating = false;
+        })
+    };
+}
+
+/*@ngInject*/
+function mdpDatePicker() {
+    var LABEL_OK = "OK",
+        LABEL_CANCEL = "Cancel",
+        DISPLAY_FORMAT = "ddd, MMM DD";
+
+    this.setDisplayFormat = function(format) {
+        DISPLAY_FORMAT = format;
+    };
+
+    this.setOKButtonLabel = function(label) {
+        LABEL_OK = label;
+    };
+
+    this.setCancelButtonLabel = function(label) {
+        LABEL_CANCEL = label;
+    };
+
+    /*@ngInject*/
+    this.$get = function($mdDialog) {
+        var datePicker = function(currentDate, options) {
+            if (!angular.isDate(currentDate)) currentDate = Date.now();
+            if (!angular.isObject(options)) options = {};
+
+            options.displayFormat = DISPLAY_FORMAT;
+
+            return $mdDialog.show({
+                controller:  ['$scope', '$mdDialog', '$mdMedia', '$timeout', 'currentDate', 'options', DatePickerCtrl],
+                controllerAs: 'datepicker',
+                clickOutsideToClose: true,
+                template: '<md-dialog aria-label="" class="mdp-datepicker" ng-class="{ \'portrait\': !$mdMedia(\'gt-xs\') }">' +
+                '<md-dialog-content layout="row" layout-wrap>' +
+                '<div layout="column" layout-align="start center">' +
+                '<md-toolbar layout-align="start start" flex class="mdp-datepicker-date-wrapper md-hue-1 md-primary" layout="column">' +
+                '<span class="mdp-datepicker-year" ng-click="datepicker.showYear()" ng-class="{ \'active\': datepicker.selectingYear }">{{ datepicker.date.format(\'YYYY\') }}</span>' +
+                '<span class="mdp-datepicker-date" ng-click="datepicker.showCalendar()" ng-class="{ \'active\': !datepicker.selectingYear }">{{ datepicker.date.format(datepicker.displayFormat) }}</span> ' +
+                '</md-toolbar>' +
+                '</div>' +
+                '<div>' +
+                '<div class="mdp-datepicker-select-year mdp-animation-zoom" layout="column" layout-align="center start" ng-if="datepicker.selectingYear">' +
+                '<md-virtual-repeat-container md-auto-shrink md-top-index="datepicker.yearTopIndex">' +
+                '<div flex md-virtual-repeat="item in datepicker.yearItems" md-on-demand class="repeated-year">' +
+                '<span class="md-button" ng-click="datepicker.selectYear(item)" md-ink-ripple ng-class="{ \'md-primary current\': item == year }">{{ item }}</span>' +
+                '</div>' +
+                '</md-virtual-repeat-container>' +
+                '</div>' +
+                '<mdp-calendar ng-if="!datepicker.selectingYear" class="mdp-animation-zoom" date="datepicker.date" min-date="datepicker.minDate" date-filter="datepicker.dateFilter" max-date="datepicker.maxDate"></mdp-calendar>' +
+                '<md-dialog-actions layout="row">' +
+                '<span flex></span>' +
+                '<md-button ng-click="datepicker.cancel()" aria-label="' + LABEL_CANCEL + '">' + LABEL_CANCEL + '</md-button>' +
+                '<md-button ng-click="datepicker.confirm()" class="md-primary" aria-label="' + LABEL_OK + '">' + LABEL_OK + '</md-button>' +
+                '</md-dialog-actions>' +
+                '</div>' +
+                '</md-dialog-content>' +
+                '</md-dialog>',
+                targetEvent: options.targetEvent,
+                locals: {
+                    currentDate: currentDate,
+                    options: options
+                },
+                multiple: true
+            });
+        };
+
+        return datePicker;
+    };
+
+}
+
+function TimePickerCtrl($scope, $mdDialog, time, autoSwitch, $mdMedia) {
+    var self = this;
+    this.VIEW_HOURS = 1;
+    this.VIEW_MINUTES = 2;
+    this.currentView = this.VIEW_HOURS;
+    this.time = moment(time);
+    this.autoSwitch = !!autoSwitch;
+
+    this.clockHours = parseInt(this.time.format("h"));
+    this.clockMinutes = parseInt(this.time.minutes());
+
+    $scope.$mdMedia = $mdMedia;
+
+    this.switchView = function() {
+        self.currentView = self.currentView == self.VIEW_HOURS ? self.VIEW_MINUTES : self.VIEW_HOURS;
+    };
+
+    this.setAM = function() {
+        if(self.time.hours() >= 12)
+            self.time.hour(self.time.hour() - 12);
+    };
+
+    this.setPM = function() {
+        if(self.time.hours() < 12)
+            self.time.hour(self.time.hour() + 12);
+    };
+
+    this.cancel = function() {
+        $mdDialog.cancel();
+    };
+
+    this.confirm = function() {
+        $mdDialog.hide(this.time.toDate());
+    };
+}
+
+/*@ngInject*/
+function mdpTimePicker() {
+    var LABEL_OK = "OK",
+        LABEL_CANCEL = "Cancel";
+
+    this.setOKButtonLabel = function(label) {
+        LABEL_OK = label;
+    };
+
+    this.setCancelButtonLabel = function(label) {
+        LABEL_CANCEL = label;
+    };
+
+    /*@ngInject*/
+    this.$get = function($mdDialog) {
+        var timePicker = function(time, options) {
+            if(!angular.isDate(time)) time = Date.now();
+            if (!angular.isObject(options)) options = {};
+
+            return $mdDialog.show({
+                controller:  ['$scope', '$mdDialog', 'time', 'autoSwitch', '$mdMedia', TimePickerCtrl],
+                controllerAs: 'timepicker',
+                clickOutsideToClose: true,
+                template: '<md-dialog aria-label="" class="mdp-timepicker" ng-class="{ \'portrait\': !$mdMedia(\'gt-xs\') }">' +
+                '<md-dialog-content layout-gt-xs="row" layout-wrap>' +
+                '<md-toolbar layout-gt-xs="column" layout-xs="row" layout-align="center center" flex class="mdp-timepicker-time md-hue-1 md-primary">' +
+                '<div class="mdp-timepicker-selected-time">' +
+                '<span ng-class="{ \'active\': timepicker.currentView == timepicker.VIEW_HOURS }" ng-click="timepicker.currentView = timepicker.VIEW_HOURS">{{ timepicker.time.format("h") }}</span>:' +
+                '<span ng-class="{ \'active\': timepicker.currentView == timepicker.VIEW_MINUTES }" ng-click="timepicker.currentView = timepicker.VIEW_MINUTES">{{ timepicker.time.format("mm") }}</span>' +
+                '</div>' +
+                '<div layout="column" class="mdp-timepicker-selected-ampm">' +
+                '<span ng-click="timepicker.setAM()" ng-class="{ \'active\': timepicker.time.hours() < 12 }">AM</span>' +
+                '<span ng-click="timepicker.setPM()" ng-class="{ \'active\': timepicker.time.hours() >= 12 }">PM</span>' +
+                '</div>' +
+                '</md-toolbar>' +
+                '<div>' +
+                '<div class="mdp-clock-switch-container" ng-switch="timepicker.currentView" layout layout-align="center center">' +
+                '<mdp-clock class="mdp-animation-zoom" auto-switch="timepicker.autoSwitch" time="timepicker.time" type="hours" ng-switch-when="1"></mdp-clock>' +
+                '<mdp-clock class="mdp-animation-zoom" auto-switch="timepicker.autoSwitch" time="timepicker.time" type="minutes" ng-switch-when="2"></mdp-clock>' +
+                '</div>' +
+
+                '<md-dialog-actions layout="row">' +
+                '<span flex></span>' +
+                '<md-button ng-click="timepicker.cancel()" aria-label="' + LABEL_CANCEL + '">' + LABEL_CANCEL + '</md-button>' +
+                '<md-button ng-click="timepicker.confirm()" class="md-primary" aria-label="' + LABEL_OK + '">' + LABEL_OK + '</md-button>' +
+                '</md-dialog-actions>' +
+                '</div>' +
+                '</md-dialog-content>' +
+                '</md-dialog>',
+                targetEvent: options.targetEvent,
+                locals: {
+                    time: time,
+                    autoSwitch: options.autoSwitch
+                },
+                multiple: true
+            });
+        };
+
+        return timePicker;
+    };
+}
diff --git a/ui/src/app/dashboard/dashboard.tpl.html b/ui/src/app/dashboard/dashboard.tpl.html
index 79653a4..a1bb268 100644
--- a/ui/src/app/dashboard/dashboard.tpl.html
+++ b/ui/src/app/dashboard/dashboard.tpl.html
@@ -74,6 +74,7 @@
                         <md-icon aria-label="{{ 'dashboard.settings' | translate }}" class="material-icons">settings</md-icon>
                     </md-button>
                     <tb-dashboard-select   ng-show="!vm.isEdit && !vm.widgetEditMode && vm.displayDashboardsSelect()"
+                                           md-theme="tb-dark"
                                            ng-model="vm.currentDashboardId"
                                            dashboards-scope="{{vm.currentDashboardScope}}"
                                            customer-id="vm.currentCustomerId">
diff --git a/ui/src/app/entity-view/entity-view.directive.js b/ui/src/app/entity-view/entity-view.directive.js
index 25377f4..761930e 100644
--- a/ui/src/app/entity-view/entity-view.directive.js
+++ b/ui/src/app/entity-view/entity-view.directive.js
@@ -98,8 +98,8 @@ export default function EntityViewDirective($q, $compile, $templateCache, $filte
                 if (newDate.getTime() > scope.maxStartTimeMs) {
                     scope.startTimeMs = angular.copy(scope.maxStartTimeMs);
                 }
-                updateMinMaxDates();
             }
+            updateMinMaxDates();
         });
 
         scope.$watch('endTimeMs', function (newDate) {
@@ -107,18 +107,24 @@ export default function EntityViewDirective($q, $compile, $templateCache, $filte
                 if (newDate.getTime() < scope.minEndTimeMs) {
                     scope.endTimeMs = angular.copy(scope.minEndTimeMs);
                 }
-                updateMinMaxDates();
             }
+            updateMinMaxDates();
         });
 
         function updateMinMaxDates() {
-            if (scope.endTimeMs) {
-                scope.maxStartTimeMs = angular.copy(new Date(scope.endTimeMs.getTime()));
-                scope.entityView.endTimeMs = scope.endTimeMs.getTime();
-            }
-            if (scope.startTimeMs) {
-                scope.minEndTimeMs = angular.copy(new Date(scope.startTimeMs.getTime()));
-                scope.entityView.startTimeMs = scope.startTimeMs.getTime();
+            if (scope.entityView) {
+                if (scope.endTimeMs) {
+                    scope.maxStartTimeMs = angular.copy(new Date(scope.endTimeMs.getTime()));
+                    scope.entityView.endTimeMs = scope.endTimeMs.getTime();
+                } else {
+                    scope.entityView.endTimeMs = 0;
+                }
+                if (scope.startTimeMs) {
+                    scope.minEndTimeMs = angular.copy(new Date(scope.startTimeMs.getTime()));
+                    scope.entityView.startTimeMs = scope.startTimeMs.getTime();
+                } else {
+                    scope.entityView.startTimeMs = 0;
+                }
             }
         }
 
diff --git a/ui/src/app/locale/locale.constant-en_US.json b/ui/src/app/locale/locale.constant-en_US.json
index b3c89c7..30e1eb1 100644
--- a/ui/src/app/locale/locale.constant-en_US.json
+++ b/ui/src/app/locale/locale.constant-en_US.json
@@ -774,6 +774,7 @@
     },
     "entity-view": {
         "entity-view": "Entity View",
+        "entity-view-required": "Entity view is required.",
         "entity-views": "Entity Views",
         "management": "Entity View management",
         "view-entity-views": "View Entity Views",