thingsboard-aplcache
Changes
application/src/main/java/org/thingsboard/server/service/cluster/discovery/ZkDiscoveryService.java 48(+39 -9)
dao/src/test/java/org/thingsboard/server/dao/service/timeseries/BaseTimeseriesServiceTest.java 22(+21 -1)
msa/black-box-tests/pom.xml 104(+104 -0)
msa/black-box-tests/README.md 21(+21 -0)
msa/black-box-tests/src/test/java/org/thingsboard/server/msa/AbstractContainerTest.java 175(+175 -0)
msa/black-box-tests/src/test/java/org/thingsboard/server/msa/connectivity/HttpClientTest.java 57(+57 -0)
msa/black-box-tests/src/test/java/org/thingsboard/server/msa/connectivity/MqttClientTest.java 392(+392 -0)
msa/black-box-tests/src/test/java/org/thingsboard/server/msa/mapper/AttributesResponse.java 26(+26 -0)
msa/black-box-tests/src/test/java/org/thingsboard/server/msa/mapper/WsTelemetryResponse.java 40(+40 -0)
msa/pom.xml 3(+2 -1)
msa/tb/README.md 30(+21 -9)
ui/src/app/common/thirdparty-fix.js 264(+264 -0)
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<>();
msa/black-box-tests/pom.xml 104(+104 -0)
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>
msa/black-box-tests/README.md 21(+21 -0)
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
ui/src/app/common/thirdparty-fix.js 264(+264 -0)
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",